- commit
- 602d2af64f0d23cd7a22532bc624a129486a5ad3
- parent
- fec17a90a7f8e62742fdb75fc54c77be7804fbac
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 2 | +- |
| M | internal/ | 42 | +++++ |
| M | internal/ | 33 | ++-- |
| M | internal/ | 134 | ++++++++++++++--- |
| A | internal/ | 232 | ++++++++++++++++++++++++++++++ |
@@ -123,7 +123,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
123123 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"))124124 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"))125125 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"))126- 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"))126+ 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"))127127 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"))128128 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) {129129 return t.mcpManage(ctx, call, "listagents")@@ -51,6 +51,13 @@ type wikiVersion struct {
5151 Defer *wikiRef `json:"defer,omitempty"`5252 Content string `json:"content,omitempty"`5353 Links []string `json:"links,omitempty"`54+ // Revision numbers this event among the author's revisions of the55+ // page, counted from 1 at the oldest kept revision; Revisions is how56+ // many the author has. SupersededBy names the event that replaced an57+ // archived revision and is empty for the author's current version.58+ Revision int `json:"revision,omitempty"`59+ Revisions int `json:"revisions,omitempty"`60+ SupersededBy string `json:"superseded_by,omitempty"`5461 // Proposal marks a version from an agent whose grant says wiki:5562 // propose. Approval is pending, approved or rejected; the other fields5663 // name the deciding reaction, its time and its author once one exists.@@ -320,6 +327,41 @@ func sortWikiVersions(versions []wikiVersion) {
320327 })321328 }322329 330+// wikiNumberRevisions numbers every version in the groups among the331+// revisions of the same author and page, oldest first from 1, and sets332+// the count on each. The groups together must hold every kept revision333+// so the numbers are the same for every viewer.334+func wikiNumberRevisions(groups ...[]wikiVersion) {335+ pages := map[string][]*wikiVersion{}336+ for _, group := range groups {337+ for i := range group {338+ key := group[i].Author + "\x00" + group[i].D339+ pages[key] = append(pages[key], &group[i])340+ }341+ }342+ for _, revisions := range pages {343+ sort.SliceStable(revisions, func(i, j int) bool {344+ if revisions[i].CreatedAt == revisions[j].CreatedAt {345+ return revisions[i].ID < revisions[j].ID346+ }347+ return revisions[i].CreatedAt < revisions[j].CreatedAt348+ })349+ for i, v := range revisions {350+ v.Revision, v.Revisions = i+1, len(revisions)351+ }352+ }353+}354+355+// wikiFull reads the content of an event into a version while keeping the356+// derived fields of the listed version: likes, revision numbers and the357+// proposal state.358+func wikiFull(e event.Event, v wikiVersion) wikiVersion {359+ full := wikiVersionFrom(e, true)360+ full.Likes, full.Revision, full.Revisions, full.SupersededBy = v.Likes, v.Revision, v.Revisions, v.SupersededBy361+ full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy362+ return full363+}364+323365 // notifyWikiMerge wakes the destination author's devices when a merge324366 // request for one of their articles arrives from someone else.325367 func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {@@ -6,9 +6,10 @@ package daemon
66 // approves it, or "-", which rejects it. A newer version from the agent is77 // a new event id and starts pending again, so every edit is decided on its88 // own. A pending or rejected proposal is visible only to the owner,9-// moderators and its author; for everyone else it does not exist. The9+// moderators and its author; everyone else sees the agent's newest10+// approved revision in its place, or nothing when none is approved. The1011 // state is computed here and carried on every browse result that lists11-// versions.12+// versions, including the archived revisions in a page's history.1213 1314 import (1415 "context"@@ -150,21 +151,23 @@ func wikiProposalState(content string) string {
150151 return ""151152 }152153 153-// wikiVisible keeps the versions the actor may see: every version that is154-// not a proposal or is approved, plus pending and rejected proposals for155-// the owner, moderators and the proposal's author.156-func (t *Tenant) wikiVisible(ctx context.Context, roles *wikiRoles, actor string, versions []wikiVersion) ([]wikiVersion, error) {157- decides, err := roles.decides(ctx, actor)158- if err != nil {159- return nil, err160- }161- visible := make([]wikiVersion, 0, len(versions))162- for _, v := range versions {163- if !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor) {164- visible = append(visible, v)154+// wikiSees reports whether an actor may see a version: every version that155+// is not a proposal or is approved, plus pending and rejected proposals156+// for the owner, moderators (decides) and the proposal's author.157+func wikiSees(decides bool, actor string, v wikiVersion) bool {158+ return !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor)159+}160+161+// wikiFallback picks the revision a reader sees in place of a hidden162+// version: the same author's newest approved revision of the page among163+// the archived revisions, which are sorted newest first.164+func wikiFallback(hidden wikiVersion, archived []wikiVersion) (wikiVersion, bool) {165+ for _, a := range archived {166+ if a.Author == hidden.Author && a.D == hidden.D && a.Proposal && a.Approval == wikiProposalApproved {167+ return a, true165168 }166169 }167- return visible, nil170+ return wikiVersion{}, false168171 }169172 170173 // notifyWikiProposal wakes the owner's and moderators' devices in the@@ -1,7 +1,9 @@
11 package daemon22 3-// Wiki browsing: the page list, one page with its versions, merge requests4-// and redirects, and one merge request. Every read passes the tenant gate3+// Wiki browsing: the page list, one page with its versions, the history4+// of its revisions, merge requests and redirects, and one merge request.5+// A version's earlier revisions come from the store's wiki_revisions6+// table, where a replaced kind 30818 event is kept. Every read passes the tenant gate57 // with the caller's session, so a members-only relay shows nothing to68 // guests, and each event is checked with CanSee before it is returned.79 @@ -49,10 +51,25 @@ type wikiPage struct {
4951 Merges []wikiMerge `json:"merges"`5052 RedirectsTo []wikiRedirect `json:"redirects_to"`5153 RedirectsFrom []wikiRedirect `json:"redirects_from"`54+ // History lists every revision of the page the caller may see across55+ // authors, newest first: each author's current version and the56+ // archived revisions it replaced.57+ History []wikiVersion `json:"history"`5258 // CanApprove reports whether the caller may accept or reject proposals.5359 CanApprove bool `json:"can_approve"`5460 }5561 62+// wikiSet is what an actor may see of the pages a query matched. Versions63+// holds one version per author and page, with a hidden proposal replaced64+// by that author's newest approved revision; History holds the visible65+// current versions and archived revisions; archived keeps the archived66+// events by id for reading their content.67+type wikiSet struct {68+ Versions []wikiVersion69+ History []wikiVersion70+ archived map[string]event.Event71+}72+5673 type wikiMergeDetail struct {5774 Merge wikiMerge `json:"merge"`5875 Proposed *wikiVersion `json:"proposed"`@@ -112,25 +129,72 @@ func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters .
112129 return rows, nil113130 }114131 115-// wikiVersions lists the versions the actor may see, with each proposal's116-// state filled in; pending and rejected proposals stay in the list only117-// for the owner, moderators and their author.118-func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) ([]wikiVersion, error) {132+// wikiVersions reads the versions and archived revisions the filter133+// matches, with each proposal's state filled in and every revision134+// numbered, and keeps what the actor may see. A pending or rejected135+// proposal stays in place only for the owner, moderators and its author;136+// for anyone else the author's newest approved revision stands in for it,137+// and the author has no version when there is none.138+func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) (wikiSet, error) {119139 filter.Kinds = []int{kindWikiArticle}120140 rows, err := t.wikiQuery(ctx, session, filter)121141 if err != nil {122- return nil, err142+ return wikiSet{}, err123143 }124- versions := make([]wikiVersion, 0, len(rows))144+ current := make([]wikiVersion, 0, len(rows))145+ ids := make(map[string]bool, len(rows))125146 for _, row := range rows {126147 if event.Tag(row, "d") != "" {127- versions = append(versions, wikiVersionFrom(row, false))148+ current = append(current, wikiVersionFrom(row, false))149+ ids[row.ID] = true128150 }129151 }130- if err := t.wikiProposals(ctx, roles, versions); err != nil {131- return nil, err152+ d := ""153+ if names := filter.Tags["d"]; len(names) == 1 {154+ d = names[0]155+ }156+ revisions, err := t.store.WikiRevisions(ctx, "", d)157+ if err != nil {158+ return wikiSet{}, err159+ }160+ set := wikiSet{Versions: []wikiVersion{}, History: []wikiVersion{}, archived: map[string]event.Event{}}161+ archived := make([]wikiVersion, 0, len(revisions))162+ for _, r := range revisions {163+ if ids[r.ID] || event.Tag(r.Event, "d") == "" || !t.gate.CanSee(ctx, r.Event, session, nil) {164+ continue165+ }166+ v := wikiVersionFrom(r.Event, false)167+ v.SupersededBy = r.SupersededBy168+ archived = append(archived, v)169+ set.archived[v.ID] = r.Event170+ }171+ if err := t.wikiProposals(ctx, roles, current); err != nil {172+ return wikiSet{}, err173+ }174+ if err := t.wikiProposals(ctx, roles, archived); err != nil {175+ return wikiSet{}, err176+ }177+ wikiNumberRevisions(current, archived)178+ decides, err := roles.decides(ctx, actor)179+ if err != nil {180+ return wikiSet{}, err181+ }182+ for _, v := range current {183+ if wikiSees(decides, actor, v) {184+ set.Versions = append(set.Versions, v)185+ set.History = append(set.History, v)186+ } else if fallback, ok := wikiFallback(v, archived); ok {187+ set.Versions = append(set.Versions, fallback)188+ }132189 }133- return t.wikiVisible(ctx, roles, actor, versions)190+ for _, v := range archived {191+ if wikiSees(decides, actor, v) {192+ set.History = append(set.History, v)193+ }194+ }195+ sortWikiVersions(set.Versions)196+ sortWikiVersions(set.History)197+ return set, nil134198 }135199 136200 // wikiMerges lists the merge requests aimed at any of the coordinates with@@ -171,7 +235,7 @@ func wikiMatches(v wikiVersion, query string) bool {
171235 func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {172236 session := browseSession(t, actor)173237 roles := t.wikiRoles()174- versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})238+ set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})175239 if err != nil {176240 return nil, err177241 }@@ -180,7 +244,7 @@ func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseReque
180244 return nil, err181245 }182246 pages := map[string][]wikiVersion{}183- for _, v := range versions {247+ for _, v := range set.Versions {184248 if v.D <= q.Cursor || !wikiMatches(v, q.Query) {185249 continue186250 }@@ -253,10 +317,11 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
253317 }254318 session := browseSession(t, actor)255319 roles := t.wikiRoles()256- versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})320+ set, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})257321 if err != nil {258322 return nil, err259323 }324+ versions := set.Versions260325 decides, err := roles.decides(ctx, actor)261326 if err != nil {262327 return nil, err@@ -265,7 +330,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
265330 if err != nil {266331 return nil, err267332 }268- page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, CanApprove: decides}333+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, History: set.History, CanApprove: decides}269334 for _, row := range redirects {270335 r := wikiRedirectFrom(row)271336 if r.D == d && r.TargetD != "" && r.TargetD != d {@@ -280,36 +345,43 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
280345 if len(versions) == 0 {281346 return page, nil282347 }283- likes, err := t.wikiMemberLikes(ctx, versions)348+ likes, err := t.wikiMemberLikes(ctx, append(append([]wikiVersion{}, versions...), set.History...))284349 if err != nil {285350 return nil, err286351 }287352 for i := range versions {288353 versions[i].Likes = likes[versions[i].ID]289354 }355+ for i := range page.History {356+ page.History[i].Likes = likes[page.History[i].ID]357+ }290358 owner := t.Policy().Owner291359 preferred, by := wikiPreferred(versions, q.Author, owner)292360 if q.Version != "" {361+ // An id names a current version, or an archived revision from the362+ // history when it is no longer its author's current version.293363 by = "version"294364 found := false295- for _, v := range versions {365+ for _, v := range append(append([]wikiVersion{}, versions...), page.History...) {296366 if v.ID == q.Version {297367 preferred, found = v, true368+ break298369 }299370 }300371 if !found {301372 return nil, errors.New("not found: wiki version")302373 }303374 }304- rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})305- if err != nil {306- return nil, err307- }308- if len(rows) == 1 {309- full := wikiVersionFrom(rows[0], true)310- full.Likes = preferred.Likes311- full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = preferred.Proposal, preferred.Approval, preferred.ApprovalEvent, preferred.ApprovalAt, preferred.ApprovalBy312- preferred = full375+ if e, ok := set.archived[preferred.ID]; ok {376+ preferred = wikiFull(e, preferred)377+ } else {378+ rows, err := t.wikiQuery(ctx, session, event.Filter{IDs: []string{preferred.ID}, Limit: intPtr(1)})379+ if err != nil {380+ return nil, err381+ }382+ if len(rows) == 1 {383+ preferred = wikiFull(rows[0], preferred)384+ }313385 }314386 page.Title = preferred.Title315387 page.Version = &preferred@@ -349,6 +421,14 @@ func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowse
349421 if len(proposed) == 1 {350422 v := wikiVersionFrom(proposed[0], true)351423 detail.Proposed = &v424+ } else if r, ok, err := t.store.WikiRevision(ctx, detail.Merge.Source); err != nil {425+ return nil, err426+ } else if ok && t.gate.CanSee(ctx, r.Event, session, nil) {427+ // The proposed version was replaced since the request was428+ // made; the archived revision still shows what was asked.429+ v := wikiVersionFrom(r.Event, true)430+ v.SupersededBy = r.SupersededBy431+ detail.Proposed = &v352432 }353433 }354434 if detail.Merge.TargetD != "" {@@ -0,0 +1,232 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/replication"13+)14+15+// wikiImport signs an article and takes it in through the import path.16+func wikiImport(t *testing.T, tenant *Tenant, secret, title string, createdAt int64) event.Event {17+ t.Helper()18+ d := strings.ToLower(strings.ReplaceAll(title, " ", "-"))19+ e := event.Event{Kind: kindWikiArticle, CreatedAt: createdAt, Content: "Imported " + title, Tags: [][]string{{"d", d}, {"title", title}}}20+ if err := event.Sign(&e, secret); err != nil {21+ t.Fatal(err)22+ }23+ if err := tenant.ingest(context.Background(), e, replication.OriginImport); err != nil {24+ t.Fatalf("import: %v", err)25+ }26+ return e27+}28+29+func wikiIDs(items []map[string]any) []string {30+ ids := make([]string, 0, len(items))31+ for _, item := range items {32+ ids = append(ids, item["id"].(string))33+ }34+ return ids35+}36+37+func TestWikiRevisionsAreKeptWhenAVersionIsReplaced(t *testing.T) {38+ _, tenant := testTenant(t)39+ ctx := context.Background()40+ owner := tenant.Policy().Owner41+ member := wikiKey(t, wikiMemberSecret)42+ wikiMember(t, tenant, member)43+ now := time.Now().Unix()44+ first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)45+ second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)46+ third := wikiImport(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)47+ // A reaction to an old revision id is an ordinary event.48+ wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", first.ID})49+50+ rows, err := tenant.store.WikiRevisions(ctx, owner, "bitcoin")51+ 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 {52+ t.Fatalf("stored revisions %+v %v", rows, err)53+ }54+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})55+ versions := wikiItems(page, "versions")56+ version := page["version"].(map[string]any)57+ if len(versions) != 1 || version["id"] != third.ID || version["revision"].(float64) != 3 || version["revisions"].(float64) != 3 || version["superseded_by"] != nil || version["content"] != "Imported Bitcoin" {58+ t.Fatalf("current version %v", page)59+ }60+ history := wikiItems(page, "history")61+ if got := wikiIDs(history); strings.Join(got, ",") != third.ID+","+second.ID+","+first.ID {62+ t.Fatalf("history order %v", got)63+ }64+ for i, want := range []struct {65+ revision float6466+ superseded any67+ likes float6468+ }{{3, nil, 0}, {2, third.ID, 0}, {1, second.ID, 1}} {69+ h := history[i]70+ 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 {71+ t.Fatalf("history entry %d %v", i, h)72+ }73+ if _, ok := h["content"]; ok {74+ t.Fatalf("history entry carries content %v", h)75+ }76+ }77+78+ // An archived revision opens by id with a note of what replaced it.79+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})80+ version = page["version"].(map[string]any)81+ 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" {82+ t.Fatalf("archived version %v", version)83+ }84+ if len(wikiItems(page, "versions")) != 1 {85+ t.Fatalf("archived lookup changed the versions %v", page["versions"])86+ }87+ items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items")88+ if len(items) != 1 || items[0]["version"].(map[string]any)["revision"].(float64) != 3 || items[0]["versions"].(float64) != 1 {89+ t.Fatalf("list %v", items)90+ }91+92+ // A member's version of the same page is numbered on its own.93+ memberVersion := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)94+ page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "author": member})95+ if v := page["version"].(map[string]any); v["id"] != memberVersion.ID || v["revision"].(float64) != 1 || v["revisions"].(float64) != 1 {96+ t.Fatalf("member version %v", v)97+ }98+ if history := wikiItems(page, "history"); len(history) != 4 || history[0]["id"] != memberVersion.ID {99+ t.Fatalf("history across authors %v", wikiIDs(history))100+ }101+}102+103+func TestWikiRevisionIsDroppedByTheAuthorsDeletion(t *testing.T) {104+ _, tenant := testTenant(t)105+ now := time.Now().Unix()106+ first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)107+ second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)108+ wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)109+ // Someone else's deletion changes nothing.110+ wikiPublish(t, tenant, wikiMemberSecret, 5, now-250, "", []string{"e", first.ID})111+ if history := wikiItems(wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"}), "history"); len(history) != 3 {112+ t.Fatalf("foreign deletion dropped a revision %v", wikiIDs(history))113+ }114+ wikiPublish(t, tenant, wikiOwnerSecret, 5, now-200, "", []string{"e", first.ID})115+ page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})116+ history := wikiItems(page, "history")117+ if len(history) != 2 || history[1]["id"] != second.ID || history[1]["revision"].(float64) != 1 || history[0]["revisions"].(float64) != 2 {118+ t.Fatalf("history after deletion %v", history)119+ }120+ if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + first.ID + `"}`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {121+ t.Fatalf("deleted revision still opens: %v", err)122+ }123+ if rows, err := tenant.store.WikiRevisions(context.Background(), "", ""); err != nil || len(rows) != 1 {124+ t.Fatalf("stored revisions %+v %v", rows, err)125+ }126+}127+128+func TestWikiReadersFallBackToTheNewestApprovedRevision(t *testing.T) {129+ app, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose")130+ ctx := context.Background()131+ now := time.Now().Unix()132+ first := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-500)133+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-490, "+", []string{"e", first.ID})134+ second := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-400)135+ // A page whose revisions were never approved stays absent.136+ wikiArticle(t, tenant, testAgentSecret, "Nostr", now-380)137+ wikiArticle(t, tenant, testAgentSecret, "Nostr", now-370)138+139+ // The owner, a moderator and the agent see the pending edit in place,140+ // with the approved revision behind it in the history.141+ for _, actor := range []string{owner, moderator, agent} {142+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})143+ version := page["version"].(map[string]any)144+ if version["id"] != second.ID || version["approval"] != "pending" || version["revision"].(float64) != 2 || version["revisions"].(float64) != 2 || version["superseded_by"] != nil {145+ t.Fatalf("%s page %v", actor, version)146+ }147+ history := wikiItems(page, "history")148+ 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 {149+ t.Fatalf("%s history %v", actor, history)150+ }151+ items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")152+ if len(items) != 2 || items[0]["version"].(map[string]any)["id"] != second.ID || items[0]["versions"].(float64) != 1 {153+ t.Fatalf("%s list %v", actor, items)154+ }155+ }156+ // A member and a guest read the approved revision while the edit157+ // waits, and the history shows only what they may see.158+ for _, actor := range []string{member, ""} {159+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})160+ version := page["version"].(map[string]any)161+ 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 {162+ t.Fatalf("%q page %v", actor, version)163+ }164+ if page["can_approve"] != false {165+ t.Fatalf("%q page %v", actor, page)166+ }167+ versions := wikiItems(page, "versions")168+ history := wikiItems(page, "history")169+ if len(versions) != 1 || versions[0]["id"] != first.ID || len(history) != 1 || history[0]["id"] != first.ID {170+ t.Fatalf("%q versions %v history %v", actor, wikiIDs(versions), wikiIDs(history))171+ }172+ items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")173+ if len(items) != 1 || items[0]["d"] != "bitcoin" || items[0]["version"].(map[string]any)["id"] != first.ID || items[0]["versions"].(float64) != 1 {174+ t.Fatalf("%q list %v", actor, items)175+ }176+ for _, params := range []string{`{"d":"bitcoin","version":"` + second.ID + `"}`, `{"d":"nostr"}`} {177+ if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(params)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {178+ t.Fatalf("%q read %s: %v", actor, params, err)179+ }180+ }181+ }182+183+ // The MCP tools read through the same code. An empty secret signs as184+ // the owner; the other key has no standing on the relay.185+ call := func(name string, arguments map[string]any, secret string) map[string]any {186+ t.Helper()187+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")188+ if w.Code != http.StatusOK {189+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())190+ }191+ result, isError := mcpToolResult(t, response)192+ if isError {193+ t.Fatalf("%s as %q: %v", name, secret, result)194+ }195+ value, _ := result["structuredContent"].(map[string]any)196+ return value197+ }198+ for _, viewer := range []struct {199+ secret string200+ id string201+ }{{"", second.ID}, {testModSecret, second.ID}, {testAgentSecret, second.ID}, {wikiMemberSecret, first.ID}, {wikiOtherSecret, first.ID}} {202+ page := call("read_wiki_page", map[string]any{"d": "bitcoin"}, viewer.secret)203+ history, _ := page["history"].([]any)204+ if page["version"].(map[string]any)["id"] != viewer.id || len(history) != map[bool]int{true: 2, false: 1}[viewer.id == second.ID] {205+ t.Fatalf("read_wiki_page as %q: %v", viewer.secret, page)206+ }207+ items, _ := call("list_wiki", map[string]any{}, viewer.secret)["items"].([]any)208+ if len(items) == 0 || items[0].(map[string]any)["version"].(map[string]any)["id"] != viewer.id {209+ t.Fatalf("list_wiki as %q: %v", viewer.secret, items)210+ }211+ }212+213+ // A rejected edit keeps the approved revision in view; approving the214+ // edit shows it to everyone with the full history.215+ wikiPublish(t, tenant, testModSecret, kindReaction, now-390, "-", []string{"e", second.ID})216+ if page := wikiCall(t, tenant, member, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"].(map[string]any)["id"] != first.ID {217+ t.Fatalf("rejected edit shown %v", page["version"])218+ }219+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-380, "+", []string{"e", second.ID})220+ for _, actor := range []string{owner, member, ""} {221+ page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})222+ history := wikiItems(page, "history")223+ if page["version"].(map[string]any)["id"] != second.ID || len(history) != 2 || history[1]["superseded_by"] != second.ID {224+ t.Fatalf("%q after approval %v", actor, page)225+ }226+ // The approved older revision still opens from the history.227+ page = wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})228+ if v := page["version"].(map[string]any); v["id"] != first.ID || v["approval"] != "approved" || v["superseded_by"] != second.ID || v["content"] != first.Content {229+ t.Fatalf("%q archived approved revision %v", actor, v)230+ }231+ }232+}