wiki-viewer 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.next/standalone/README.md +328 -99
  2. package/.next/standalone/agents/bootstrap-prompt.md +1 -0
  3. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
  4. package/.next/standalone/bin/wiki-viewer.js +57 -4
  5. package/.next/standalone/docs/agent-collab-plan.md +1615 -0
  6. package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
  7. package/.next/standalone/package.json +19 -2
  8. package/.next/standalone/pnpm-lock.yaml +1368 -325
  9. package/.next/standalone/pnpm-workspace.yaml +7 -1
  10. package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
  11. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
  12. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
  13. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
  14. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
  15. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
  16. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
  17. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
  18. package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
  19. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
  20. package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
  21. package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
  22. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
  23. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
  24. package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
  25. package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
  26. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
  27. package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
  28. package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
  29. package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
  30. package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
  31. package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
  32. package/.next/standalone/src/app/api/system/config/route.ts +5 -1
  33. package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
  34. package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
  35. package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
  36. package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
  37. package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
  38. package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
  39. package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
  40. package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
  41. package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
  42. package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
  43. package/.next/standalone/src/app/api/wiki/route.ts +10 -0
  44. package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
  45. package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
  46. package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
  47. package/.next/standalone/src/app/globals.css +70 -0
  48. package/.next/standalone/src/app/page.tsx +461 -217
  49. package/.next/standalone/src/app/signin/page.tsx +217 -0
  50. package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
  51. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
  52. package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
  53. package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
  54. package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
  55. package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
  56. package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
  57. package/.next/standalone/src/components/editor/editor.tsx +513 -10
  58. package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
  59. package/.next/standalone/src/components/editor/extensions.ts +2 -0
  60. package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
  61. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
  62. package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
  63. package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
  64. package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
  65. package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
  66. package/.next/standalone/src/lib/auth/client.ts +4 -0
  67. package/.next/standalone/src/lib/auth/csrf.ts +68 -0
  68. package/.next/standalone/src/lib/auth/server.ts +164 -0
  69. package/.next/standalone/src/lib/config.ts +4 -0
  70. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
  71. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
  72. package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
  73. package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
  74. package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
  75. package/.next/standalone/src/lib/proof/activity.ts +74 -0
  76. package/.next/standalone/src/lib/proof/auth.ts +132 -0
  77. package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
  78. package/.next/standalone/src/lib/proof/blocks.ts +73 -0
  79. package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
  80. package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
  81. package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
  82. package/.next/standalone/src/lib/proof/glob.ts +51 -0
  83. package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
  84. package/.next/standalone/src/lib/proof/mutex.ts +32 -0
  85. package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
  86. package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
  87. package/.next/standalone/src/lib/proof/pending.ts +97 -0
  88. package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
  89. package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
  90. package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
  91. package/.next/standalone/src/lib/proof/registry.ts +190 -0
  92. package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
  93. package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
  94. package/.next/standalone/src/lib/proof/types.ts +170 -0
  95. package/.next/standalone/src/lib/proof-config.ts +14 -0
  96. package/.next/standalone/src/middleware.ts +38 -0
  97. package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
  98. package/.next/standalone/src/stores/editor-store.ts +115 -9
  99. package/.next/standalone/src/stores/proof-store.ts +123 -0
  100. package/.next/standalone/src/stores/view-width-store.ts +62 -0
  101. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
  102. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
  103. package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
  104. package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
  105. package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
  106. package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
  107. package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
  108. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
  109. package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
  110. package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
  111. package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
  112. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
  113. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
  114. package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
  115. package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
  116. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
  117. package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
  118. package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
  119. package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
  120. package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
  121. package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
  122. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
  123. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
  124. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
  125. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
  126. package/README.md +328 -99
  127. package/bin/wiki-viewer.js +57 -4
  128. package/package.json +19 -2
@@ -0,0 +1,516 @@
1
+ import { test, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+ import { setRootDir } from "../../lib/root-dir.js";
8
+
9
+ // Import route handlers (disk reads are lazy — happen at request time)
10
+ import { GET as filesGET, POST as filesPOST } from "../../app/api/agent/files/[...path]/route.js";
11
+ import { _resetBuckets } from "../../lib/proof/rate-limit.js";
12
+ import { GET as eventsGET, POST as ackPOST } from "../../app/api/agent/events/[...path]/route.js";
13
+ import { GET as sidecarGET } from "../../app/api/agent/sidecar/[...path]/route.js";
14
+ import { GET as settingsGET } from "../../app/api/agent/settings/route.js";
15
+ import { POST as regeneratePOST } from "../../app/api/agent/settings/token/regenerate/route.js";
16
+ import { GET as activityGET } from "../../app/api/agent/activity/route.js";
17
+ import { POST as revokePOST } from "../../app/api/agent/admin/agents/[agentId]/revoke/route.js";
18
+ import { ensureRegistry, addAgent, hashToken } from "../../lib/proof/registry.js";
19
+ import { makeUserSession } from "./helpers/auth-session.js";
20
+
21
+ let tmpHome: string;
22
+ let tmpRoot: string;
23
+ let TEST_TOKEN: string;
24
+ let RATE_TOKEN: string;
25
+ let RESTRICTED_TOKEN: string;
26
+ let REVOKE_TOKEN: string;
27
+
28
+ before(async () => {
29
+ // Isolated HOME so registry writes go to tmpHome/.wiki-viewer
30
+ tmpHome = await mkdtemp(path.join(tmpdir(), "wiki-home-test-"));
31
+ process.env.HOME = tmpHome;
32
+
33
+ // Wiki files root
34
+ tmpRoot = await mkdtemp(path.join(tmpdir(), "wiki-routes-test-"));
35
+ setRootDir(tmpRoot);
36
+
37
+ // Create registry and add test agents
38
+ await ensureRegistry();
39
+
40
+ TEST_TOKEN = randomBytes(32).toString("hex");
41
+ RATE_TOKEN = randomBytes(32).toString("hex");
42
+
43
+ await addAgent({
44
+ id: "ai:test",
45
+ displayName: "Test Agent",
46
+ tokenHash: hashToken(TEST_TOKEN),
47
+ scope: { paths: ["**/*"], ops: ["read", "mutate"] },
48
+ createdAt: new Date().toISOString(),
49
+ lastSeen: new Date().toISOString(),
50
+ });
51
+
52
+ await addAgent({
53
+ id: "ai:rate-limited-agent",
54
+ displayName: "Rate Agent",
55
+ tokenHash: hashToken(RATE_TOKEN),
56
+ scope: { paths: ["**/*"], ops: ["read", "mutate"] },
57
+ createdAt: new Date().toISOString(),
58
+ lastSeen: new Date().toISOString(),
59
+ });
60
+
61
+ RESTRICTED_TOKEN = randomBytes(32).toString("hex");
62
+ REVOKE_TOKEN = randomBytes(32).toString("hex");
63
+
64
+ await addAgent({
65
+ id: "ai:restricted-agent",
66
+ displayName: "Restricted Agent",
67
+ tokenHash: hashToken(RESTRICTED_TOKEN),
68
+ scope: { paths: ["work/*.md"], ops: ["read"] },
69
+ createdAt: new Date().toISOString(),
70
+ lastSeen: new Date().toISOString(),
71
+ });
72
+
73
+ await addAgent({
74
+ id: "ai:revoke-agent",
75
+ displayName: "Revoke Agent",
76
+ tokenHash: hashToken(REVOKE_TOKEN),
77
+ scope: { paths: ["**/*"], ops: ["read", "mutate"] },
78
+ createdAt: new Date().toISOString(),
79
+ lastSeen: new Date().toISOString(),
80
+ });
81
+
82
+ // Create sidecar files for activity scope filter test
83
+ const makeSidecar = (filePath: string) => ({
84
+ schemaVersion: 1,
85
+ path: filePath,
86
+ revision: 0,
87
+ createdAt: new Date().toISOString(),
88
+ updatedAt: new Date().toISOString(),
89
+ refMap: {},
90
+ refAliases: {},
91
+ comments: [],
92
+ suggestions: [],
93
+ archivedSuggestions: [],
94
+ events: [
95
+ {
96
+ id: 1,
97
+ type: "block.replace",
98
+ at: new Date().toISOString(),
99
+ by: "ai:test",
100
+ ref: "b001",
101
+ },
102
+ ],
103
+ nextEventId: 2,
104
+ lastAck: {},
105
+ fingerprint: "",
106
+ blockProvenance: {},
107
+ });
108
+
109
+ const proofDir = path.join(tmpRoot, ".proof");
110
+ await mkdir(path.join(proofDir, "work"), { recursive: true });
111
+ await mkdir(path.join(proofDir, "personal"), { recursive: true });
112
+ await writeFile(
113
+ path.join(proofDir, "work", "a.md.json"),
114
+ JSON.stringify(makeSidecar("work/a.md")),
115
+ "utf-8",
116
+ );
117
+ await writeFile(
118
+ path.join(proofDir, "personal", "b.md.json"),
119
+ JSON.stringify(makeSidecar("personal/b.md")),
120
+ "utf-8",
121
+ );
122
+ });
123
+
124
+ after(async () => {
125
+ // Wait for any in-flight async updateLastSeen writes to finish
126
+ await new Promise((r) => setTimeout(r, 50));
127
+ await rm(tmpRoot, { recursive: true, force: true });
128
+ await rm(tmpHome, { recursive: true, force: true });
129
+ });
130
+
131
+ function makeParams(segments: string[]): { params: Promise<{ path: string[] }> } {
132
+ return { params: Promise.resolve({ path: segments }) };
133
+ }
134
+
135
+ function authHeaders(): Headers {
136
+ const h = new Headers();
137
+ h.set("Authorization", `Bearer ${TEST_TOKEN}`);
138
+ h.set("X-Agent-Id", "ai:test");
139
+ return h;
140
+ }
141
+
142
+ function makeGetReq(url: string, withAuth = true): Request {
143
+ const h = withAuth ? authHeaders() : new Headers();
144
+ return new Request(url, { method: "GET", headers: h });
145
+ }
146
+
147
+ function makePostReq(url: string, body: unknown, extra?: Record<string, string>, withAuth = true): Request {
148
+ const h = withAuth ? authHeaders() : new Headers();
149
+ h.set("Content-Type", "application/json");
150
+ if (extra) {
151
+ for (const [k, v] of Object.entries(extra)) h.set(k, v);
152
+ }
153
+ return new Request(url, { method: "POST", headers: h, body: JSON.stringify(body) });
154
+ }
155
+
156
+ // ── GET /api/agent/files/[...path] ──────────────────────────────────────────
157
+
158
+ test("GET snapshot - missing file returns 404", async () => {
159
+ const req = makeGetReq("http://localhost:3000/api/agent/files/ghost.md");
160
+ const res = await filesGET(req, makeParams(["ghost.md"]));
161
+ assert.equal(res.status, 404);
162
+ const body = (await res.json()) as { error: string };
163
+ assert.equal(body.error, "NOT_FOUND");
164
+ });
165
+
166
+ test("GET snapshot - existing file returns snapshot shape", async () => {
167
+ await writeFile(path.join(tmpRoot, "hello.md"), "# Hello\n\nWorld.\n", "utf-8");
168
+ const req = makeGetReq("http://localhost:3000/api/agent/files/hello.md");
169
+ const res = await filesGET(req, makeParams(["hello.md"]));
170
+ assert.equal(res.status, 200);
171
+ const snap = (await res.json()) as { path: string; revision: number; blocks: unknown[] };
172
+ assert.equal(snap.path, "hello.md");
173
+ assert.equal(typeof snap.revision, "number");
174
+ assert.ok(Array.isArray(snap.blocks));
175
+ assert.equal(snap.blocks.length, 2);
176
+ });
177
+
178
+ test("GET snapshot - bad token returns 401", async () => {
179
+ const req = makeGetReq("http://localhost:3000/api/agent/files/hello.md", false);
180
+ const res = await filesGET(req, makeParams(["hello.md"]));
181
+ assert.equal(res.status, 401);
182
+ });
183
+
184
+ test("GET snapshot - missing X-Agent-Id returns 401", async () => {
185
+ const h = new Headers();
186
+ h.set("Authorization", `Bearer ${TEST_TOKEN}`);
187
+ const req = new Request("http://localhost:3000/api/agent/files/hello.md", { method: "GET", headers: h });
188
+ const res = await filesGET(req, makeParams(["hello.md"]));
189
+ assert.equal(res.status, 401);
190
+ });
191
+
192
+ test("GET snapshot - X-Agent-Id mismatch returns 401", async () => {
193
+ const h = new Headers();
194
+ h.set("Authorization", `Bearer ${TEST_TOKEN}`);
195
+ h.set("X-Agent-Id", "ai:imposter");
196
+ const req = new Request("http://localhost:3000/api/agent/files/hello.md", { method: "GET", headers: h });
197
+ const res = await filesGET(req, makeParams(["hello.md"]));
198
+ assert.equal(res.status, 401);
199
+ const body = (await res.json()) as { error: string };
200
+ assert.equal(body.error, "UNAUTHORIZED");
201
+ });
202
+
203
+ test("GET snapshot - non-markdown path returns 400", async () => {
204
+ const req = makeGetReq("http://localhost:3000/api/agent/files/notes.txt");
205
+ const res = await filesGET(req, makeParams(["notes.txt"]));
206
+ assert.equal(res.status, 400);
207
+ const body = (await res.json()) as { error: string };
208
+ assert.equal(body.error, "INVALID_PATH");
209
+ });
210
+
211
+ test("GET snapshot - .proof prefix returns 400", async () => {
212
+ const req = makeGetReq("http://localhost:3000/api/agent/files/.proof/foo.md");
213
+ const res = await filesGET(req, makeParams([".proof", "foo.md"]));
214
+ assert.equal(res.status, 400);
215
+ });
216
+
217
+ // ── POST /api/agent/files/[...path] ────────────────────────────────────────
218
+
219
+ test("POST - missing Idempotency-Key returns 400", async () => {
220
+ const req = makePostReq(
221
+ "http://localhost:3000/api/agent/files/hello.md",
222
+ { baseRevision: 0, by: "ai:test", ops: [] },
223
+ );
224
+ const res = await filesPOST(req, makeParams(["hello.md"]));
225
+ assert.equal(res.status, 400);
226
+ const body = (await res.json()) as { error: string };
227
+ assert.equal(body.error, "MISSING_IDEMPOTENCY_KEY");
228
+ });
229
+
230
+ test("POST - bad token returns 401", async () => {
231
+ const req = makePostReq(
232
+ "http://localhost:3000/api/agent/files/hello.md",
233
+ { baseRevision: 0, by: "ai:test", ops: [] },
234
+ { "Idempotency-Key": "key-401" },
235
+ false,
236
+ );
237
+ const res = await filesPOST(req, makeParams(["hello.md"]));
238
+ assert.equal(res.status, 401);
239
+ });
240
+
241
+ test("POST - happy path append returns 200 with snapshot", async () => {
242
+ await writeFile(path.join(tmpRoot, "post-happy.md"), "# Test\n\nOriginal.\n", "utf-8");
243
+
244
+ const getReq = makeGetReq("http://localhost:3000/api/agent/files/post-happy.md");
245
+ const getRes = await filesGET(getReq, makeParams(["post-happy.md"]));
246
+ const snap = (await getRes.json()) as { revision: number };
247
+
248
+ const req = makePostReq(
249
+ "http://localhost:3000/api/agent/files/post-happy.md",
250
+ { baseRevision: snap.revision, by: "ai:test", ops: [{ type: "block.append", markdown: "New paragraph." }] },
251
+ { "Idempotency-Key": "key-happy-" + Date.now() },
252
+ );
253
+ const res = await filesPOST(req, makeParams(["post-happy.md"]));
254
+ assert.equal(res.status, 200);
255
+ const result = (await res.json()) as { revision: number; blocks: unknown[] };
256
+ assert.ok(result);
257
+ assert.equal(result.revision, snap.revision + 1);
258
+ assert.ok(Array.isArray(result.blocks));
259
+ });
260
+
261
+ test("POST - stale revision returns 409 with snapshot", async () => {
262
+ await writeFile(path.join(tmpRoot, "stale.md"), "# Stale\n\nContent.\n", "utf-8");
263
+ const req = makePostReq(
264
+ "http://localhost:3000/api/agent/files/stale.md",
265
+ { baseRevision: 9999, by: "ai:test", ops: [] },
266
+ { "Idempotency-Key": "key-stale-" + Date.now() },
267
+ );
268
+ const res = await filesPOST(req, makeParams(["stale.md"]));
269
+ assert.equal(res.status, 409);
270
+ const body = (await res.json()) as { error: string; snapshot: unknown };
271
+ assert.equal(body.error, "STALE_REVISION");
272
+ assert.ok(body.snapshot, "Should include snapshot on 409");
273
+ });
274
+
275
+ test("POST - idempotent replay returns same response", async () => {
276
+ await writeFile(path.join(tmpRoot, "idem.md"), "# Idem\n\nText.\n", "utf-8");
277
+ const getRes = await filesGET(
278
+ makeGetReq("http://localhost:3000/api/agent/files/idem.md"),
279
+ makeParams(["idem.md"]),
280
+ );
281
+ const snap = (await getRes.json()) as { revision: number };
282
+ const key = "idem-key-" + Date.now();
283
+ const payload = { baseRevision: snap.revision, by: "ai:test", ops: [{ type: "block.append", markdown: "Appended." }] };
284
+
285
+ const r1 = await filesPOST(
286
+ makePostReq("http://localhost:3000/api/agent/files/idem.md", payload, { "Idempotency-Key": key }),
287
+ makeParams(["idem.md"]),
288
+ );
289
+ const r2 = await filesPOST(
290
+ makePostReq("http://localhost:3000/api/agent/files/idem.md", payload, { "Idempotency-Key": key }),
291
+ makeParams(["idem.md"]),
292
+ );
293
+ assert.equal(r1.status, r2.status);
294
+ const b1 = await r1.text();
295
+ const b2 = await r2.text();
296
+ assert.equal(b1, b2, "Idempotent replay must return identical body");
297
+ });
298
+
299
+ // ── GET /api/agent/events/[...path] ──────────────────────────────────────
300
+
301
+ test("GET events - returns events array and lastEventId for fresh file", async () => {
302
+ await writeFile(path.join(tmpRoot, "events-test.md"), "# Events\n", "utf-8");
303
+ const req = makeGetReq("http://localhost:3000/api/agent/events/events-test.md?after=0");
304
+ const res = await eventsGET(req, makeParams(["events-test.md"]));
305
+ assert.equal(res.status, 200);
306
+ const body = (await res.json()) as { events: unknown[]; lastEventId: number };
307
+ assert.ok(Array.isArray(body.events));
308
+ assert.equal(typeof body.lastEventId, "number");
309
+ });
310
+
311
+ test("GET events - bad token returns 401", async () => {
312
+ const req = makeGetReq("http://localhost:3000/api/agent/events/events-test.md", false);
313
+ const res = await eventsGET(req, makeParams(["events-test.md"]));
314
+ assert.equal(res.status, 401);
315
+ });
316
+
317
+ // ── POST /api/agent/events/[...path] ─────────────────────────────────────
318
+
319
+ test("POST ack - acknowledges event id", async () => {
320
+ await writeFile(path.join(tmpRoot, "ack-test.md"), "# Ack\n", "utf-8");
321
+ const req = makePostReq(
322
+ "http://localhost:3000/api/agent/events/ack-test.md",
323
+ { upToId: 5, by: "ai:test" },
324
+ );
325
+ const res = await ackPOST(req, makeParams(["ack-test.md"]));
326
+ assert.equal(res.status, 200);
327
+ const body = (await res.json()) as { ok: boolean };
328
+ assert.equal(body.ok, true);
329
+ });
330
+
331
+ test("POST ack - bad token returns 401", async () => {
332
+ const req = makePostReq(
333
+ "http://localhost:3000/api/agent/events/ack-test.md",
334
+ { upToId: 5, by: "ai:test" },
335
+ {},
336
+ false,
337
+ );
338
+ const res = await ackPOST(req, makeParams(["ack-test.md"]));
339
+ assert.equal(res.status, 401);
340
+ });
341
+
342
+ // ── GET /api/agent/sidecar/[...path] ───────────────────────────────────────
343
+
344
+ test("GET sidecar - returns sidecar shape", async () => {
345
+ await writeFile(path.join(tmpRoot, "sidecar-test.md"), "# Sidecar\n", "utf-8");
346
+ const req = makeGetReq("http://localhost:3000/api/agent/sidecar/sidecar-test.md");
347
+ const res = await sidecarGET(req, makeParams(["sidecar-test.md"]));
348
+ assert.equal(res.status, 200);
349
+ const sc = (await res.json()) as { schemaVersion: number; events: unknown[] };
350
+ assert.equal(sc.schemaVersion, 1);
351
+ assert.ok(Array.isArray(sc.events));
352
+ });
353
+
354
+ // ── GET /api/agent/settings ────────────────────────────────────────────────
355
+
356
+ test("GET settings - returns config", async () => {
357
+ const req = makeGetReq("http://localhost:3000/api/agent/settings");
358
+ const res = await settingsGET(req);
359
+ assert.equal(res.status, 200);
360
+ const body = (await res.json()) as { rateLimit: number; root: string; registeredAgents: number };
361
+ assert.equal(body.rateLimit, 60);
362
+ assert.ok(typeof body.root === "string");
363
+ assert.ok(typeof body.registeredAgents === "number");
364
+ assert.ok(body.registeredAgents >= 1, "Should have at least one registered agent");
365
+ const bodyStr = JSON.stringify(body);
366
+ assert.ok(!bodyStr.includes(TEST_TOKEN), "Token value must not appear in response");
367
+ });
368
+
369
+ // ── POST rate-limit (429) ─────────────────────────────────────────────────
370
+
371
+ test("POST - rate-limited agent returns 429 with Retry-After", async () => {
372
+ _resetBuckets();
373
+ await writeFile(path.join(tmpRoot, "rate-test.md"), "# Rate\n\nContent.\n", "utf-8");
374
+
375
+ // Get current revision using main test token
376
+ const getRes = await filesGET(
377
+ makeGetReq("http://localhost:3000/api/agent/files/rate-test.md"),
378
+ makeParams(["rate-test.md"]),
379
+ );
380
+ const snap = (await getRes.json()) as { revision: number };
381
+
382
+ // Use rate-limited-agent's own token + matching by field
383
+ const rateHeaders = new Headers();
384
+ rateHeaders.set("Authorization", `Bearer ${RATE_TOKEN}`);
385
+ rateHeaders.set("X-Agent-Id", "ai:rate-limited-agent");
386
+ rateHeaders.set("Content-Type", "application/json");
387
+ rateHeaders.set("Idempotency-Key", "rate-limit-key-" + Date.now());
388
+
389
+ const ops = Array.from({ length: 61 }, (_, i) => ({
390
+ type: "block.append",
391
+ markdown: `Paragraph ${i}.`,
392
+ }));
393
+
394
+ const req = new Request("http://localhost:3000/api/agent/files/rate-test.md", {
395
+ method: "POST",
396
+ headers: rateHeaders,
397
+ body: JSON.stringify({ baseRevision: snap.revision, by: "ai:rate-limited-agent", ops }),
398
+ });
399
+ const res = await filesPOST(req, makeParams(["rate-test.md"]));
400
+ assert.equal(res.status, 429);
401
+ const body = (await res.json()) as { error: string; retryAfterMs: number };
402
+ assert.equal(body.error, "RATE_LIMITED");
403
+ assert.ok(body.retryAfterMs > 0, "retryAfterMs must be positive");
404
+ assert.ok(res.headers.get("Retry-After") !== null, "Retry-After header must be set");
405
+ });
406
+
407
+ // ── POST /api/agent/settings/token/regenerate ──────────────────────────────
408
+
409
+ test("POST regenerate - returns 410 Gone", async () => {
410
+ const req = new Request("http://localhost:3000/api/agent/settings/token/regenerate", { method: "POST" });
411
+ const res = await regeneratePOST(req);
412
+ assert.equal(res.status, 410);
413
+ const body = (await res.json()) as { error: string };
414
+ assert.equal(body.error, "GONE");
415
+ });
416
+
417
+ // ── Oracle fix #2: activity scope filter ────────────────────────────────────
418
+
419
+ test("GET /api/agent/activity - restricted agent sees only scoped events", async () => {
420
+ // tmpRoot has sidecar for work/a.md and personal/b.md (set up in before)
421
+ const h = new Headers();
422
+ h.set("Authorization", `Bearer ${RESTRICTED_TOKEN}`);
423
+ h.set("X-Agent-Id", "ai:restricted-agent");
424
+ const req = new Request("http://localhost:3000/api/agent/activity", { method: "GET", headers: h });
425
+ const res = await activityGET(req);
426
+ assert.equal(res.status, 200);
427
+ const body = (await res.json()) as { events: Array<{ path: string }> };
428
+ // restricted agent scope is work/*.md — only work/a.md should appear
429
+ const paths = body.events.map((e) => e.path);
430
+ assert.ok(paths.every((p) => p.startsWith("work/")), "Only work/* paths visible");
431
+ assert.ok(paths.includes("work/a.md"), "work/a.md present");
432
+ assert.ok(!paths.includes("personal/b.md"), "personal/b.md excluded");
433
+ });
434
+
435
+ test("GET /api/agent/activity?file=personal/b.md - restricted agent gets 403", async () => {
436
+ const h = new Headers();
437
+ h.set("Authorization", `Bearer ${RESTRICTED_TOKEN}`);
438
+ h.set("X-Agent-Id", "ai:restricted-agent");
439
+ const req = new Request(
440
+ "http://localhost:3000/api/agent/activity?file=personal/b.md",
441
+ { method: "GET", headers: h },
442
+ );
443
+ const res = await activityGET(req);
444
+ assert.equal(res.status, 403);
445
+ const body = (await res.json()) as { error: string };
446
+ assert.equal(body.error, "FORBIDDEN");
447
+ });
448
+
449
+ // ── Oracle item 12: additional coverage ───────────────────────────────────────
450
+
451
+ test("GET snapshot - scope path mismatch returns 403", async () => {
452
+ // restricted agent (scope: work/*.md) tries to read personal/b.md
453
+ await writeFile(path.join(tmpRoot, "personal-test.md"), "# Personal\n", "utf-8");
454
+ const h = new Headers();
455
+ h.set("Authorization", `Bearer ${RESTRICTED_TOKEN}`);
456
+ h.set("X-Agent-Id", "ai:restricted-agent");
457
+ h.set("Content-Type", "application/json");
458
+ const req = new Request(
459
+ "http://localhost:3000/api/agent/files/personal-test.md",
460
+ { method: "GET", headers: h },
461
+ );
462
+ const res = await filesGET(req, makeParams(["personal-test.md"]));
463
+ assert.equal(res.status, 403);
464
+ const body = (await res.json()) as { error: string };
465
+ assert.equal(body.error, "FORBIDDEN");
466
+ });
467
+
468
+ test("POST - 'by' mismatch returns 403", async () => {
469
+ await writeFile(path.join(tmpRoot, "by-mismatch.md"), "# By\n\nContent.\n", "utf-8");
470
+ const getRes = await filesGET(
471
+ makeGetReq("http://localhost:3000/api/agent/files/by-mismatch.md"),
472
+ makeParams(["by-mismatch.md"]),
473
+ );
474
+ const snap = (await getRes.json()) as { revision: number };
475
+
476
+ const req = makePostReq(
477
+ "http://localhost:3000/api/agent/files/by-mismatch.md",
478
+ { baseRevision: snap.revision, by: "ai:other", ops: [] },
479
+ { "Idempotency-Key": "by-mismatch-" + Date.now() },
480
+ );
481
+ const res = await filesPOST(req, makeParams(["by-mismatch.md"]));
482
+ assert.equal(res.status, 403);
483
+ const body = (await res.json()) as { error: string };
484
+ assert.equal(body.error, "FORBIDDEN");
485
+ });
486
+
487
+ test("Revoke followed by use returns 401", async () => {
488
+ // Get Better Auth user session to authorize revoke
489
+ const sessionCookie = await makeUserSession();
490
+
491
+ // Revoke the agent
492
+ const revokeReq = new Request(
493
+ "http://localhost:3000/api/agent/admin/agents/ai:revoke-agent/revoke",
494
+ {
495
+ method: "POST",
496
+ headers: { Cookie: sessionCookie },
497
+ },
498
+ );
499
+ const revokeRes = await revokePOST(revokeReq, {
500
+ params: Promise.resolve({ agentId: "ai:revoke-agent" }),
501
+ });
502
+ assert.equal(revokeRes.status, 200);
503
+
504
+ // Now try to use the revoked token
505
+ const h = new Headers();
506
+ h.set("Authorization", `Bearer ${REVOKE_TOKEN}`);
507
+ h.set("X-Agent-Id", "ai:revoke-agent");
508
+ const useReq = new Request(
509
+ "http://localhost:3000/api/agent/files/hello.md",
510
+ { method: "GET", headers: h },
511
+ );
512
+ const useRes = await filesGET(useReq, makeParams(["hello.md"]));
513
+ assert.equal(useRes.status, 401);
514
+ const body = (await useRes.json()) as { error: string };
515
+ assert.equal(body.error, "UNAUTHORIZED");
516
+ });
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { emitEvents, trimEvents } from "../../lib/proof/event-bus.js";
4
+ import { emptySidecar } from "../../lib/proof/sidecar.js";
5
+
6
+ test("trimEvents: keeps last 1000 events when no ack cursors", () => {
7
+ const sc = emptySidecar("test.md");
8
+
9
+ // Emit 2000 events
10
+ for (let i = 0; i < 2000; i++) {
11
+ emitEvents(sc, [{ type: "block.inserted", at: new Date().toISOString(), by: "ai:test", position: "end", refs: [`b${i}`] }]);
12
+ }
13
+ assert.equal(sc.events.length, 2000);
14
+ assert.equal(sc.nextEventId, 2001); // starts at 1, increments to 2001 after 2000 events
15
+
16
+ trimEvents(sc, 1000);
17
+
18
+ assert.equal(sc.events.length, 1000);
19
+ // Events retained should be the last 1000 (IDs 1001-2000)
20
+ assert.equal(sc.events[0].id, 1001);
21
+ assert.equal(sc.events[999].id, 2000);
22
+ });
23
+
24
+ test("trimEvents: retains events newer than oldest ack cursor even beyond trim window", () => {
25
+ const sc = emptySidecar("test.md");
26
+
27
+ // Emit 2000 events
28
+ for (let i = 0; i < 2000; i++) {
29
+ emitEvents(sc, [{ type: "block.inserted", at: new Date().toISOString(), by: "ai:test", position: "end", refs: [`b${i}`] }]);
30
+ }
31
+
32
+ // Agent acked up to event 200 (old cursor - before the trim window)
33
+ sc.lastAck["ai:slow"] = 200;
34
+
35
+ trimEvents(sc, 1000);
36
+
37
+ // Events from IDs 201+ should be retained (because ack is at 200, events > 200 must be kept)
38
+ // Plus the last 1000 (1000-1999). Combined: events with id > 200 OR index >= 1000.
39
+ // Events 0-999 are candidates for trim. Events 0-200 have id <= 200, kept only if index >= 1000 (none).
40
+ // Events 201-999 have id > 200, kept. Events 1000-1999 always kept.
41
+ // So total retained: (999-201+1) + 1000 = 799 + 1000 = 1799
42
+ assert.ok(sc.events.length > 1000, `expected >1000 events, got ${sc.events.length}`);
43
+ // No event with id <= 200 should be present
44
+ for (const ev of sc.events) {
45
+ assert.ok(ev.id > 200, `Event id ${ev.id} should have been trimmed (ack at 200)`);
46
+ }
47
+ });
48
+
49
+ test("trimEvents: no-op when events <= trim size", () => {
50
+ const sc = emptySidecar("test.md");
51
+
52
+ for (let i = 0; i < 500; i++) {
53
+ emitEvents(sc, [{ type: "block.inserted", at: new Date().toISOString(), by: "ai:test", position: "end", refs: [`b${i}`] }]);
54
+ }
55
+
56
+ trimEvents(sc, 1000);
57
+ assert.equal(sc.events.length, 500);
58
+ });