tinyrelaycommit

Commit 0921e2e8e581

browse files at this commit | history

1diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
2index 83c3816..6c5dc7b 100644
3--- a/internal/daemon/client_browse.go
4+++ b/internal/daemon/client_browse.go
5@@ -30,7 +30,7 @@ type clientBrowseRequest struct {
6 }
7
8 func clientBrowseMethod(method string) bool {
9- return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)
10+ return wikiBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)
11 }
12
13 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
14@@ -54,6 +54,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
15 if err := t.browseRead(ctx, actor); err != nil {
16 return nil, err
17 }
18+ if wikiBrowseMethod(method) {
19+ return t.executeWiki(ctx, actor, method, params)
20+ }
21 q := clientBrowseRequest{}
22 if len(params) > 0 {
23 if err := json.Unmarshal(params[0], &q); err != nil {
24diff --git a/internal/daemon/services.go b/internal/daemon/services.go
25index 6c7a924..e3df7ae 100644
26--- a/internal/daemon/services.go
27+++ b/internal/daemon/services.go
28@@ -659,6 +659,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
29 _, err := t.store.Save(ctx, e, opts)
30 if err == nil {
31 t.notifyDevices(ctx, e)
32+ t.notifyWikiMerge(ctx, e)
33 }
34 return err
35 }
36diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
37index 1d049f2..56e8a6c 100644
38--- a/internal/daemon/tenant.go
39+++ b/internal/daemon/tenant.go
40@@ -370,6 +370,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
41 return "", err
42 }
43 t.notifyDevices(ctx, e)
44+ t.notifyWikiMerge(ctx, e)
45 // Stage Git metadata before acknowledging the event. This closes the
46 // publish-ACK/receive-pack race: the signed pending refs and hook exist
47 // before a client can push objects for the state.
48diff --git a/internal/daemon/wiki.go b/internal/daemon/wiki.go
49new file mode 100644
50index 0000000..8f8af8e
51--- /dev/null
52+++ b/internal/daemon/wiki.go
53@@ -0,0 +1,341 @@
54+package daemon
55+
56+// NIP-54 wiki rules that sit on top of stored events: how a merge request is
57+// read, how its answer is derived from the destination author's reactions,
58+// and the device notice sent when one arrives. The relay never merges on
59+// its own; an accepted merge request is applied when the destination author
60+// publishes a new version of their article.
61+
62+import (
63+ "context"
64+ "net/url"
65+ "sort"
66+ "strconv"
67+ "strings"
68+ "time"
69+
70+ "github.com/FelineStateMachine/tinyrelay/internal/event"
71+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
72+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"
73+)
74+
75+const (
76+ kindWikiArticle = 30818
77+ kindWikiMerge = 818
78+ kindWikiRedirect = 30819
79+ kindReaction = 7
80+ wikiQueryLimit = 2000
81+)
82+
83+const (
84+ wikiMergeOpen = "open"
85+ wikiMergeAccepted = "accepted"
86+ wikiMergeRejected = "rejected"
87+)
88+
89+type wikiRef struct {
90+ Coordinate string `json:"a,omitempty"`
91+ Event string `json:"e,omitempty"`
92+}
93+
94+type wikiVersion struct {
95+ ID string `json:"id"`
96+ Author string `json:"author"`
97+ CreatedAt int64 `json:"created_at"`
98+ D string `json:"d"`
99+ Title string `json:"title"`
100+ Summary string `json:"summary,omitempty"`
101+ Coordinate string `json:"coordinate"`
102+ Likes int `json:"likes"`
103+ Fork *wikiRef `json:"fork,omitempty"`
104+ Defer *wikiRef `json:"defer,omitempty"`
105+ Content string `json:"content,omitempty"`
106+ Links []string `json:"links,omitempty"`
107+}
108+
109+type wikiAnswer struct {
110+ Status string `json:"status"`
111+ AnsweredAt int64 `json:"answered_at,omitempty"`
112+ Reaction string `json:"reaction,omitempty"`
113+}
114+
115+type wikiMerge struct {
116+ ID string `json:"id"`
117+ Author string `json:"author"`
118+ CreatedAt int64 `json:"created_at"`
119+ Content string `json:"content"`
120+ Target string `json:"target"`
121+ TargetD string `json:"target_d"`
122+ Destination string `json:"destination"`
123+ Base string `json:"base,omitempty"`
124+ Source string `json:"source,omitempty"`
125+ wikiAnswer
126+}
127+
128+type wikiRedirect struct {
129+ ID string `json:"id"`
130+ Author string `json:"author"`
131+ CreatedAt int64 `json:"created_at"`
132+ D string `json:"d"`
133+ Target string `json:"target"`
134+ TargetD string `json:"target_d"`
135+ TargetAuthor string `json:"target_author"`
136+}
137+
138+// wikiCoordinate splits a "30818:<pubkey>:<d>" reference.
139+func wikiCoordinate(value string) (author, d string, ok bool) {
140+ parts := strings.SplitN(value, ":", 3)
141+ if len(parts) != 3 || parts[0] != strconv.Itoa(kindWikiArticle) || len(parts[1]) != 64 || parts[2] == "" {
142+ return "", "", false
143+ }
144+ return parts[1], parts[2], true
145+}
146+
147+func wikiVersionFrom(e event.Event, full bool) wikiVersion {
148+ d := event.Tag(e, "d")
149+ v := wikiVersion{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: d, Title: event.Tag(e, "title"), Summary: event.Tag(e, "summary"), Coordinate: strconv.Itoa(kindWikiArticle) + ":" + e.PubKey + ":" + d}
150+ if v.Title == "" {
151+ v.Title = d
152+ }
153+ for _, tag := range e.Tags {
154+ if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") || (tag[0] != "a" && tag[0] != "e") {
155+ continue
156+ }
157+ ref := &v.Fork
158+ if tag[3] == "defer" {
159+ ref = &v.Defer
160+ }
161+ if *ref == nil {
162+ *ref = &wikiRef{}
163+ }
164+ if tag[0] == "a" {
165+ (*ref).Coordinate = tag[1]
166+ } else {
167+ (*ref).Event = tag[1]
168+ }
169+ }
170+ if full {
171+ v.Content = e.Content
172+ v.Links = wiki.Links(e.Content)
173+ }
174+ return v
175+}
176+
177+// wikiMergeFrom reads a kind 818 request. The proposed version is the e tag
178+// with the source marker, or a source tag, or the only plain e tag; when two
179+// plain e tags are present the first is the base and the last the proposal.
180+func wikiMergeFrom(e event.Event) wikiMerge {
181+ m := wikiMerge{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, Content: e.Content, Target: event.Tag(e, "a"), wikiAnswer: wikiAnswer{Status: wikiMergeOpen}}
182+ m.Destination, m.TargetD, _ = wikiCoordinate(m.Target)
183+ if p := event.Tag(e, "p"); len(p) == 64 {
184+ m.Destination = p
185+ }
186+ var plain []string
187+ for _, tag := range e.Tags {
188+ if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {
189+ continue
190+ }
191+ if len(tag) >= 4 && tag[3] == "source" {
192+ m.Source = tag[1]
193+ } else {
194+ plain = append(plain, tag[1])
195+ }
196+ }
197+ if m.Source == "" {
198+ if source := event.Tag(e, "source"); len(source) == 64 {
199+ m.Source = source
200+ }
201+ }
202+ switch {
203+ case m.Source == "" && len(plain) == 1:
204+ m.Source = plain[0]
205+ case m.Source == "" && len(plain) > 1:
206+ m.Base, m.Source = plain[0], plain[len(plain)-1]
207+ case len(plain) > 0:
208+ m.Base = plain[0]
209+ }
210+ return m
211+}
212+
213+func wikiRedirectFrom(e event.Event) wikiRedirect {
214+ r := wikiRedirect{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: event.Tag(e, "d"), Target: event.Tag(e, "a")}
215+ r.TargetAuthor, r.TargetD, _ = wikiCoordinate(r.Target)
216+ return r
217+}
218+
219+// wikiMergeStatus reports how the destination author answered a merge
220+// request: a NIP-25 reaction from that pubkey tagging the request with "+"
221+// accepts it and "-" rejects it. The newest reaction wins.
222+func (t *Tenant) wikiMergeStatus(ctx context.Context, mergeRequestID, destination string) (wikiAnswer, error) {
223+ answers, err := t.wikiMergeStatuses(ctx, map[string]string{mergeRequestID: destination})
224+ if err != nil {
225+ return wikiAnswer{}, err
226+ }
227+ return answers[mergeRequestID], nil
228+}
229+
230+// wikiMergeStatuses resolves a set of merge requests, keyed by request id
231+// with the destination pubkey as value, in one query.
232+func (t *Tenant) wikiMergeStatuses(ctx context.Context, destinations map[string]string) (map[string]wikiAnswer, error) {
233+ answers := make(map[string]wikiAnswer, len(destinations))
234+ ids := make([]string, 0, len(destinations))
235+ authors := make([]string, 0, len(destinations))
236+ for id, destination := range destinations {
237+ answers[id] = wikiAnswer{Status: wikiMergeOpen}
238+ ids = append(ids, id)
239+ authors = append(authors, destination)
240+ }
241+ if len(ids) == 0 {
242+ return answers, nil
243+ }
244+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
245+ if err != nil {
246+ return nil, err
247+ }
248+ reactions := rows.Events
249+ sortEventsNewestFirst(reactions)
250+ for _, reaction := range reactions {
251+ status := wikiReactionStatus(reaction.Content)
252+ if status == "" {
253+ continue
254+ }
255+ for _, id := range event.TagValues(reaction, "e") {
256+ if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen {
257+ continue
258+ }
259+ answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID}
260+ }
261+ }
262+ return answers, nil
263+}
264+
265+func wikiReactionStatus(content string) string {
266+ switch strings.TrimSpace(content) {
267+ case "+", "":
268+ return wikiMergeAccepted
269+ case "-":
270+ return wikiMergeRejected
271+ }
272+ return ""
273+}
274+
275+// wikiMemberLikes counts "+" reactions from the owner and members for each
276+// version, matched by event id or article coordinate.
277+func (t *Tenant) wikiMemberLikes(ctx context.Context, versions []wikiVersion) (map[string]int, error) {
278+ likes := make(map[string]int, len(versions))
279+ if len(versions) == 0 {
280+ return likes, nil
281+ }
282+ ids := make([]string, 0, len(versions))
283+ coordinates := make([]string, 0, len(versions))
284+ byCoordinate := make(map[string]string, len(versions))
285+ for _, v := range versions {
286+ likes[v.ID] = 0
287+ ids = append(ids, v.ID)
288+ coordinates = append(coordinates, v.Coordinate)
289+ byCoordinate[v.Coordinate] = v.ID
290+ }
291+ var reactions []event.Event
292+ for _, tags := range []map[string][]string{{"e": ids}, {"a": coordinates}} {
293+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: tags}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
294+ if err != nil {
295+ return nil, err
296+ }
297+ reactions = append(reactions, rows.Events...)
298+ }
299+ members := map[string]bool{t.Policy().Owner: true}
300+ counted := map[string]bool{}
301+ for _, reaction := range uniqueEvents(reactions) {
302+ if wikiReactionStatus(reaction.Content) != wikiMergeAccepted {
303+ continue
304+ }
305+ if _, ok := members[reaction.PubKey]; !ok {
306+ member, err := t.community.IsMember(ctx, reaction.PubKey)
307+ if err != nil {
308+ return nil, err
309+ }
310+ members[reaction.PubKey] = member
311+ }
312+ if !members[reaction.PubKey] {
313+ continue
314+ }
315+ targets := event.TagValues(reaction, "e")
316+ for _, coordinate := range event.TagValues(reaction, "a") {
317+ if id, ok := byCoordinate[coordinate]; ok {
318+ targets = append(targets, id)
319+ }
320+ }
321+ for _, id := range targets {
322+ key := reaction.PubKey + "\x00" + id
323+ if _, known := likes[id]; !known || counted[key] {
324+ continue
325+ }
326+ counted[key] = true
327+ likes[id]++
328+ }
329+ }
330+ return likes, nil
331+}
332+
333+// wikiPreferred picks the version to show: the requested author, else the
334+// tenant owner, else the most member likes, else the newest. Versions must
335+// already be sorted newest first.
336+func wikiPreferred(versions []wikiVersion, author, owner string) (wikiVersion, string) {
337+ for _, v := range versions {
338+ if author != "" && v.Author == author {
339+ return v, "author"
340+ }
341+ }
342+ for _, v := range versions {
343+ if v.Author == owner {
344+ return v, "owner"
345+ }
346+ }
347+ best := versions[0]
348+ for _, v := range versions[1:] {
349+ if v.Likes > best.Likes {
350+ best = v
351+ }
352+ }
353+ if best.Likes > 0 {
354+ return best, "reactions"
355+ }
356+ return versions[0], "newest"
357+}
358+
359+func sortWikiVersions(versions []wikiVersion) {
360+ sort.Slice(versions, func(i, j int) bool {
361+ if versions[i].CreatedAt == versions[j].CreatedAt {
362+ return versions[i].ID < versions[j].ID
363+ }
364+ return versions[i].CreatedAt > versions[j].CreatedAt
365+ })
366+}
367+
368+// notifyWikiMerge wakes the destination author's devices when a merge
369+// request for one of their articles arrives from someone else.
370+func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {
371+ if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) {
372+ return
373+ }
374+ m := wikiMergeFrom(e)
375+ if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" {
376+ return
377+ }
378+ role, err := t.community.Role(ctx, m.Destination)
379+ if err != nil || role == "" {
380+ return
381+ }
382+ if !t.pushCoalesced(m.Destination, pushReplies) {
383+ return
384+ }
385+ title := m.TargetD
386+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{m.Destination}, Tags: map[string][]string{"d": {m.TargetD}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
387+ if err == nil && len(rows.Events) == 1 {
388+ title = wikiVersionFrom(rows.Events[0], false).Title
389+ }
390+ notice := pushNotice{recipient: m.Destination, category: pushReplies, body: "Merge request for " + excerpt(title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(m.TargetD) + "?merge=" + e.ID}
391+ if err := t.enqueuePushNotice(ctx, notice); err != nil {
392+ t.app.telemetry.Logger().Debug("wiki merge notification not queued", "error", err)
393+ }
394+}
395diff --git a/internal/daemon/wiki_browse.go b/internal/daemon/wiki_browse.go
396new file mode 100644
397index 0000000..b85519d
398--- /dev/null
399+++ b/internal/daemon/wiki_browse.go
400@@ -0,0 +1,346 @@
401+package daemon
402+
403+// Wiki browsing: the page list, one page with its versions, merge requests
404+// and redirects, and one merge request. Every read passes the tenant gate
405+// with the caller's session, so a members-only relay shows nothing to
406+// guests, and each event is checked with CanSee before it is returned.
407+
408+import (
409+ "context"
410+ "encoding/json"
411+ "errors"
412+ "fmt"
413+ "sort"
414+ "strings"
415+ "time"
416+
417+ "github.com/FelineStateMachine/tinyrelay/internal/event"
418+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
419+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
420+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"
421+)
422+
423+type wikiBrowseRequest struct {
424+ D string `json:"d"`
425+ Author string `json:"author"`
426+ Version string `json:"version"`
427+ ID string `json:"id"`
428+ Event string `json:"event"`
429+ Query string `json:"q"`
430+ Cursor string `json:"cursor"`
431+ Limit int `json:"limit"`
432+}
433+
434+type wikiPageItem struct {
435+ D string `json:"d"`
436+ Title string `json:"title"`
437+ Summary string `json:"summary,omitempty"`
438+ Version wikiVersion `json:"version"`
439+ Versions int `json:"versions"`
440+ OpenMerges int `json:"open_merges"`
441+}
442+
443+type wikiPage struct {
444+ D string `json:"d"`
445+ Title string `json:"title"`
446+ Version *wikiVersion `json:"version"`
447+ PreferredBy string `json:"preferred_by,omitempty"`
448+ Versions []wikiVersion `json:"versions"`
449+ Merges []wikiMerge `json:"merges"`
450+ RedirectsTo []wikiRedirect `json:"redirects_to"`
451+ RedirectsFrom []wikiRedirect `json:"redirects_from"`
452+}
453+
454+type wikiMergeDetail struct {
455+ Merge wikiMerge `json:"merge"`
456+ Proposed *wikiVersion `json:"proposed"`
457+ Target *wikiVersion `json:"target"`
458+}
459+
460+func wikiBrowseMethod(method string) bool {
461+ return method == "browsewiki" || method == "browsewikipage" || method == "browsewikimerge"
462+}
463+
464+func (t *Tenant) executeWiki(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
465+ q := wikiBrowseRequest{}
466+ if len(params) > 0 {
467+ if err := json.Unmarshal(params[0], &q); err != nil {
468+ return nil, fmt.Errorf("invalid: wiki parameters: %w", err)
469+ }
470+ }
471+ if q.Limit <= 0 || q.Limit > 100 {
472+ q.Limit = 50
473+ }
474+ if q.Author != "" && len(q.Author) != 64 {
475+ return nil, errors.New("invalid: wiki author")
476+ }
477+ switch method {
478+ case "browsewiki":
479+ return t.browseWiki(ctx, actor, q)
480+ case "browsewikipage":
481+ return t.browseWikiPage(ctx, actor, q)
482+ case "browsewikimerge":
483+ if q.Event == "" {
484+ q.Event = q.ID
485+ }
486+ return t.browseWikiMerge(ctx, actor, q)
487+ }
488+ return nil, errors.New("unsupported: browse operation")
489+}
490+
491+// wikiQuery reads visible events for the caller's session.
492+func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters ...event.Filter) ([]event.Event, error) {
493+ if _, err := t.gate.Read(ctx, filters, session); err != nil {
494+ return nil, err
495+ }
496+ var rows []event.Event
497+ for _, filter := range filters {
498+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: wikiQueryLimit})
499+ if err != nil {
500+ return nil, err
501+ }
502+ for _, row := range page.Events {
503+ if t.gate.CanSee(ctx, row, session, nil) {
504+ rows = append(rows, row)
505+ }
506+ }
507+ }
508+ rows = uniqueEvents(rows)
509+ sortEventsNewestFirst(rows)
510+ return rows, nil
511+}
512+
513+func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter event.Filter) ([]wikiVersion, error) {
514+ filter.Kinds = []int{kindWikiArticle}
515+ rows, err := t.wikiQuery(ctx, session, filter)
516+ if err != nil {
517+ return nil, err
518+ }
519+ versions := make([]wikiVersion, 0, len(rows))
520+ for _, row := range rows {
521+ if event.Tag(row, "d") != "" {
522+ versions = append(versions, wikiVersionFrom(row, false))
523+ }
524+ }
525+ return versions, nil
526+}
527+
528+// wikiMerges lists the merge requests aimed at any of the coordinates with
529+// their answers, newest first.
530+func (t *Tenant) wikiMerges(ctx context.Context, session relay.Session, coordinates []string) ([]wikiMerge, error) {
531+ if len(coordinates) == 0 {
532+ return []wikiMerge{}, nil
533+ }
534+ rows, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiMerge}, Tags: map[string][]string{"a": coordinates}})
535+ if err != nil {
536+ return nil, err
537+ }
538+ merges := make([]wikiMerge, 0, len(rows))
539+ destinations := make(map[string]string, len(rows))
540+ for _, row := range rows {
541+ m := wikiMergeFrom(row)
542+ if m.Destination == "" {
543+ continue
544+ }
545+ merges = append(merges, m)
546+ destinations[m.ID] = m.Destination
547+ }
548+ answers, err := t.wikiMergeStatuses(ctx, destinations)
549+ if err != nil {
550+ return nil, err
551+ }
552+ for i := range merges {
553+ merges[i].wikiAnswer = answers[merges[i].ID]
554+ }
555+ return merges, nil
556+}
557+
558+func wikiMatches(v wikiVersion, query string) bool {
559+ query = strings.ToLower(strings.TrimSpace(query))
560+ return query == "" || strings.Contains(strings.ToLower(v.Title), query) || strings.Contains(strings.ToLower(v.Summary), query) || strings.Contains(v.D, query)
561+}
562+
563+func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
564+ session := browseSession(t, actor)
565+ versions, err := t.wikiVersions(ctx, session, event.Filter{})
566+ if err != nil {
567+ return nil, err
568+ }
569+ pages := map[string][]wikiVersion{}
570+ for _, v := range versions {
571+ if v.D <= q.Cursor || !wikiMatches(v, q.Query) {
572+ continue
573+ }
574+ pages[v.D] = append(pages[v.D], v)
575+ }
576+ names := make([]string, 0, len(pages))
577+ for d := range pages {
578+ names = append(names, d)
579+ }
580+ sort.Strings(names)
581+ next := ""
582+ if len(names) > q.Limit {
583+ names = names[:q.Limit]
584+ next = names[len(names)-1]
585+ }
586+ owner := t.Policy().Owner
587+ items := make([]wikiPageItem, 0, len(names))
588+ for _, d := range names {
589+ page := pages[d]
590+ if err := t.wikiRankVersions(ctx, page, q.Author, owner); err != nil {
591+ return nil, err
592+ }
593+ preferred, _ := wikiPreferred(page, q.Author, owner)
594+ merges, err := t.wikiMerges(ctx, session, wikiCoordinates(page))
595+ if err != nil {
596+ return nil, err
597+ }
598+ open := 0
599+ for _, m := range merges {
600+ if m.Status == wikiMergeOpen {
601+ open++
602+ }
603+ }
604+ items = append(items, wikiPageItem{D: d, Title: preferred.Title, Summary: preferred.Summary, Version: preferred, Versions: len(page), OpenMerges: open})
605+ }
606+ return map[string]any{"items": items, "next_cursor": next}, nil
607+}
608+
609+// wikiRankVersions fills in member likes when they decide the preferred
610+// version, which is only when neither an explicit author nor the owner has
611+// one.
612+func (t *Tenant) wikiRankVersions(ctx context.Context, versions []wikiVersion, author, owner string) error {
613+ for _, v := range versions {
614+ if v.Author == owner || (author != "" && v.Author == author) {
615+ return nil
616+ }
617+ }
618+ likes, err := t.wikiMemberLikes(ctx, versions)
619+ if err != nil {
620+ return err
621+ }
622+ for i := range versions {
623+ versions[i].Likes = likes[versions[i].ID]
624+ }
625+ return nil
626+}
627+
628+func wikiCoordinates(versions []wikiVersion) []string {
629+ coordinates := make([]string, 0, len(versions))
630+ for _, v := range versions {
631+ coordinates = append(coordinates, v.Coordinate)
632+ }
633+ return coordinates
634+}
635+
636+func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
637+ d := wiki.Normalize(q.D)
638+ if d == "" {
639+ return nil, errors.New("invalid: wiki page name")
640+ }
641+ session := browseSession(t, actor)
642+ versions, err := t.wikiVersions(ctx, session, event.Filter{Tags: map[string][]string{"d": {d}}})
643+ if err != nil {
644+ return nil, err
645+ }
646+ redirects, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"d": {d}}}, event.Filter{Kinds: []int{kindWikiRedirect}, Tags: map[string][]string{"a": wikiCoordinates(versions)}})
647+ if err != nil {
648+ return nil, err
649+ }
650+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}}
651+ for _, row := range redirects {
652+ r := wikiRedirectFrom(row)
653+ if r.D == d && r.TargetD != "" && r.TargetD != d {
654+ page.RedirectsFrom = append(page.RedirectsFrom, r)
655+ } else if r.TargetD == d && r.D != d {
656+ page.RedirectsTo = append(page.RedirectsTo, r)
657+ }
658+ }
659+ if len(versions) == 0 && len(page.RedirectsFrom) == 0 {
660+ return nil, errors.New("not found: wiki page")
661+ }
662+ if len(versions) == 0 {
663+ return page, nil
664+ }
665+ likes, err := t.wikiMemberLikes(ctx, versions)
666+ if err != nil {
667+ return nil, err
668+ }
669+ for i := range versions {
670+ versions[i].Likes = likes[versions[i].ID]
671+ }
672+ owner := t.Policy().Owner
673+ preferred, by := wikiPreferred(versions, q.Author, owner)
674+ if q.Version != "" {
675+ by = "version"
676+ found := false
677+ for _, v := range versions {
678+ if v.ID == q.Version {
679+ preferred, found = v, true
680+ }
681+ }
682+ if !found {
683+ return nil, errors.New("not found: wiki version")
684+ }
685+ }
686+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})
687+ if err != nil {
688+ return nil, err
689+ }
690+ if len(rows) == 1 {
691+ full := wikiVersionFrom(rows[0], true)
692+ full.Likes = preferred.Likes
693+ preferred = full
694+ }
695+ page.Title = preferred.Title
696+ page.Version = &preferred
697+ page.PreferredBy = by
698+ page.Merges, err = t.wikiMerges(ctx, session, wikiCoordinates(versions))
699+ if err != nil {
700+ return nil, err
701+ }
702+ return page, nil
703+}
704+
705+func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
706+ if len(q.Event) != 64 {
707+ return nil, errors.New("invalid: wiki merge request")
708+ }
709+ session := browseSession(t, actor)
710+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{q.Event}, Kinds: []int{kindWikiMerge}, Limit: intPtr(1)})
711+ if err != nil {
712+ return nil, err
713+ }
714+ if len(rows) != 1 {
715+ return nil, errors.New("not found: wiki merge request")
716+ }
717+ detail := wikiMergeDetail{Merge: wikiMergeFrom(rows[0])}
718+ if detail.Merge.Destination == "" {
719+ return nil, errors.New("not found: wiki merge request has no destination")
720+ }
721+ detail.Merge.wikiAnswer, err = t.wikiMergeStatus(ctx, detail.Merge.ID, detail.Merge.Destination)
722+ if err != nil {
723+ return nil, err
724+ }
725+ if detail.Merge.Source != "" {
726+ proposed, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{detail.Merge.Source}, Kinds: []int{kindWikiArticle}, Limit: intPtr(1)})
727+ if err != nil {
728+ return nil, err
729+ }
730+ if len(proposed) == 1 {
731+ v := wikiVersionFrom(proposed[0], true)
732+ detail.Proposed = &v
733+ }
734+ }
735+ if detail.Merge.TargetD != "" {
736+ target, err := t.wikiQuery(ctx, session, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{detail.Merge.Destination}, Tags: map[string][]string{"d": {detail.Merge.TargetD}}, Limit: intPtr(1)})
737+ if err != nil {
738+ return nil, err
739+ }
740+ if len(target) == 1 {
741+ v := wikiVersionFrom(target[0], true)
742+ detail.Target = &v
743+ }
744+ }
745+ return detail, nil
746+}
747diff --git a/internal/daemon/wiki_test.go b/internal/daemon/wiki_test.go
748new file mode 100644
749index 0000000..cd680d7
750--- /dev/null
751+++ b/internal/daemon/wiki_test.go
752@@ -0,0 +1,369 @@
753+package daemon
754+
755+import (
756+ "context"
757+ "encoding/json"
758+ "fmt"
759+ "strings"
760+ "testing"
761+ "time"
762+
763+ "github.com/FelineStateMachine/tinyrelay/internal/event"
764+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
765+)
766+
767+const (
768+ wikiOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"
769+ wikiMemberSecret = "2222222222222222222222222222222222222222222222222222222222222222"
770+ wikiOtherSecret = "3333333333333333333333333333333333333333333333333333333333333333"
771+ wikiThirdSecret = "4444444444444444444444444444444444444444444444444444444444444444"
772+)
773+
774+func wikiKey(t *testing.T, secret string) string {
775+ t.Helper()
776+ pk, err := event.PublicKey(secret)
777+ if err != nil {
778+ t.Fatal(err)
779+ }
780+ return pk
781+}
782+
783+func wikiPublish(t *testing.T, tenant *Tenant, secret string, kind int, createdAt int64, content string, tags ...[]string) event.Event {
784+ t.Helper()
785+ e := event.Event{Kind: kind, CreatedAt: createdAt, Content: content, Tags: tags}
786+ if err := event.Sign(&e, secret); err != nil {
787+ t.Fatal(err)
788+ }
789+ if _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: tenant.RelayURL()}); err != nil {
790+ t.Fatalf("publish kind %d: %v", kind, err)
791+ }
792+ return e
793+}
794+
795+func wikiArticle(t *testing.T, tenant *Tenant, secret, title string, createdAt int64, extra ...[]string) event.Event {
796+ t.Helper()
797+ tags := append([][]string{{"d", strings.ToLower(strings.ReplaceAll(title, " ", "-"))}, {"title", title}, {"summary", "Summary about " + title}}, extra...)
798+ return wikiPublish(t, tenant, secret, kindWikiArticle, createdAt, "Content of "+title+" with a [[Wiki Link]].", tags...)
799+}
800+
801+func wikiMember(t *testing.T, tenant *Tenant, pubkey string) {
802+ t.Helper()
803+ if _, err := tenant.community.Execute(context.Background(), tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf("%q", pubkey)), json.RawMessage(fmt.Sprintf(`{"name":"member-%s"}`, pubkey[:8]))}); err != nil {
804+ t.Fatal(err)
805+ }
806+}
807+
808+func wikiCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) map[string]any {
809+ t.Helper()
810+ raw, err := json.Marshal(params)
811+ if err != nil {
812+ t.Fatal(err)
813+ }
814+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})
815+ if err != nil {
816+ t.Fatalf("%s %s: %v", method, raw, err)
817+ }
818+ encoded, err := json.Marshal(value)
819+ if err != nil {
820+ t.Fatal(err)
821+ }
822+ var result map[string]any
823+ if err := json.Unmarshal(encoded, &result); err != nil {
824+ t.Fatal(err)
825+ }
826+ return result
827+}
828+
829+func wikiItems(result map[string]any, key string) []map[string]any {
830+ var items []map[string]any
831+ for _, item := range result[key].([]any) {
832+ items = append(items, item.(map[string]any))
833+ }
834+ return items
835+}
836+
837+func TestWikiPreferenceOrder(t *testing.T) {
838+ _, tenant := testTenant(t)
839+ owner := tenant.Policy().Owner
840+ member, other, third := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret), wikiKey(t, wikiThirdSecret)
841+ wikiMember(t, tenant, member)
842+ wikiMember(t, tenant, third)
843+ now := time.Now().Unix()
844+
845+ ownerBitcoin := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)
846+ memberBitcoin := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)
847+ otherBitcoin := wikiArticle(t, tenant, wikiOtherSecret, "Bitcoin", now-100)
848+
849+ memberLightning := wikiArticle(t, tenant, wikiMemberSecret, "Lightning", now-300)
850+ otherLightning := wikiArticle(t, tenant, wikiOtherSecret, "Lightning", now-200)
851+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-150, "+", []string{"e", memberLightning.ID})
852+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-140, "", []string{"a", "30818:" + member + ":lightning"})
853+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-130, "-", []string{"e", memberLightning.ID})
854+ // Non-members do not count, and one member counts once per version.
855+ wikiPublish(t, tenant, wikiOtherSecret, kindReaction, now-120, "+", []string{"e", otherLightning.ID})
856+ wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-110, "+", []string{"e", memberLightning.ID})
857+
858+ wikiArticle(t, tenant, wikiMemberSecret, "Nostr", now-300)
859+ otherNostr := wikiArticle(t, tenant, wikiOtherSecret, "Nostr", now-200)
860+
861+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
862+ version := page["version"].(map[string]any)
863+ if version["author"] != owner || page["preferred_by"] != "owner" || version["id"] != ownerBitcoin.ID {
864+ t.Fatalf("owner preference %v", page)
865+ }
866+ if version["content"] == "" || fmt.Sprint(version["links"]) != "[wiki-link]" {
867+ t.Fatalf("preferred version content %v", version)
868+ }
869+ versions := wikiItems(page, "versions")
870+ if len(versions) != 3 || versions[0]["id"] != otherBitcoin.ID || versions[1]["id"] != memberBitcoin.ID || versions[2]["id"] != ownerBitcoin.ID {
871+ t.Fatalf("versions newest first %v", versions)
872+ }
873+ if _, ok := versions[0]["content"]; ok {
874+ t.Fatalf("version list carries content %v", versions[0])
875+ }
876+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "Bitcoin", "author": member})
877+ if page["version"].(map[string]any)["id"] != memberBitcoin.ID || page["preferred_by"] != "author" {
878+ t.Fatalf("author preference %v", page)
879+ }
880+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": otherBitcoin.ID})
881+ if page["version"].(map[string]any)["id"] != otherBitcoin.ID || page["preferred_by"] != "version" {
882+ t.Fatalf("version selection %v", page)
883+ }
884+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + strings.Repeat("f", 64) + `"}`)}); err == nil {
885+ t.Fatal("unknown version selected")
886+ }
887+
888+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "lightning"})
889+ version = page["version"].(map[string]any)
890+ if version["id"] != memberLightning.ID || page["preferred_by"] != "reactions" || version["likes"].(float64) != 2 {
891+ t.Fatalf("reaction preference %v", page)
892+ }
893+ for _, v := range wikiItems(page, "versions") {
894+ if v["id"] == otherLightning.ID && v["likes"].(float64) != 0 {
895+ t.Fatalf("non-member like counted %v", v)
896+ }
897+ }
898+
899+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "nostr"})
900+ if page["version"].(map[string]any)["id"] != otherNostr.ID || page["preferred_by"] != "newest" {
901+ t.Fatalf("newest preference %v", page)
902+ }
903+
904+ list := wikiCall(t, tenant, "", "browsewiki", nil)
905+ items := wikiItems(list, "items")
906+ if len(items) != 3 || items[0]["d"] != "bitcoin" || items[1]["d"] != "lightning" || items[2]["d"] != "nostr" || list["next_cursor"] != "" {
907+ t.Fatalf("page list %v", list)
908+ }
909+ if items[0]["versions"].(float64) != 3 || items[0]["version"].(map[string]any)["author"] != owner || items[1]["version"].(map[string]any)["id"] != memberLightning.ID {
910+ t.Fatalf("page list preference %v", items)
911+ }
912+ list = wikiCall(t, tenant, "", "browsewiki", map[string]any{"author": other})
913+ if wikiItems(list, "items")[0]["version"].(map[string]any)["author"] != other {
914+ t.Fatalf("author preference in list %v", list)
915+ }
916+}
917+
918+func TestWikiSearchAndPagination(t *testing.T) {
919+ _, tenant := testTenant(t)
920+ now := time.Now().Unix()
921+ names := []string{"Alpha", "Beta", "Delta", "Epsilon", "Gamma"}
922+ for i, name := range names {
923+ wikiArticle(t, tenant, wikiOwnerSecret, name, now-int64(i))
924+ }
925+ var got []string
926+ cursor := ""
927+ for page := 0; page < 5; page++ {
928+ result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"limit": 2, "cursor": cursor})
929+ for _, item := range wikiItems(result, "items") {
930+ got = append(got, item["d"].(string))
931+ }
932+ cursor = result["next_cursor"].(string)
933+ if cursor == "" {
934+ break
935+ }
936+ }
937+ if fmt.Sprint(got) != "[alpha beta delta epsilon gamma]" {
938+ t.Fatalf("paginated pages = %v", got)
939+ }
940+ result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "about BETA"})
941+ if items := wikiItems(result, "items"); len(items) != 1 || items[0]["d"] != "beta" {
942+ t.Fatalf("summary search %v", result)
943+ }
944+ result = wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "gamm"})
945+ if items := wikiItems(result, "items"); len(items) != 1 || items[0]["title"] != "Gamma" {
946+ t.Fatalf("title search %v", result)
947+ }
948+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "zeta"}), "items"); len(items) != 0 {
949+ t.Fatalf("unmatched search %v", items)
950+ }
951+}
952+
953+func TestWikiMergeRequestStatusFollowsDestinationReactions(t *testing.T) {
954+ _, tenant := testTenant(t)
955+ ctx := context.Background()
956+ owner := tenant.Policy().Owner
957+ member := wikiKey(t, wikiMemberSecret)
958+ wikiMember(t, tenant, member)
959+ now := time.Now().Unix()
960+ original := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)
961+ coordinate := "30818:" + owner + ":bitcoin"
962+ fork := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-400, []string{"a", coordinate, "", "fork"}, []string{"e", original.ID, "", "fork"})
963+ merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-300, "Added the block size limit", []string{"a", coordinate}, []string{"e", original.ID}, []string{"p", owner}, []string{"e", fork.ID, "", "source"})
964+
965+ detail := wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"id": merge.ID})
966+ m := detail["merge"].(map[string]any)
967+ if m["status"] != "open" || m["source"] != fork.ID || m["base"] != original.ID || m["destination"] != owner || m["target_d"] != "bitcoin" {
968+ t.Fatalf("merge detail %v", m)
969+ }
970+ if detail["proposed"].(map[string]any)["id"] != fork.ID || detail["target"].(map[string]any)["id"] != original.ID {
971+ t.Fatalf("merge versions %v", detail)
972+ }
973+ if proposed := detail["proposed"].(map[string]any); proposed["fork"].(map[string]any)["e"] != original.ID || proposed["fork"].(map[string]any)["a"] != coordinate {
974+ t.Fatalf("fork reference %v", proposed)
975+ }
976+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
977+ if merges := wikiItems(page, "merges"); len(merges) != 1 || merges[0]["id"] != merge.ID || merges[0]["status"] != "open" {
978+ t.Fatalf("page merges %v", page)
979+ }
980+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 1 {
981+ t.Fatalf("open merge count %v", items)
982+ }
983+
984+ // Only the destination author's reaction answers the request.
985+ wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", merge.ID}, []string{"p", owner})
986+ if answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner); err != nil || answer.Status != wikiMergeOpen {
987+ t.Fatalf("foreign reaction %+v %v", answer, err)
988+ }
989+ accept := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", merge.ID}, []string{"p", member})
990+ answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner)
991+ if err != nil || answer.Status != wikiMergeAccepted || answer.AnsweredAt != now-200 || answer.Reaction != accept.ID {
992+ t.Fatalf("accepted %+v %v", answer, err)
993+ }
994+ reject := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-100, "-", []string{"e", merge.ID})
995+ answer, err = tenant.wikiMergeStatus(ctx, merge.ID, owner)
996+ if err != nil || answer.Status != wikiMergeRejected || answer.Reaction != reject.ID {
997+ t.Fatalf("rejected %+v %v", answer, err)
998+ }
999+ // Other reaction content does not answer.
1000+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "🤔", []string{"e", merge.ID})
1001+ if answer, _ := tenant.wikiMergeStatus(ctx, merge.ID, owner); answer.Status != wikiMergeRejected {
1002+ t.Fatalf("emoji reaction changed the answer %+v", answer)
1003+ }
1004+ detail = wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"event": merge.ID})
1005+ if detail["merge"].(map[string]any)["status"] != "rejected" {
1006+ t.Fatalf("merge detail status %v", detail)
1007+ }
1008+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 0 {
1009+ t.Fatalf("answered merge still open %v", items)
1010+ }
1011+ if _, err := tenant.Execute(ctx, "", "browsewikimerge", []json.RawMessage{json.RawMessage(`{"id":"` + original.ID + `"}`)}); err == nil {
1012+ t.Fatal("article served as a merge request")
1013+ }
1014+}
1015+
1016+func TestWikiRedirects(t *testing.T) {
1017+ _, tenant := testTenant(t)
1018+ owner := tenant.Policy().Owner
1019+ now := time.Now().Unix()
1020+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-100)
1021+ redirect := wikiPublish(t, tenant, wikiMemberSecret, kindWikiRedirect, now-50, "", []string{"d", "btc"}, []string{"a", "30818:" + owner + ":bitcoin"})
1022+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
1023+ if to := wikiItems(page, "redirects_to"); len(to) != 1 || to[0]["d"] != "btc" || to[0]["id"] != redirect.ID || len(wikiItems(page, "redirects_from")) != 0 {
1024+ t.Fatalf("redirects to %v", page)
1025+ }
1026+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "BTC"})
1027+ if from := wikiItems(page, "redirects_from"); page["version"] != nil || len(from) != 1 || from[0]["target_d"] != "bitcoin" || from[0]["target_author"] != owner {
1028+ t.Fatalf("redirects from %v", page)
1029+ }
1030+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"missing"}`)}); err == nil || !strings.Contains(err.Error(), "not found") {
1031+ t.Fatalf("missing page %v", err)
1032+ }
1033+}
1034+
1035+func TestWikiMembersOnlyHidesPagesFromGuests(t *testing.T) {
1036+ _, tenant := testTenant(t)
1037+ ctx := context.Background()
1038+ member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)
1039+ wikiMember(t, tenant, member)
1040+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", time.Now().Unix()-10)
1041+ if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); len(items) != 1 {
1042+ t.Fatalf("open relay list %v", items)
1043+ }
1044+ p := tenant.Policy()
1045+ p.Reads = "members"
1046+ if err := tenant.applyPolicy(ctx, p); err != nil {
1047+ t.Fatal(err)
1048+ }
1049+ for _, actor := range []string{"", other} {
1050+ for _, method := range []string{"browsewiki", "browsewikipage", "browsewikimerge"} {
1051+ if _, err := tenant.Execute(ctx, actor, method, []json.RawMessage{json.RawMessage(`{"d":"bitcoin","id":"` + strings.Repeat("a", 64) + `"}`)}); err == nil {
1052+ t.Fatalf("%s exposed to %q", method, actor)
1053+ }
1054+ }
1055+ }
1056+ if items := wikiItems(wikiCall(t, tenant, member, "browsewiki", nil), "items"); len(items) != 1 {
1057+ t.Fatalf("member list %v", items)
1058+ }
1059+ if page := wikiCall(t, tenant, tenant.Policy().Owner, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"] == nil {
1060+ t.Fatalf("owner page %v", page)
1061+ }
1062+}
1063+
1064+func TestWikiMergeRequestWakesDestinationDevices(t *testing.T) {
1065+ _, tenant := testTenant(t)
1066+ ctx := context.Background()
1067+ owner := tenant.Policy().Owner
1068+ member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)
1069+ wikiMember(t, tenant, member)
1070+ now := time.Now().Unix()
1071+ for _, pubkey := range []string{owner, other} {
1072+ if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil {
1073+ t.Fatal(err)
1074+ }
1075+ }
1076+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin Basics", now-100)
1077+ // A request aimed at someone who is not a member, and one the owner
1078+ // sends to themselves, wake nobody.
1079+ wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-90, "", []string{"a", "30818:" + other + ":bitcoin-basics"}, []string{"p", other}, []string{"e", strings.Repeat("a", 64), "", "source"})
1080+ wikiPublish(t, tenant, wikiOwnerSecret, kindWikiMerge, now-80, "", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("b", 64), "", "source"})
1081+ if got := countIntents(t, tenant, notificationPush); got != 0 {
1082+ t.Fatalf("unexpected notifications %d", got)
1083+ }
1084+ merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-70, "Please merge", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("c", 64), "", "source"})
1085+ var payload string
1086+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {
1087+ t.Fatalf("notification intent: %v", err)
1088+ }
1089+ var notice pushPayload
1090+ if err := json.Unmarshal([]byte(payload), &notice); err != nil {
1091+ t.Fatal(err)
1092+ }
1093+ if notice.Kind != pushReplies || notice.Text != "Merge request for Bitcoin Basics" || notice.URL != "http://relay.test/wiki/bitcoin-basics?merge="+merge.ID {
1094+ t.Fatalf("notice %+v", notice)
1095+ }
1096+}
1097+
1098+func TestWikiMergeFromReadsEveryTagShape(t *testing.T) {
1099+ owner := strings.Repeat("a", 64)
1100+ base, source := strings.Repeat("b", 64), strings.Repeat("c", 64)
1101+ coordinate := "30818:" + owner + ":bitcoin"
1102+ cases := []struct {
1103+ name string
1104+ tags [][]string
1105+ base, source string
1106+ }{
1107+ {"source marker", [][]string{{"a", coordinate}, {"e", base}, {"p", owner}, {"e", source, "", "source"}}, base, source},
1108+ {"source tag", [][]string{{"a", coordinate}, {"e", base}, {"source", source}}, base, source},
1109+ {"single e", [][]string{{"a", coordinate}, {"e", source}}, "", source},
1110+ {"two plain e", [][]string{{"a", coordinate}, {"e", base}, {"e", source}}, base, source},
1111+ }
1112+ for _, tc := range cases {
1113+ m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: tc.tags})
1114+ if m.Base != tc.base || m.Source != tc.source || m.Destination != owner || m.TargetD != "bitcoin" || m.Status != wikiMergeOpen {
1115+ t.Errorf("%s: %+v", tc.name, m)
1116+ }
1117+ }
1118+ if m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: [][]string{{"a", "30617:" + owner + ":repo"}}}); m.Destination != "" || m.TargetD != "" {
1119+ t.Errorf("foreign coordinate accepted %+v", m)
1120+ }
1121+}
1122diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go
1123index 00d809a..4f14bae 100644
1124--- a/internal/webui/webmcp.go
1125+++ b/internal/webui/webmcp.go
1126@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
1127 return
1128 }
1129 }
1130- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true}
1131+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true}
1132 if !allowed[method] {
1133 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})
1134 return

About

ownerc01503e8035f
visibilitypublic
head0921e2e8e581

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs