| 1 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 2 | index 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") |
| 14 | diff --git a/internal/daemon/wiki.go b/internal/daemon/wiki.go |
| 15 | index 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) { |
| 74 | diff --git a/internal/daemon/wiki_approvals.go b/internal/daemon/wiki_approvals.go |
| 75 | index 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 |
| 128 | diff --git a/internal/daemon/wiki_browse.go b/internal/daemon/wiki_browse.go |
| 129 | index 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 != "" { |
| 363 | diff --git a/internal/daemon/wiki_revisions_test.go b/internal/daemon/wiki_revisions_test.go |
| 364 | new file mode 100644 |
| 365 | index 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 | +} |