tinyrelaycommit

Commit 602d2af64f0d

browse files at this commit | history

1diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
2index 7ed7b80..203a7c6 100644
3--- a/internal/daemon/mcp_tools.go
4+++ b/internal/daemon/mcp_tools.go
5@@ -123,7 +123,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
6 add("read_room", "Read a room, its members and its newest messages, threads and reactions. Pass cursor from next_cursor for older messages.", mcp.Object(map[string]any{"id": mcpRoomID, "cursor": mcpText, "limit": mcpLimit}, "id"), mcpReads, t.mcpBrowse("browseroom"))
7 add("read_thread", "Read a thread root and its replies in a room, newest first.", mcp.Object(map[string]any{"id": mcpRoomID, "event": mcpHash, "cursor": mcpText, "limit": mcpLimit}, "id", "event"), mcpReads, t.mcpBrowse("browsethread"))
8 add("list_wiki", "List wiki pages with their preferred version. q searches titles and summaries; author prefers that key's versions.", mcp.Object(map[string]any{"q": mcpText, "author": mcpPubKey, "cursor": mcpText, "limit": mcpLimit}), mcpReads, t.mcpBrowse("browsewiki"))
9- add("read_wiki_page", "Read a wiki page: its preferred version with content, every version, merge requests and redirects. Pass author to prefer that key's version or version to open one by event id.", mcp.Object(map[string]any{"d": mcpPageName, "author": mcpPubKey, "version": mcpHash}, "d"), mcpReads, t.mcpBrowse("browsewikipage"))
10+ add("read_wiki_page", "Read a wiki page: its preferred version with content, every version, the history of revisions with their states, merge requests and redirects. Pass author to prefer that key's version or version to open one by event id, including an archived revision from the history.", mcp.Object(map[string]any{"d": mcpPageName, "author": mcpPubKey, "version": mcpHash}, "d"), mcpReads, t.mcpBrowse("browsewikipage"))
11 add("read_merge_request", "Read a wiki merge request with its status, the proposed version and the target version.", mcp.Object(map[string]any{"id": mcpHash}, "id"), mcpReads, t.mcpBrowse("browsewikimerge"))
12 add("list_agents", "List granted agents with their name, key, owner, expiration, paused and revoked state, scope and last event. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
13 return t.mcpManage(ctx, call, "listagents")
14diff --git a/internal/daemon/wiki.go b/internal/daemon/wiki.go
15index 6e8a44b..c00162b 100644
16--- a/internal/daemon/wiki.go
17+++ b/internal/daemon/wiki.go
18@@ -51,6 +51,13 @@ type wikiVersion struct {
19 Defer *wikiRef `json:"defer,omitempty"`
20 Content string `json:"content,omitempty"`
21 Links []string `json:"links,omitempty"`
22+ // Revision numbers this event among the author's revisions of the
23+ // page, counted from 1 at the oldest kept revision; Revisions is how
24+ // many the author has. SupersededBy names the event that replaced an
25+ // archived revision and is empty for the author's current version.
26+ Revision int `json:"revision,omitempty"`
27+ Revisions int `json:"revisions,omitempty"`
28+ SupersededBy string `json:"superseded_by,omitempty"`
29 // Proposal marks a version from an agent whose grant says wiki:
30 // propose. Approval is pending, approved or rejected; the other fields
31 // name the deciding reaction, its time and its author once one exists.
32@@ -320,6 +327,41 @@ func sortWikiVersions(versions []wikiVersion) {
33 })
34 }
35
36+// wikiNumberRevisions numbers every version in the groups among the
37+// revisions of the same author and page, oldest first from 1, and sets
38+// the count on each. The groups together must hold every kept revision
39+// so the numbers are the same for every viewer.
40+func wikiNumberRevisions(groups ...[]wikiVersion) {
41+ pages := map[string][]*wikiVersion{}
42+ for _, group := range groups {
43+ for i := range group {
44+ key := group[i].Author + "\x00" + group[i].D
45+ pages[key] = append(pages[key], &group[i])
46+ }
47+ }
48+ for _, revisions := range pages {
49+ sort.SliceStable(revisions, func(i, j int) bool {
50+ if revisions[i].CreatedAt == revisions[j].CreatedAt {
51+ return revisions[i].ID < revisions[j].ID
52+ }
53+ return revisions[i].CreatedAt < revisions[j].CreatedAt
54+ })
55+ for i, v := range revisions {
56+ v.Revision, v.Revisions = i+1, len(revisions)
57+ }
58+ }
59+}
60+
61+// wikiFull reads the content of an event into a version while keeping the
62+// derived fields of the listed version: likes, revision numbers and the
63+// proposal state.
64+func wikiFull(e event.Event, v wikiVersion) wikiVersion {
65+ full := wikiVersionFrom(e, true)
66+ full.Likes, full.Revision, full.Revisions, full.SupersededBy = v.Likes, v.Revision, v.Revisions, v.SupersededBy
67+ full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy
68+ return full
69+}
70+
71 // notifyWikiMerge wakes the destination author's devices when a merge
72 // request for one of their articles arrives from someone else.
73 func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {
74diff --git a/internal/daemon/wiki_approvals.go b/internal/daemon/wiki_approvals.go
75index 1aa3065..84b7df8 100644
76--- a/internal/daemon/wiki_approvals.go
77+++ b/internal/daemon/wiki_approvals.go
78@@ -6,9 +6,10 @@ package daemon
79 // approves it, or "-", which rejects it. A newer version from the agent is
80 // a new event id and starts pending again, so every edit is decided on its
81 // own. A pending or rejected proposal is visible only to the owner,
82-// moderators and its author; for everyone else it does not exist. The
83+// moderators and its author; everyone else sees the agent's newest
84+// approved revision in its place, or nothing when none is approved. The
85 // state is computed here and carried on every browse result that lists
86-// versions.
87+// versions, including the archived revisions in a page's history.
88
89 import (
90 "context"
91@@ -150,21 +151,23 @@ func wikiProposalState(content string) string {
92 return ""
93 }
94
95-// wikiVisible keeps the versions the actor may see: every version that is
96-// not a proposal or is approved, plus pending and rejected proposals for
97-// the owner, moderators and the proposal's author.
98-func (t *Tenant) wikiVisible(ctx context.Context, roles *wikiRoles, actor string, versions []wikiVersion) ([]wikiVersion, error) {
99- decides, err := roles.decides(ctx, actor)
100- if err != nil {
101- return nil, err
102- }
103- visible := make([]wikiVersion, 0, len(versions))
104- for _, v := range versions {
105- if !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor) {
106- visible = append(visible, v)
107+// wikiSees reports whether an actor may see a version: every version that
108+// is not a proposal or is approved, plus pending and rejected proposals
109+// for the owner, moderators (decides) and the proposal's author.
110+func wikiSees(decides bool, actor string, v wikiVersion) bool {
111+ return !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor)
112+}
113+
114+// wikiFallback picks the revision a reader sees in place of a hidden
115+// version: the same author's newest approved revision of the page among
116+// the archived revisions, which are sorted newest first.
117+func wikiFallback(hidden wikiVersion, archived []wikiVersion) (wikiVersion, bool) {
118+ for _, a := range archived {
119+ if a.Author == hidden.Author && a.D == hidden.D && a.Proposal && a.Approval == wikiProposalApproved {
120+ return a, true
121 }
122 }
123- return visible, nil
124+ return wikiVersion{}, false
125 }
126
127 // notifyWikiProposal wakes the owner's and moderators' devices in the
128diff --git a/internal/daemon/wiki_browse.go b/internal/daemon/wiki_browse.go
129index b3b2ed1..99f1257 100644
130--- a/internal/daemon/wiki_browse.go
131+++ b/internal/daemon/wiki_browse.go
132@@ -1,7 +1,9 @@
133 package daemon
134
135-// Wiki browsing: the page list, one page with its versions, merge requests
136-// and redirects, and one merge request. Every read passes the tenant gate
137+// Wiki browsing: the page list, one page with its versions, the history
138+// of its revisions, merge requests and redirects, and one merge request.
139+// A version's earlier revisions come from the store's wiki_revisions
140+// table, where a replaced kind 30818 event is kept. Every read passes the tenant gate
141 // with the caller's session, so a members-only relay shows nothing to
142 // guests, and each event is checked with CanSee before it is returned.
143
144@@ -49,10 +51,25 @@ type wikiPage struct {
145 Merges []wikiMerge `json:"merges"`
146 RedirectsTo []wikiRedirect `json:"redirects_to"`
147 RedirectsFrom []wikiRedirect `json:"redirects_from"`
148+ // History lists every revision of the page the caller may see across
149+ // authors, newest first: each author's current version and the
150+ // archived revisions it replaced.
151+ History []wikiVersion `json:"history"`
152 // CanApprove reports whether the caller may accept or reject proposals.
153 CanApprove bool `json:"can_approve"`
154 }
155
156+// wikiSet is what an actor may see of the pages a query matched. Versions
157+// holds one version per author and page, with a hidden proposal replaced
158+// by that author's newest approved revision; History holds the visible
159+// current versions and archived revisions; archived keeps the archived
160+// events by id for reading their content.
161+type wikiSet struct {
162+ Versions []wikiVersion
163+ History []wikiVersion
164+ archived map[string]event.Event
165+}
166+
167 type wikiMergeDetail struct {
168 Merge wikiMerge `json:"merge"`
169 Proposed *wikiVersion `json:"proposed"`
170@@ -112,25 +129,72 @@ func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters .
171 return rows, nil
172 }
173
174-// wikiVersions lists the versions the actor may see, with each proposal's
175-// state filled in; pending and rejected proposals stay in the list only
176-// for the owner, moderators and their author.
177-func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) ([]wikiVersion, error) {
178+// wikiVersions reads the versions and archived revisions the filter
179+// matches, with each proposal's state filled in and every revision
180+// numbered, and keeps what the actor may see. A pending or rejected
181+// proposal stays in place only for the owner, moderators and its author;
182+// for anyone else the author's newest approved revision stands in for it,
183+// and the author has no version when there is none.
184+func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) (wikiSet, error) {
185 filter.Kinds = []int{kindWikiArticle}
186 rows, err := t.wikiQuery(ctx, session, filter)
187 if err != nil {
188- return nil, err
189+ return wikiSet{}, err
190 }
191- versions := make([]wikiVersion, 0, len(rows))
192+ current := make([]wikiVersion, 0, len(rows))
193+ ids := make(map[string]bool, len(rows))
194 for _, row := range rows {
195 if event.Tag(row, "d") != "" {
196- versions = append(versions, wikiVersionFrom(row, false))
197+ current = append(current, wikiVersionFrom(row, false))
198+ ids[row.ID] = true
199 }
200 }
201- if err := t.wikiProposals(ctx, roles, versions); err != nil {
202- return nil, err
203+ d := ""
204+ if names := filter.Tags["d"]; len(names) == 1 {
205+ d = names[0]
206+ }
207+ revisions, err := t.store.WikiRevisions(ctx, "", d)
208+ if err != nil {
209+ return wikiSet{}, err
210+ }
211+ set := wikiSet{Versions: []wikiVersion{}, History: []wikiVersion{}, archived: map[string]event.Event{}}
212+ archived := make([]wikiVersion, 0, len(revisions))
213+ for _, r := range revisions {
214+ if ids[r.ID] || event.Tag(r.Event, "d") == "" || !t.gate.CanSee(ctx, r.Event, session, nil) {
215+ continue
216+ }
217+ v := wikiVersionFrom(r.Event, false)
218+ v.SupersededBy = r.SupersededBy
219+ archived = append(archived, v)
220+ set.archived[v.ID] = r.Event
221+ }
222+ if err := t.wikiProposals(ctx, roles, current); err != nil {
223+ return wikiSet{}, err
224+ }
225+ if err := t.wikiProposals(ctx, roles, archived); err != nil {
226+ return wikiSet{}, err
227+ }
228+ wikiNumberRevisions(current, archived)
229+ decides, err := roles.decides(ctx, actor)
230+ if err != nil {
231+ return wikiSet{}, err
232+ }
233+ for _, v := range current {
234+ if wikiSees(decides, actor, v) {
235+ set.Versions = append(set.Versions, v)
236+ set.History = append(set.History, v)
237+ } else if fallback, ok := wikiFallback(v, archived); ok {
238+ set.Versions = append(set.Versions, fallback)
239+ }
240 }
241- return t.wikiVisible(ctx, roles, actor, versions)
242+ for _, v := range archived {
243+ if wikiSees(decides, actor, v) {
244+ set.History = append(set.History, v)
245+ }
246+ }
247+ sortWikiVersions(set.Versions)
248+ sortWikiVersions(set.History)
249+ return set, nil
250 }
251
252 // wikiMerges lists the merge requests aimed at any of the coordinates with
253@@ -171,7 +235,7 @@ func wikiMatches(v wikiVersion, query string) bool {
254 func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
255 session := browseSession(t, actor)
256 roles := t.wikiRoles()
257- versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})
258+ set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})
259 if err != nil {
260 return nil, err
261 }
262@@ -180,7 +244,7 @@ func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseReque
263 return nil, err
264 }
265 pages := map[string][]wikiVersion{}
266- for _, v := range versions {
267+ for _, v := range set.Versions {
268 if v.D <= q.Cursor || !wikiMatches(v, q.Query) {
269 continue
270 }
271@@ -253,10 +317,11 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
272 }
273 session := browseSession(t, actor)
274 roles := t.wikiRoles()
275- versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})
276+ set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})
277 if err != nil {
278 return nil, err
279 }
280+ versions := set.Versions
281 decides, err := roles.decides(ctx, actor)
282 if err != nil {
283 return nil, err
284@@ -265,7 +330,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
285 if err != nil {
286 return nil, err
287 }
288- page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, CanApprove: decides}
289+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, History: set.History, CanApprove: decides}
290 for _, row := range redirects {
291 r := wikiRedirectFrom(row)
292 if r.D == d && r.TargetD != "" && r.TargetD != d {
293@@ -280,36 +345,43 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
294 if len(versions) == 0 {
295 return page, nil
296 }
297- likes, err := t.wikiMemberLikes(ctx, versions)
298+ likes, err := t.wikiMemberLikes(ctx, append(append([]wikiVersion{}, versions...), set.History...))
299 if err != nil {
300 return nil, err
301 }
302 for i := range versions {
303 versions[i].Likes = likes[versions[i].ID]
304 }
305+ for i := range page.History {
306+ page.History[i].Likes = likes[page.History[i].ID]
307+ }
308 owner := t.Policy().Owner
309 preferred, by := wikiPreferred(versions, q.Author, owner)
310 if q.Version != "" {
311+ // An id names a current version, or an archived revision from the
312+ // history when it is no longer its author's current version.
313 by = "version"
314 found := false
315- for _, v := range versions {
316+ for _, v := range append(append([]wikiVersion{}, versions...), page.History...) {
317 if v.ID == q.Version {
318 preferred, found = v, true
319+ break
320 }
321 }
322 if !found {
323 return nil, errors.New("not found: wiki version")
324 }
325 }
326- rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})
327- if err != nil {
328- return nil, err
329- }
330- if len(rows) == 1 {
331- full := wikiVersionFrom(rows[0], true)
332- full.Likes = preferred.Likes
333- full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = preferred.Proposal, preferred.Approval, preferred.ApprovalEvent, preferred.ApprovalAt, preferred.ApprovalBy
334- preferred = full
335+ if e, ok := set.archived[preferred.ID]; ok {
336+ preferred = wikiFull(e, preferred)
337+ } else {
338+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})
339+ if err != nil {
340+ return nil, err
341+ }
342+ if len(rows) == 1 {
343+ preferred = wikiFull(rows[0], preferred)
344+ }
345 }
346 page.Title = preferred.Title
347 page.Version = &preferred
348@@ -349,6 +421,14 @@ func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowse
349 if len(proposed) == 1 {
350 v := wikiVersionFrom(proposed[0], true)
351 detail.Proposed = &v
352+ } else if r, ok, err := t.store.WikiRevision(ctx, detail.Merge.Source); err != nil {
353+ return nil, err
354+ } else if ok && t.gate.CanSee(ctx, r.Event, session, nil) {
355+ // The proposed version was replaced since the request was
356+ // made; the archived revision still shows what was asked.
357+ v := wikiVersionFrom(r.Event, true)
358+ v.SupersededBy = r.SupersededBy
359+ detail.Proposed = &v
360 }
361 }
362 if detail.Merge.TargetD != "" {
363diff --git a/internal/daemon/wiki_revisions_test.go b/internal/daemon/wiki_revisions_test.go
364new file mode 100644
365index 0000000..c9754ad
366--- /dev/null
367+++ b/internal/daemon/wiki_revisions_test.go
368@@ -0,0 +1,232 @@
369+package daemon
370+
371+import (
372+ "context"
373+ "encoding/json"
374+ "net/http"
375+ "strings"
376+ "testing"
377+ "time"
378+
379+ "github.com/FelineStateMachine/tinyrelay/internal/event"
380+ "github.com/FelineStateMachine/tinyrelay/internal/replication"
381+)
382+
383+// wikiImport signs an article and takes it in through the import path.
384+func wikiImport(t *testing.T, tenant *Tenant, secret, title string, createdAt int64) event.Event {
385+ t.Helper()
386+ d := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
387+ e := event.Event{Kind: kindWikiArticle, CreatedAt: createdAt, Content: "Imported " + title, Tags: [][]string{{"d", d}, {"title", title}}}
388+ if err := event.Sign(&e, secret); err != nil {
389+ t.Fatal(err)
390+ }
391+ if err := tenant.ingest(context.Background(), e, replication.OriginImport); err != nil {
392+ t.Fatalf("import: %v", err)
393+ }
394+ return e
395+}
396+
397+func wikiIDs(items []map[string]any) []string {
398+ ids := make([]string, 0, len(items))
399+ for _, item := range items {
400+ ids = append(ids, item["id"].(string))
401+ }
402+ return ids
403+}
404+
405+func TestWikiRevisionsAreKeptWhenAVersionIsReplaced(t *testing.T) {
406+ _, tenant := testTenant(t)
407+ ctx := context.Background()
408+ owner := tenant.Policy().Owner
409+ member := wikiKey(t, wikiMemberSecret)
410+ wikiMember(t, tenant, member)
411+ now := time.Now().Unix()
412+ first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)
413+ second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)
414+ third := wikiImport(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)
415+ // A reaction to an old revision id is an ordinary event.
416+ wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", first.ID})
417+
418+ rows, err := tenant.store.WikiRevisions(ctx, owner, "bitcoin")
419+ if err != nil || len(rows) != 2 || rows[0].ID != second.ID || rows[0].SupersededBy != third.ID || rows[1].ID != first.ID || rows[1].SupersededBy != second.ID || rows[1].Event.Content != first.Content {
420+ t.Fatalf("stored revisions %+v %v", rows, err)
421+ }
422+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
423+ versions := wikiItems(page, "versions")
424+ version := page["version"].(map[string]any)
425+ if len(versions) != 1 || version["id"] != third.ID || version["revision"].(float64) != 3 || version["revisions"].(float64) != 3 || version["superseded_by"] != nil || version["content"] != "Imported Bitcoin" {
426+ t.Fatalf("current version %v", page)
427+ }
428+ history := wikiItems(page, "history")
429+ if got := wikiIDs(history); strings.Join(got, ",") != third.ID+","+second.ID+","+first.ID {
430+ t.Fatalf("history order %v", got)
431+ }
432+ for i, want := range []struct {
433+ revision float64
434+ superseded any
435+ likes float64
436+ }{{3, nil, 0}, {2, third.ID, 0}, {1, second.ID, 1}} {
437+ h := history[i]
438+ if h["revision"].(float64) != want.revision || h["revisions"].(float64) != 3 || h["superseded_by"] != want.superseded || h["author"] != owner || h["title"] != "Bitcoin" || h["likes"].(float64) != want.likes {
439+ t.Fatalf("history entry %d %v", i, h)
440+ }
441+ if _, ok := h["content"]; ok {
442+ t.Fatalf("history entry carries content %v", h)
443+ }
444+ }
445+
446+ // An archived revision opens by id with a note of what replaced it.
447+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})
448+ version = page["version"].(map[string]any)
449+ if version["id"] != first.ID || version["content"] != first.Content || version["superseded_by"] != second.ID || version["revision"].(float64) != 1 || version["likes"].(float64) != 1 || page["preferred_by"] != "version" {
450+ t.Fatalf("archived version %v", version)
451+ }
452+ if len(wikiItems(page, "versions")) != 1 {
453+ t.Fatalf("archived lookup changed the versions %v", page["versions"])
454+ }
455+ items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items")
456+ if len(items) != 1 || items[0]["version"].(map[string]any)["revision"].(float64) != 3 || items[0]["versions"].(float64) != 1 {
457+ t.Fatalf("list %v", items)
458+ }
459+
460+ // A member's version of the same page is numbered on its own.
461+ memberVersion := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)
462+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "author": member})
463+ if v := page["version"].(map[string]any); v["id"] != memberVersion.ID || v["revision"].(float64) != 1 || v["revisions"].(float64) != 1 {
464+ t.Fatalf("member version %v", v)
465+ }
466+ if history := wikiItems(page, "history"); len(history) != 4 || history[0]["id"] != memberVersion.ID {
467+ t.Fatalf("history across authors %v", wikiIDs(history))
468+ }
469+}
470+
471+func TestWikiRevisionIsDroppedByTheAuthorsDeletion(t *testing.T) {
472+ _, tenant := testTenant(t)
473+ now := time.Now().Unix()
474+ first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)
475+ second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)
476+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)
477+ // Someone else's deletion changes nothing.
478+ wikiPublish(t, tenant, wikiMemberSecret, 5, now-250, "", []string{"e", first.ID})
479+ if history := wikiItems(wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"}), "history"); len(history) != 3 {
480+ t.Fatalf("foreign deletion dropped a revision %v", wikiIDs(history))
481+ }
482+ wikiPublish(t, tenant, wikiOwnerSecret, 5, now-200, "", []string{"e", first.ID})
483+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
484+ history := wikiItems(page, "history")
485+ if len(history) != 2 || history[1]["id"] != second.ID || history[1]["revision"].(float64) != 1 || history[0]["revisions"].(float64) != 2 {
486+ t.Fatalf("history after deletion %v", history)
487+ }
488+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + first.ID + `"}`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
489+ t.Fatalf("deleted revision still opens: %v", err)
490+ }
491+ if rows, err := tenant.store.WikiRevisions(context.Background(), "", ""); err != nil || len(rows) != 1 {
492+ t.Fatalf("stored revisions %+v %v", rows, err)
493+ }
494+}
495+
496+func TestWikiReadersFallBackToTheNewestApprovedRevision(t *testing.T) {
497+ app, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose")
498+ ctx := context.Background()
499+ now := time.Now().Unix()
500+ first := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-500)
501+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-490, "+", []string{"e", first.ID})
502+ second := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-400)
503+ // A page whose revisions were never approved stays absent.
504+ wikiArticle(t, tenant, testAgentSecret, "Nostr", now-380)
505+ wikiArticle(t, tenant, testAgentSecret, "Nostr", now-370)
506+
507+ // The owner, a moderator and the agent see the pending edit in place,
508+ // with the approved revision behind it in the history.
509+ for _, actor := range []string{owner, moderator, agent} {
510+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})
511+ version := page["version"].(map[string]any)
512+ if version["id"] != second.ID || version["approval"] != "pending" || version["revision"].(float64) != 2 || version["revisions"].(float64) != 2 || version["superseded_by"] != nil {
513+ t.Fatalf("%s page %v", actor, version)
514+ }
515+ history := wikiItems(page, "history")
516+ if len(history) != 2 || history[0]["id"] != second.ID || history[0]["approval"] != "pending" || history[1]["id"] != first.ID || history[1]["approval"] != "approved" || history[1]["superseded_by"] != second.ID {
517+ t.Fatalf("%s history %v", actor, history)
518+ }
519+ items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")
520+ if len(items) != 2 || items[0]["version"].(map[string]any)["id"] != second.ID || items[0]["versions"].(float64) != 1 {
521+ t.Fatalf("%s list %v", actor, items)
522+ }
523+ }
524+ // A member and a guest read the approved revision while the edit
525+ // waits, and the history shows only what they may see.
526+ for _, actor := range []string{member, ""} {
527+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})
528+ version := page["version"].(map[string]any)
529+ if version["id"] != first.ID || version["approval"] != "approved" || version["proposal"] != true || version["content"] != first.Content || version["superseded_by"] != second.ID || version["revision"].(float64) != 1 || version["revisions"].(float64) != 2 {
530+ t.Fatalf("%q page %v", actor, version)
531+ }
532+ if page["can_approve"] != false {
533+ t.Fatalf("%q page %v", actor, page)
534+ }
535+ versions := wikiItems(page, "versions")
536+ history := wikiItems(page, "history")
537+ if len(versions) != 1 || versions[0]["id"] != first.ID || len(history) != 1 || history[0]["id"] != first.ID {
538+ t.Fatalf("%q versions %v history %v", actor, wikiIDs(versions), wikiIDs(history))
539+ }
540+ items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")
541+ if len(items) != 1 || items[0]["d"] != "bitcoin" || items[0]["version"].(map[string]any)["id"] != first.ID || items[0]["versions"].(float64) != 1 {
542+ t.Fatalf("%q list %v", actor, items)
543+ }
544+ for _, params := range []string{`{"d":"bitcoin","version":"` + second.ID + `"}`, `{"d":"nostr"}`} {
545+ if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(params)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
546+ t.Fatalf("%q read %s: %v", actor, params, err)
547+ }
548+ }
549+ }
550+
551+ // The MCP tools read through the same code. An empty secret signs as
552+ // the owner; the other key has no standing on the relay.
553+ call := func(name string, arguments map[string]any, secret string) map[string]any {
554+ t.Helper()
555+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
556+ if w.Code != http.StatusOK {
557+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
558+ }
559+ result, isError := mcpToolResult(t, response)
560+ if isError {
561+ t.Fatalf("%s as %q: %v", name, secret, result)
562+ }
563+ value, _ := result["structuredContent"].(map[string]any)
564+ return value
565+ }
566+ for _, viewer := range []struct {
567+ secret string
568+ id string
569+ }{{"", second.ID}, {testModSecret, second.ID}, {testAgentSecret, second.ID}, {wikiMemberSecret, first.ID}, {wikiOtherSecret, first.ID}} {
570+ page := call("read_wiki_page", map[string]any{"d": "bitcoin"}, viewer.secret)
571+ history, _ := page["history"].([]any)
572+ if page["version"].(map[string]any)["id"] != viewer.id || len(history) != map[bool]int{true: 2, false: 1}[viewer.id == second.ID] {
573+ t.Fatalf("read_wiki_page as %q: %v", viewer.secret, page)
574+ }
575+ items, _ := call("list_wiki", map[string]any{}, viewer.secret)["items"].([]any)
576+ if len(items) == 0 || items[0].(map[string]any)["version"].(map[string]any)["id"] != viewer.id {
577+ t.Fatalf("list_wiki as %q: %v", viewer.secret, items)
578+ }
579+ }
580+
581+ // A rejected edit keeps the approved revision in view; approving the
582+ // edit shows it to everyone with the full history.
583+ wikiPublish(t, tenant, testModSecret, kindReaction, now-390, "-", []string{"e", second.ID})
584+ if page := wikiCall(t, tenant, member, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"].(map[string]any)["id"] != first.ID {
585+ t.Fatalf("rejected edit shown %v", page["version"])
586+ }
587+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-380, "+", []string{"e", second.ID})
588+ for _, actor := range []string{owner, member, ""} {
589+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})
590+ history := wikiItems(page, "history")
591+ if page["version"].(map[string]any)["id"] != second.ID || len(history) != 2 || history[1]["superseded_by"] != second.ID {
592+ t.Fatalf("%q after approval %v", actor, page)
593+ }
594+ // The approved older revision still opens from the history.
595+ page = wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})
596+ if v := page["version"].(map[string]any); v["id"] != first.ID || v["approval"] != "approved" || v["superseded_by"] != second.ID || v["content"] != first.Content {
597+ t.Fatalf("%q archived approved revision %v", actor, v)
598+ }
599+ }
600+}

About

ownerc01503e8035f
visibilitypublic
head602d2af64f0d

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs