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,62 @@
1
+ "use client";
2
+ import { create } from "zustand";
3
+
4
+ export type ViewWidth = "narrow" | "normal" | "wide";
5
+
6
+ const STORAGE_KEY = "wiki-view-width";
7
+
8
+ /** Tailwind max-width class per setting. "wide" removes the cap. */
9
+ export const VIEW_WIDTH_CLASS: Record<ViewWidth, string> = {
10
+ narrow: "max-w-2xl",
11
+ normal: "max-w-4xl",
12
+ wide: "max-w-screen-xl",
13
+ };
14
+
15
+ /** CSS max-width value per setting, for use via the --editor-max-w variable. */
16
+ export const VIEW_WIDTH_CSS: Record<ViewWidth, string> = {
17
+ narrow: "42rem",
18
+ normal: "56rem",
19
+ wide: "80rem",
20
+ };
21
+
22
+ export const VIEW_WIDTH_LABEL: Record<ViewWidth, string> = {
23
+ narrow: "Narrow",
24
+ normal: "Normal",
25
+ wide: "Wide",
26
+ };
27
+
28
+ export const VIEW_WIDTH_ORDER: ViewWidth[] = ["narrow", "normal", "wide"];
29
+
30
+ function loadInitial(): ViewWidth {
31
+ if (typeof window === "undefined") return "normal";
32
+ const saved = localStorage.getItem(STORAGE_KEY);
33
+ if (saved === "narrow" || saved === "normal" || saved === "wide") {
34
+ return saved;
35
+ }
36
+ return "normal";
37
+ }
38
+
39
+ interface ViewWidthState {
40
+ width: ViewWidth;
41
+ setWidth: (width: ViewWidth) => void;
42
+ cycle: () => void;
43
+ }
44
+
45
+ export const useViewWidthStore = create<ViewWidthState>((set) => ({
46
+ width: loadInitial(),
47
+ setWidth: (width) => {
48
+ if (typeof window !== "undefined") {
49
+ localStorage.setItem(STORAGE_KEY, width);
50
+ }
51
+ set({ width });
52
+ },
53
+ cycle: () =>
54
+ set((s) => {
55
+ const idx = VIEW_WIDTH_ORDER.indexOf(s.width);
56
+ const next = VIEW_WIDTH_ORDER[(idx + 1) % VIEW_WIDTH_ORDER.length];
57
+ if (typeof window !== "undefined") {
58
+ localStorage.setItem(STORAGE_KEY, next);
59
+ }
60
+ return { width: next };
61
+ }),
62
+ }));
@@ -0,0 +1,146 @@
1
+ import { test, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ aggregateActivity,
8
+ deriveConnections,
9
+ ACTIVITY_DEFAULT_LIMIT,
10
+ ACTIVITY_MAX_LIMIT,
11
+ } from "../../lib/proof/activity.js";
12
+ import type { Sidecar } from "../../lib/proof/types.js";
13
+
14
+ let tmpRoot: string;
15
+
16
+ function makeSidecar(filePath: string, eventCount: number, byPrefix = "agent"): Sidecar {
17
+ const now = Date.now();
18
+ return {
19
+ schemaVersion: 1,
20
+ path: filePath,
21
+ revision: 0,
22
+ createdAt: new Date(now).toISOString(),
23
+ updatedAt: new Date(now).toISOString(),
24
+ refMap: {},
25
+ refAliases: {},
26
+ comments: [],
27
+ suggestions: [],
28
+ archivedSuggestions: [],
29
+ events: Array.from({ length: eventCount }, (_, i) => ({
30
+ id: i + 1,
31
+ type: "block.replace",
32
+ at: new Date(now - (eventCount - i) * 1000).toISOString(),
33
+ by: `${byPrefix}-${i % 2}`,
34
+ ref: `b00000${i}`,
35
+ })),
36
+ nextEventId: eventCount + 1,
37
+ lastAck: {},
38
+ fingerprint: "",
39
+ blockProvenance: {},
40
+ };
41
+ }
42
+
43
+ before(async () => {
44
+ tmpRoot = await mkdtemp(path.join(tmpdir(), "wiki-activity-test-"));
45
+ });
46
+
47
+ after(async () => {
48
+ await rm(tmpRoot, { recursive: true, force: true });
49
+ });
50
+
51
+ async function writeSidecarFile(sc: Sidecar): Promise<void> {
52
+ const dest = path.join(tmpRoot, ".proof", sc.path + ".json");
53
+ await mkdir(path.dirname(dest), { recursive: true });
54
+ await writeFile(dest, JSON.stringify(sc), "utf-8");
55
+ }
56
+
57
+ test("aggregates events from 3 sidecar files", async () => {
58
+ const sc1 = makeSidecar("notes/a.md", 5, "alice");
59
+ const sc2 = makeSidecar("notes/b.md", 5, "bob");
60
+ const sc3 = makeSidecar("notes/c.md", 5, "carol");
61
+ await writeSidecarFile(sc1);
62
+ await writeSidecarFile(sc2);
63
+ await writeSidecarFile(sc3);
64
+
65
+ const events = await aggregateActivity(tmpRoot);
66
+ assert.equal(events.length, 15);
67
+ });
68
+
69
+ test("events include path field from sidecar", async () => {
70
+ const events = await aggregateActivity(tmpRoot);
71
+ for (const ev of events) {
72
+ assert.ok(typeof ev.path === "string", "event has path field");
73
+ }
74
+ const paths = new Set(events.map((e) => e.path));
75
+ assert.ok(paths.has("notes/a.md"));
76
+ assert.ok(paths.has("notes/b.md"));
77
+ assert.ok(paths.has("notes/c.md"));
78
+ });
79
+
80
+ test("events sorted newest first", async () => {
81
+ const events = await aggregateActivity(tmpRoot);
82
+ for (let i = 0; i < events.length - 1; i++) {
83
+ assert.ok(
84
+ events[i].at >= events[i + 1].at,
85
+ `event[${i}].at >= event[${i + 1}].at`,
86
+ );
87
+ }
88
+ });
89
+
90
+ test("limit respected", async () => {
91
+ const events = await aggregateActivity(tmpRoot, { limit: 7 });
92
+ assert.equal(events.length, 7);
93
+ });
94
+
95
+ test("limit capped at ACTIVITY_MAX_LIMIT", async () => {
96
+ const events = await aggregateActivity(tmpRoot, { limit: 99999 });
97
+ assert.ok(events.length <= ACTIVITY_MAX_LIMIT);
98
+ });
99
+
100
+ test("default limit is ACTIVITY_DEFAULT_LIMIT", async () => {
101
+ const events = await aggregateActivity(tmpRoot);
102
+ assert.ok(events.length <= ACTIVITY_DEFAULT_LIMIT);
103
+ });
104
+
105
+ test("file filter returns only matching sidecar events", async () => {
106
+ const events = await aggregateActivity(tmpRoot, { file: "notes/a.md" });
107
+ assert.ok(events.length > 0, "should have events");
108
+ for (const ev of events) {
109
+ assert.equal(ev.path, "notes/a.md");
110
+ }
111
+ assert.equal(events.length, 5);
112
+ });
113
+
114
+ test("file filter on non-existent path returns empty", async () => {
115
+ const events = await aggregateActivity(tmpRoot, { file: "ghost.md" });
116
+ assert.equal(events.length, 0);
117
+ });
118
+
119
+ test("aggregateActivity returns empty when .proof missing", async () => {
120
+ const emptyRoot = await mkdtemp(path.join(tmpdir(), "wiki-activity-empty-"));
121
+ try {
122
+ const events = await aggregateActivity(emptyRoot);
123
+ assert.equal(events.length, 0);
124
+ } finally {
125
+ await rm(emptyRoot, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("deriveConnections groups by 'by' field within 5 min", async () => {
130
+ const now = new Date().toISOString();
131
+ const old = new Date(Date.now() - 10 * 60 * 1000).toISOString();
132
+ const events = [
133
+ { id: 1, type: "block.replace", at: now, by: "alice", path: "a.md" },
134
+ { id: 2, type: "block.replace", at: now, by: "alice", path: "a.md" },
135
+ { id: 3, type: "block.replace", at: now, by: "bob", path: "b.md" },
136
+ { id: 4, type: "block.replace", at: old, by: "carol", path: "c.md" },
137
+ ];
138
+ const conns = deriveConnections(events);
139
+ assert.equal(conns.length, 2, "carol excluded (too old)");
140
+ const alice = conns.find((c) => c.by === "alice");
141
+ assert.ok(alice, "alice present");
142
+ assert.equal(alice!.opCount, 2);
143
+ const bob = conns.find((c) => c.by === "bob");
144
+ assert.ok(bob, "bob present");
145
+ assert.equal(bob!.opCount, 1);
146
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Per-user agent ownership tests.
3
+ *
4
+ * User A approves an agent → only user A sees it / can revoke it.
5
+ * User B cannot see or revoke user A's agents.
6
+ */
7
+ import { test, before, after, beforeEach } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+
13
+ let tmpHome: string;
14
+
15
+ before(async () => {
16
+ tmpHome = await mkdtemp(path.join(tmpdir(), "agent-ownership-test-"));
17
+ process.env.HOME = tmpHome;
18
+ });
19
+
20
+ after(async () => {
21
+ await rm(tmpHome, { recursive: true, force: true });
22
+ });
23
+
24
+ import { POST as registerPOST } from "../../app/api/agent/register/route.js";
25
+ import { POST as approvePOST } from "../../app/api/agent/admin/registrations/[regId]/approve/route.js";
26
+ import { GET as listAgentsGET } from "../../app/api/agent/admin/agents/route.js";
27
+ import { POST as revokeAgentPOST } from "../../app/api/agent/admin/agents/[agentId]/revoke/route.js";
28
+ import { ensureRegistry } from "../../lib/proof/registry.js";
29
+
30
+ beforeEach(async () => {
31
+ const { _resetRegisterBuckets } = await import("../../lib/proof/register-rate-limit");
32
+ _resetRegisterBuckets();
33
+ });
34
+
35
+ async function makeUserCookie(tag: string): Promise<string> {
36
+ await ensureRegistry();
37
+ const { auth, authReady } = await import("../../lib/auth/server.js");
38
+ await authReady();
39
+ const res = await auth.api.signUpEmail({
40
+ body: {
41
+ email: `${tag}${Date.now()}@test.local`,
42
+ password: "test1234!",
43
+ name: tag,
44
+ },
45
+ asResponse: true,
46
+ });
47
+ if (!res.ok) throw new Error("signUpEmail failed: " + res.status);
48
+ const setCookie = res.headers.get("set-cookie") ?? "";
49
+ return setCookie
50
+ .split(/,(?=[^ ])/)
51
+ .map((c) => c.split(";")[0].trim())
52
+ .join("; ");
53
+ }
54
+
55
+ async function registerAgent(agentId: string): Promise<string> {
56
+ const req = new Request("http://localhost:3000/api/agent/register", {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ id: agentId,
61
+ displayName: `Agent ${agentId}`,
62
+ scope: { paths: ["**/*"], ops: ["read"] },
63
+ }),
64
+ });
65
+ const res = await registerPOST(req);
66
+ const data = (await res.json()) as { registrationId: string };
67
+ return data.registrationId;
68
+ }
69
+
70
+ async function approveAgent(regId: string, cookie: string): Promise<void> {
71
+ const req = new Request(
72
+ `http://localhost:3000/api/agent/admin/registrations/${regId}/approve`,
73
+ { method: "POST", headers: { Cookie: cookie } },
74
+ );
75
+ const res = await approvePOST(req, { params: Promise.resolve({ regId }) });
76
+ assert.equal(res.status, 200, `approve failed: ${await res.text()}`);
77
+ }
78
+
79
+ test("User A approves agent → appears in user A's agent list", async () => {
80
+ const cookieA = await makeUserCookie("usera");
81
+ const regId = await registerAgent("ai:agent-a1");
82
+ await approveAgent(regId, cookieA);
83
+
84
+ const listReq = new Request("http://localhost:3000/api/agent/admin/agents", {
85
+ headers: { Cookie: cookieA },
86
+ });
87
+ const res = await listAgentsGET(listReq);
88
+ assert.equal(res.status, 200);
89
+ const data = (await res.json()) as { agents: Array<{ id: string }> };
90
+ assert.ok(data.agents.some((a) => a.id === "ai:agent-a1"));
91
+ });
92
+
93
+ test("User B cannot see user A's agent", async () => {
94
+ const cookieA = await makeUserCookie("usera2");
95
+ const cookieB = await makeUserCookie("userb2");
96
+
97
+ const regId = await registerAgent("ai:agent-a2");
98
+ await approveAgent(regId, cookieA);
99
+
100
+ const listReq = new Request("http://localhost:3000/api/agent/admin/agents", {
101
+ headers: { Cookie: cookieB },
102
+ });
103
+ const res = await listAgentsGET(listReq);
104
+ assert.equal(res.status, 200);
105
+ const data = (await res.json()) as { agents: Array<{ id: string }> };
106
+ assert.ok(!data.agents.some((a) => a.id === "ai:agent-a2"), "User B should not see user A's agent");
107
+ });
108
+
109
+ test("User B cannot revoke user A's agent → 403", async () => {
110
+ const cookieA = await makeUserCookie("usera3");
111
+ const cookieB = await makeUserCookie("userb3");
112
+
113
+ const regId = await registerAgent("ai:agent-a3");
114
+ await approveAgent(regId, cookieA);
115
+
116
+ const revokeReq = new Request(
117
+ "http://localhost:3000/api/agent/admin/agents/ai:agent-a3/revoke",
118
+ { method: "POST", headers: { Cookie: cookieB } },
119
+ );
120
+ const res = await revokeAgentPOST(revokeReq, {
121
+ params: Promise.resolve({ agentId: "ai:agent-a3" }),
122
+ });
123
+ assert.equal(res.status, 403);
124
+ });
125
+
126
+ test("User A can revoke their own agent", async () => {
127
+ const cookieA = await makeUserCookie("usera4");
128
+
129
+ const regId = await registerAgent("ai:agent-a4");
130
+ await approveAgent(regId, cookieA);
131
+
132
+ const revokeReq = new Request(
133
+ "http://localhost:3000/api/agent/admin/agents/ai:agent-a4/revoke",
134
+ { method: "POST", headers: { Cookie: cookieA } },
135
+ );
136
+ const res = await revokeAgentPOST(revokeReq, {
137
+ params: Promise.resolve({ agentId: "ai:agent-a4" }),
138
+ });
139
+ assert.equal(res.status, 200);
140
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tests for /api/agents/install, /api/agents/skill, /api/agents/skill.tar.gz
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ function makeMockRequest(url: string): Request {
8
+ return new Request(url, { method: "GET" });
9
+ }
10
+
11
+ // Import routes
12
+ import { GET as installGET } from "../../app/api/agents/install/route.js";
13
+ import { GET as skillGET } from "../../app/api/agents/skill/route.js";
14
+ import { GET as tarGET } from "../../app/api/agents/skill.tar.gz/route.js";
15
+
16
+ test("GET /api/agents/install returns 200 with expected shape", async () => {
17
+ const req = makeMockRequest("http://localhost:3000/api/agents/install");
18
+ const res = await installGET(req as Parameters<typeof installGET>[0]);
19
+ assert.equal(res.status, 200);
20
+
21
+ const body = await res.json() as Record<string, unknown>;
22
+ assert.equal(body.name, "wiki-viewer");
23
+ assert.ok(typeof body.version === "string" && body.version.length > 0);
24
+ assert.ok(typeof body.endpoint === "string" && body.endpoint.startsWith("http"));
25
+ assert.ok(Array.isArray(body.routes) && (body.routes as unknown[]).length > 0);
26
+ assert.ok(typeof body.skillTarball === "string");
27
+ assert.ok(typeof body.bootstrapPrompt === "string" && body.bootstrapPrompt.length > 0);
28
+ assert.ok(typeof body.humanInstructions === "string" && body.humanInstructions.length > 0);
29
+ assert.ok(Array.isArray(body.ops) && (body.ops as unknown[]).length > 0);
30
+ });
31
+
32
+ test("GET /api/agents/skill returns 200 text/markdown with SKILL.md content", async () => {
33
+ const req = makeMockRequest("http://localhost:3000/api/agents/skill");
34
+ const res = await skillGET(req as Parameters<typeof skillGET>[0]);
35
+ assert.equal(res.status, 200);
36
+
37
+ const ct = res.headers.get("content-type") ?? "";
38
+ assert.ok(ct.startsWith("text/markdown"), `Expected text/markdown, got: ${ct}`);
39
+
40
+ const body = await res.text();
41
+ assert.ok(body.startsWith("---\nname: wiki-viewer"), `Unexpected start: ${body.slice(0, 40)}`);
42
+ });
43
+
44
+ test("GET /api/agents/skill.tar.gz returns 200 gzip with magic bytes", async () => {
45
+ const req = makeMockRequest("http://localhost:3000/api/agents/skill.tar.gz");
46
+ const res = await tarGET(req as Parameters<typeof tarGET>[0]);
47
+ assert.equal(res.status, 200);
48
+
49
+ const ct = res.headers.get("content-type") ?? "";
50
+ assert.equal(ct, "application/gzip");
51
+
52
+ const buf = Buffer.from(await res.arrayBuffer());
53
+ assert.ok(buf.length > 0, "tarball should not be empty");
54
+ // gzip magic bytes
55
+ assert.equal(buf[0], 0x1f);
56
+ assert.equal(buf[1], 0x8b);
57
+ });
@@ -0,0 +1,68 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { isEmailAllowed } from "../../lib/auth/allowlist";
4
+
5
+ function resetEnv() {
6
+ delete process.env.AUTH_ALLOWED_EMAILS;
7
+ delete process.env.AUTH_ALLOWED_DOMAIN;
8
+ }
9
+
10
+ // These tests exercise the env-fallback path. They assume no allowlist is set
11
+ // in ~/.wiki-viewer/config.json (config takes precedence over env when present).
12
+
13
+ test("isEmailAllowed: no env vars allows any email", async () => {
14
+ resetEnv();
15
+ assert.equal(await isEmailAllowed("anyone@example.com"), true);
16
+ assert.equal(await isEmailAllowed("eve@evil.com"), true);
17
+ });
18
+
19
+ test("isEmailAllowed: explicit allowlist (case-insensitive)", async () => {
20
+ resetEnv();
21
+ process.env.AUTH_ALLOWED_EMAILS = "alice@example.com,bob@example.com";
22
+ try {
23
+ assert.equal(await isEmailAllowed("alice@example.com"), true);
24
+ assert.equal(await isEmailAllowed("ALICE@EXAMPLE.COM"), true);
25
+ assert.equal(await isEmailAllowed("bob@example.com"), true);
26
+ assert.equal(await isEmailAllowed("eve@example.com"), false);
27
+ } finally {
28
+ resetEnv();
29
+ }
30
+ });
31
+
32
+ test("isEmailAllowed: domain allowlist", async () => {
33
+ resetEnv();
34
+ process.env.AUTH_ALLOWED_DOMAIN = "example.com,trusted.org";
35
+ try {
36
+ assert.equal(await isEmailAllowed("anyone@example.com"), true);
37
+ assert.equal(await isEmailAllowed("user@trusted.org"), true);
38
+ assert.equal(await isEmailAllowed("user@TRUSTED.ORG"), true);
39
+ assert.equal(await isEmailAllowed("eve@evil.com"), false);
40
+ } finally {
41
+ resetEnv();
42
+ }
43
+ });
44
+
45
+ test("isEmailAllowed: both lists, either match wins", async () => {
46
+ resetEnv();
47
+ process.env.AUTH_ALLOWED_EMAILS = "guest@anywhere.com";
48
+ process.env.AUTH_ALLOWED_DOMAIN = "example.com";
49
+ try {
50
+ assert.equal(await isEmailAllowed("alice@example.com"), true);
51
+ assert.equal(await isEmailAllowed("guest@anywhere.com"), true);
52
+ assert.equal(await isEmailAllowed("eve@evil.com"), false);
53
+ } finally {
54
+ resetEnv();
55
+ }
56
+ });
57
+
58
+ test("isEmailAllowed: handles whitespace and empty entries", async () => {
59
+ resetEnv();
60
+ process.env.AUTH_ALLOWED_EMAILS = " alice@example.com , , bob@example.com ";
61
+ try {
62
+ assert.equal(await isEmailAllowed("alice@example.com"), true);
63
+ assert.equal(await isEmailAllowed("bob@example.com"), true);
64
+ assert.equal(await isEmailAllowed("eve@example.com"), false);
65
+ } finally {
66
+ resetEnv();
67
+ }
68
+ });
@@ -0,0 +1,108 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseBlocks } from "../../lib/proof/blocks.js";
4
+ import { assignRefs, resolveRef } from "../../lib/proof/block-refs.js";
5
+ import { emptySidecar } from "../../lib/proof/sidecar.js";
6
+ import type { Sidecar } from "../../lib/proof/types.js";
7
+
8
+ function makeSidecar(refMap: Sidecar["refMap"]): Sidecar {
9
+ return { ...emptySidecar("test.md"), refMap };
10
+ }
11
+
12
+ test("first parse assigns refs deterministically", () => {
13
+ const md = "# Title\n\nParagraph.\n";
14
+ const nodes = parseBlocks(md);
15
+ const { blocks } = assignRefs(nodes, null);
16
+
17
+ const { blocks: blocks2 } = assignRefs(nodes, null);
18
+ assert.equal(blocks[0].ref, blocks2[0].ref, "heading ref should be deterministic");
19
+ assert.equal(blocks[1].ref, blocks2[1].ref, "paragraph ref should be deterministic");
20
+ });
21
+
22
+ test("re-parse with same content reuses refs", () => {
23
+ const md = "# Title\n\nParagraph.\n";
24
+ const nodes = parseBlocks(md);
25
+ const { blocks: first, newRefMap } = assignRefs(nodes, null);
26
+
27
+ const sidecar = makeSidecar(newRefMap);
28
+ const { blocks: second } = assignRefs(nodes, sidecar);
29
+
30
+ assert.equal(first[0].ref, second[0].ref, "heading ref should be reused");
31
+ assert.equal(first[1].ref, second[1].ref, "paragraph ref should be reused");
32
+ });
33
+
34
+ test("edit one block, others keep refs", () => {
35
+ const md = "# Title\n\nOriginal paragraph.\n\n## Section\n";
36
+ const nodes = parseBlocks(md);
37
+ const { blocks: original, newRefMap } = assignRefs(nodes, null);
38
+
39
+ // Edit the paragraph
40
+ const md2 = "# Title\n\nEdited paragraph.\n\n## Section\n";
41
+ const nodes2 = parseBlocks(md2);
42
+ const sidecar = makeSidecar(newRefMap);
43
+ const { blocks: updated } = assignRefs(nodes2, sidecar);
44
+
45
+ assert.equal(updated[0].ref, original[0].ref, "heading ref should be preserved");
46
+ assert.equal(updated[2].ref, original[2].ref, "second heading ref should be preserved");
47
+ // Middle block changed, so it gets a new ref
48
+ assert.notEqual(updated[1].ref, original[1].ref, "edited block should get new ref");
49
+ });
50
+
51
+ test("insert a block in middle, others keep refs", () => {
52
+ const md = "# Title\n\nParagraph.\n";
53
+ const nodes = parseBlocks(md);
54
+ const { blocks: original, newRefMap } = assignRefs(nodes, null);
55
+
56
+ // Insert a new block in the middle
57
+ const md2 = "# Title\n\nNew block.\n\nParagraph.\n";
58
+ const nodes2 = parseBlocks(md2);
59
+ const sidecar = makeSidecar(newRefMap);
60
+ const { blocks: updated } = assignRefs(nodes2, sidecar);
61
+
62
+ assert.equal(updated[0].ref, original[0].ref, "heading ref preserved after insert");
63
+ assert.equal(updated[2].ref, original[1].ref, "paragraph ref preserved after insert");
64
+ assert.notEqual(updated[1].ref, original[0].ref, "new block has distinct ref");
65
+ assert.notEqual(updated[1].ref, original[1].ref, "new block has distinct ref");
66
+ });
67
+
68
+ test("aliases populated after replace", () => {
69
+ const md = "# Title\n\nOriginal.\n";
70
+ const nodes = parseBlocks(md);
71
+ const { blocks: original, newRefMap } = assignRefs(nodes, null);
72
+
73
+ const md2 = "# Title\n\nReplaced.\n";
74
+ const nodes2 = parseBlocks(md2);
75
+ const sidecar = makeSidecar(newRefMap);
76
+ const { blocks: updated, newRefMap: newMap } = assignRefs(nodes2, sidecar);
77
+
78
+ // Old ref for the paragraph should not appear in new refMap
79
+ const oldRef = original[1].ref;
80
+ const newRef = updated[1].ref;
81
+ assert.ok(oldRef !== newRef, "replaced block should have different ref");
82
+ assert.ok(!(oldRef in newMap), "old ref should not be in new refMap");
83
+ });
84
+
85
+ test("resolveRef resolves via aliases", () => {
86
+ const md = "Paragraph.\n";
87
+ const nodes = parseBlocks(md);
88
+ const { blocks, newRefMap } = assignRefs(nodes, null);
89
+ const oldRef = blocks[0].ref;
90
+
91
+ // Simulate a replacement: new content
92
+ const md2 = "Replaced.\n";
93
+ const nodes2 = parseBlocks(md2);
94
+ const sidecarWithAlias: Sidecar = {
95
+ ...makeSidecar(newRefMap),
96
+ refAliases: { [oldRef]: "bnewref" },
97
+ };
98
+
99
+ const current = new Set(["bnewref"]);
100
+ const resolved = resolveRef(sidecarWithAlias, oldRef, current);
101
+ assert.equal(resolved, "bnewref", "should resolve via alias");
102
+ });
103
+
104
+ test("resolveRef returns null for unknown ref", () => {
105
+ const sidecar = makeSidecar({});
106
+ const resolved = resolveRef(sidecar, "bdeadbeef", new Set(["babc123"]));
107
+ assert.equal(resolved, null);
108
+ });
@@ -0,0 +1,92 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseBlocks, blockToMarkdown, blocksToMarkdown, blockType } from "../../lib/proof/blocks.js";
4
+
5
+ test("roundtrip preserves <proof-span> exactly", () => {
6
+ const md = `<proof-span id="p001" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z">The AI wrote this.</proof-span>`;
7
+ const nodes = parseBlocks(md);
8
+ assert.equal(nodes.length, 1, "should parse as 1 block");
9
+ const roundtripped = blockToMarkdown(nodes[0]);
10
+ assert.ok(
11
+ roundtripped.includes('<proof-span id="p001"'),
12
+ `proof-span not preserved: ${roundtripped}`,
13
+ );
14
+ assert.ok(
15
+ roundtripped.includes("The AI wrote this."),
16
+ `content not preserved: ${roundtripped}`,
17
+ );
18
+ assert.ok(
19
+ roundtripped.includes("</proof-span>"),
20
+ `closing tag not preserved: ${roundtripped}`,
21
+ );
22
+ });
23
+
24
+ test("list with checkboxes detected as taskList", () => {
25
+ const md = `- [x] Done\n- [ ] Todo`;
26
+ const nodes = parseBlocks(md);
27
+ assert.equal(nodes.length, 1);
28
+ const { type } = blockType(nodes[0]);
29
+ assert.equal(type, "taskList");
30
+ });
31
+
32
+ test("ordered list detected as orderedList", () => {
33
+ const md = `1. First\n2. Second`;
34
+ const nodes = parseBlocks(md);
35
+ assert.equal(nodes.length, 1);
36
+ const { type } = blockType(nodes[0]);
37
+ assert.equal(type, "orderedList");
38
+ });
39
+
40
+ test("bullet list detected as bulletList", () => {
41
+ const md = `- one\n- two`;
42
+ const nodes = parseBlocks(md);
43
+ const { type } = blockType(nodes[0]);
44
+ assert.equal(type, "bulletList");
45
+ });
46
+
47
+ test("code block with lang preserved", () => {
48
+ const md = "```typescript\nconst x = 1;\n```";
49
+ const nodes = parseBlocks(md);
50
+ assert.equal(nodes.length, 1);
51
+ const { type, lang } = blockType(nodes[0]);
52
+ assert.equal(type, "codeBlock");
53
+ assert.equal(lang, "typescript");
54
+ const out = blockToMarkdown(nodes[0]);
55
+ assert.ok(out.includes("typescript"), `lang not preserved: ${out}`);
56
+ assert.ok(out.includes("const x = 1;"), `code not preserved: ${out}`);
57
+ });
58
+
59
+ test("table preserved", () => {
60
+ const md = "| A | B |\n|---|---|\n| 1 | 2 |";
61
+ const nodes = parseBlocks(md);
62
+ assert.equal(nodes.length, 1);
63
+ const { type } = blockType(nodes[0]);
64
+ assert.equal(type, "table");
65
+ const out = blockToMarkdown(nodes[0]);
66
+ assert.ok(out.includes("| A |"), `table not preserved: ${out}`);
67
+ });
68
+
69
+ test("blocksToMarkdown joins multiple blocks", () => {
70
+ const md = "# Title\n\nSome text.\n\n- item1\n- item2";
71
+ const nodes = parseBlocks(md);
72
+ assert.equal(nodes.length, 3);
73
+ const out = blocksToMarkdown(nodes);
74
+ assert.ok(out.includes("# Title"), `heading missing: ${out}`);
75
+ assert.ok(out.includes("Some text."), `paragraph missing: ${out}`);
76
+ assert.ok(out.includes("item1"), `list missing: ${out}`);
77
+ });
78
+
79
+ test("heading block type detected", () => {
80
+ const md = "## Section";
81
+ const nodes = parseBlocks(md);
82
+ const { type, level } = blockType(nodes[0]);
83
+ assert.equal(type, "heading");
84
+ assert.equal(level, 2);
85
+ });
86
+
87
+ test("hr block type detected", () => {
88
+ const md = "---";
89
+ const nodes = parseBlocks(md);
90
+ const { type } = blockType(nodes[0]);
91
+ assert.equal(type, "hr");
92
+ });