wiki-viewer 1.4.3 → 1.5.0

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 (187) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +5 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/routes-manifest.json +34 -0
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js +8 -7
  24. package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route/app-paths-manifest.json +3 -0
  26. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route/build-manifest.json +9 -0
  27. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route/server-reference-manifest.json +4 -0
  28. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js +17 -0
  29. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.map +5 -0
  30. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -0
  31. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route_client-reference-manifest.js +3 -0
  32. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route/app-paths-manifest.json +3 -0
  33. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route/build-manifest.json +9 -0
  34. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route/server-reference-manifest.json +4 -0
  35. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js +15 -0
  36. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.map +5 -0
  37. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -0
  38. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route_client-reference-manifest.js +3 -0
  39. package/.next/standalone/.next/server/app/api/agent/fs/move/route/app-paths-manifest.json +3 -0
  40. package/.next/standalone/.next/server/app/api/agent/fs/move/route/build-manifest.json +9 -0
  41. package/.next/standalone/.next/server/app/api/agent/fs/move/route/server-reference-manifest.json +4 -0
  42. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js +15 -0
  43. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.map +5 -0
  44. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -0
  45. package/.next/standalone/.next/server/app/api/agent/fs/move/route_client-reference-manifest.js +3 -0
  46. package/.next/standalone/.next/server/app/api/agent/fs/search/route/app-paths-manifest.json +3 -0
  47. package/.next/standalone/.next/server/app/api/agent/fs/search/route/build-manifest.json +9 -0
  48. package/.next/standalone/.next/server/app/api/agent/fs/search/route/server-reference-manifest.json +4 -0
  49. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js +15 -0
  50. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.map +5 -0
  51. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -0
  52. package/.next/standalone/.next/server/app/api/agent/fs/search/route_client-reference-manifest.js +3 -0
  53. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/agent/settings/route.js +1 -1
  55. package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +1 -1
  58. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/api/system/clear-root/route.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/api/system/root-status/route.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/api/wiki/content/route.js +2 -2
  68. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/api/wiki/move/route.js +3 -3
  72. package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/api/wiki/presence/route/app-paths-manifest.json +3 -0
  76. package/.next/standalone/.next/server/app/api/wiki/presence/route/build-manifest.json +9 -0
  77. package/.next/standalone/.next/server/app/api/wiki/presence/route/server-reference-manifest.json +4 -0
  78. package/.next/standalone/.next/server/app/api/wiki/presence/route.js +14 -0
  79. package/.next/standalone/.next/server/app/api/wiki/presence/route.js.map +5 -0
  80. package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -0
  81. package/.next/standalone/.next/server/app/api/wiki/presence/route_client-reference-manifest.js +3 -0
  82. package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
  83. package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  85. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  86. package/.next/standalone/.next/server/app/index.html +1 -1
  87. package/.next/standalone/.next/server/app/index.rsc +2 -2
  88. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  89. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
  90. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  91. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  92. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  93. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  94. package/.next/standalone/.next/server/app-paths-manifest.json +5 -0
  95. package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0d2jbo5.js +3 -0
  96. package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +3 -0
  97. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0269d2e._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05f6x0i._.js +3 -0
  99. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06-dtd0._.js +3 -0
  100. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06sek6_._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/[root-of-the-server]__09s9qkd._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d75a5q._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d_da6q._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e7si9p._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0hhhvm7._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kk26wc._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kur4ro._.js +15 -0
  108. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0p12-6~._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0qc2~6l._.js +15 -0
  110. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0s~i040._.js +3 -0
  111. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0w47wil._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xq124z._.js +3 -0
  113. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l..bxz._.js → [root-of-the-server]__0xz-_0v._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10w2q~o._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/_079xuln._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_agent_fs_file_[___path]_route_actions_0gb11t~.js +3 -0
  117. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_agent_fs_ls_[[___path]]_route_actions_0wfg4nl.js +3 -0
  118. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_agent_fs_move_route_actions_0z34mqq.js +3 -0
  119. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_agent_fs_search_route_actions_0ut_ug..js +3 -0
  120. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_presence_route_actions_0x9d7f4.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/12y~_mermaid_dist_chunks_mermaid_core_diagram-OG6HWLK6_mjs_0t5sus6._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/12y~_mermaid_dist_chunks_mermaid_core_sankeyDiagram-5OEKKPKP_mjs_11ifrnu._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +2 -2
  124. package/.next/standalone/.next/server/chunks/ssr/node_modules__pnpm_0y9k6h~._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/node_modules__pnpm_0znncy9._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/node_modules__pnpm_12-ni1n._.js +1 -1
  127. package/.next/standalone/.next/server/functions-config-manifest.json +5 -0
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  130. package/.next/standalone/.next/server/pages/404.html +1 -1
  131. package/.next/standalone/.next/server/pages/500.html +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  134. package/.next/standalone/.next/static/chunks/{11s9zwr7edrs7.js → 14luv~jq7e6-n.js} +12 -12
  135. package/.next/standalone/README.md +79 -16
  136. package/.next/standalone/agents/bootstrap-prompt.md +12 -1
  137. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +199 -6
  138. package/.next/standalone/docs/file-vs-collab-authority.md +124 -0
  139. package/.next/standalone/package.json +1 -1
  140. package/.next/standalone/packages/wiki-viewer-mcp/README.md +194 -0
  141. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +220 -0
  142. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +472 -0
  143. package/.next/standalone/packages/wiki-viewer-mcp/dist/register.js +94 -0
  144. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +26 -0
  145. package/.next/standalone/packages/wiki-viewer-mcp/package-lock.json +1716 -0
  146. package/.next/standalone/packages/wiki-viewer-mcp/package.json +41 -0
  147. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +344 -0
  148. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +575 -0
  149. package/.next/standalone/packages/wiki-viewer-mcp/src/register.ts +144 -0
  150. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +45 -0
  151. package/.next/standalone/packages/wiki-viewer-mcp/tsconfig.json +19 -0
  152. package/.next/standalone/pnpm-lock.yaml +9 -9
  153. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +11 -1
  154. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +344 -0
  155. package/.next/standalone/src/app/api/agent/fs/ls/[[...path]]/route.ts +147 -0
  156. package/.next/standalone/src/app/api/agent/fs/move/route.ts +125 -0
  157. package/.next/standalone/src/app/api/agent/fs/search/route.ts +236 -0
  158. package/.next/standalone/src/app/api/agent/register/route.ts +3 -3
  159. package/.next/standalone/src/app/api/agents/install/route.ts +44 -2
  160. package/.next/standalone/src/app/api/wiki/move/route.ts +10 -1
  161. package/.next/standalone/src/app/api/wiki/presence/route.ts +55 -0
  162. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +49 -0
  163. package/.next/standalone/src/components/editor/editor.tsx +38 -0
  164. package/.next/standalone/src/lib/proof/audit.ts +79 -0
  165. package/.next/standalone/src/lib/proof/auth.ts +2 -2
  166. package/.next/standalone/src/lib/proof/collab-state.ts +79 -0
  167. package/.next/standalone/src/lib/proof/lease.ts +104 -0
  168. package/.next/standalone/src/lib/proof/ops-applier.ts +74 -25
  169. package/.next/standalone/src/lib/proof/raw-fs.ts +198 -0
  170. package/.next/standalone/src/lib/proof/registry.ts +1 -1
  171. package/.next/standalone/src/lib/proof/sidecar.ts +38 -1
  172. package/.next/standalone/src/lib/proof/types.ts +13 -1
  173. package/.next/standalone/src/tests/proof/agent-fs.test.ts +824 -0
  174. package/.next/standalone/src/tests/proof/agents-install.test.ts +53 -0
  175. package/.next/standalone/src/tests/proof/collab-state.test.ts +499 -0
  176. package/.next/standalone/src/tests/proof/reconcile-sidecar.test.ts +195 -0
  177. package/.next/standalone/src/tests/proof/sidecar-lifecycle.test.ts +177 -0
  178. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  179. package/README.md +79 -16
  180. package/package.json +1 -1
  181. package/.next/standalone/.next/server/chunks/[root-of-the-server]__09~~-nd._.js +0 -3
  182. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0w87kiz._.js +0 -3
  183. package/.next/standalone/.next/server/chunks/[root-of-the-server]__12ssbhv._.js +0 -3
  184. package/.next/standalone/.next/server/chunks/node_modules__pnpm_0g0fhfk._.js +0 -3
  185. /package/.next/standalone/.next/static/{kzOuSAQArtvD7uiAJ6ZLh → 19j4-_brJLoulXAMekpM2}/_buildManifest.js +0 -0
  186. /package/.next/standalone/.next/static/{kzOuSAQArtvD7uiAJ6ZLh → 19j4-_brJLoulXAMekpM2}/_clientMiddlewareManifest.js +0 -0
  187. /package/.next/standalone/.next/static/{kzOuSAQArtvD7uiAJ6ZLh → 19j4-_brJLoulXAMekpM2}/_ssgManifest.js +0 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tier-1 Raw FS — directory listing.
3
+ *
4
+ * GET /api/agent/fs/ls[/<path>]?recursive&limit=N&depth=N
5
+ *
6
+ * Returns {path, entries, truncated}.
7
+ * Each entry: {name, path, type, size?, mtime?}.
8
+ * Excludes .proof/ and .git/. Scope-filters every returned path.
9
+ */
10
+ export const runtime = "nodejs";
11
+
12
+ import { readdir, stat } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { NextResponse } from "next/server";
15
+ import { checkAuth, enforceScope } from "@/lib/proof/auth";
16
+ import { getRootDir, safeRootPath } from "@/lib/root-dir";
17
+ import { safeAbsPath } from "@/lib/proof/raw-fs";
18
+ import type { Agent } from "@/lib/proof/registry";
19
+
20
+ const HARD_MAX_ENTRIES = 10_000;
21
+ const HARD_MAX_DEPTH = 20;
22
+
23
+ export interface LsEntry {
24
+ name: string;
25
+ path: string;
26
+ type: "file" | "dir" | "symlink";
27
+ size?: number;
28
+ mtime?: string;
29
+ }
30
+
31
+ function errJson(code: string, message: string, status: number): NextResponse {
32
+ return NextResponse.json({ error: code, message }, { status });
33
+ }
34
+
35
+ /** Skip these names at any depth */
36
+ const SKIP_NAMES = new Set([".proof", ".git"]);
37
+
38
+ async function walkDir(
39
+ rootDir: string,
40
+ relDir: string,
41
+ agent: Agent,
42
+ recursive: boolean,
43
+ depth: number,
44
+ maxDepth: number,
45
+ limit: number,
46
+ results: LsEntry[],
47
+ ): Promise<void> {
48
+ if (depth > maxDepth || results.length >= limit) return;
49
+
50
+ const absDir = path.join(rootDir, relDir);
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ let items: any[];
53
+ try {
54
+ items = await readdir(absDir, { withFileTypes: true }) as any[];
55
+ } catch {
56
+ return;
57
+ }
58
+
59
+ for (const item of items) {
60
+ if (results.length >= limit) break;
61
+ if (SKIP_NAMES.has(item.name)) continue;
62
+
63
+ const childRel = relDir ? `${relDir}/${item.name}` : item.name;
64
+
65
+ // Scope check per entry
66
+ const sc = enforceScope(agent, { filePath: childRel, op: "read" });
67
+ if (!sc.ok) continue;
68
+
69
+ let type: LsEntry["type"];
70
+ let size: number | undefined;
71
+ let mtime: string | undefined;
72
+
73
+ if (item.isSymbolicLink()) {
74
+ type = "symlink";
75
+ } else if (item.isDirectory()) {
76
+ type = "dir";
77
+ } else {
78
+ type = "file";
79
+ try {
80
+ const st = await stat(path.join(rootDir, childRel));
81
+ size = st.size;
82
+ mtime = st.mtime.toISOString();
83
+ } catch {
84
+ // best-effort
85
+ }
86
+ }
87
+
88
+ results.push({ name: item.name, path: childRel, type, size, mtime });
89
+
90
+ if (recursive && type === "dir") {
91
+ await walkDir(rootDir, childRel, agent, recursive, depth + 1, maxDepth, limit, results);
92
+ }
93
+ }
94
+ }
95
+
96
+ export async function GET(
97
+ req: Request,
98
+ { params }: { params: Promise<{ path?: string[] }> },
99
+ ): Promise<NextResponse> {
100
+ const auth = await checkAuth(req);
101
+ if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
102
+
103
+ const { path: segments } = await params;
104
+ const relPath = segments ? segments.join("/") : "";
105
+
106
+ if (relPath) {
107
+ const basic = safeRootPath(relPath);
108
+ if (!basic) return errJson("INVALID_PATH", "Path traversal rejected", 400);
109
+ const safe = await safeAbsPath(relPath);
110
+ if (!safe) return errJson("INVALID_PATH", "Path rejected (symlink escape or denied)", 400);
111
+ }
112
+
113
+ const url = new URL(req.url);
114
+ const recursive = url.searchParams.has("recursive");
115
+ const limit = Math.min(
116
+ parseInt(url.searchParams.get("limit") ?? "1000", 10) || 1000,
117
+ HARD_MAX_ENTRIES,
118
+ );
119
+ const depth = Math.min(
120
+ parseInt(url.searchParams.get("depth") ?? "10", 10) || 10,
121
+ HARD_MAX_DEPTH,
122
+ );
123
+
124
+ // Verify the dir actually exists
125
+ const rootDir = getRootDir();
126
+ const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
127
+ try {
128
+ const st = await stat(absDir);
129
+ if (!st.isDirectory()) {
130
+ return errJson("NOT_A_DIRECTORY", "Path is not a directory", 400);
131
+ }
132
+ } catch (e) {
133
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") {
134
+ return errJson("NOT_FOUND", "Directory not found", 404);
135
+ }
136
+ throw e;
137
+ }
138
+
139
+ const entries: LsEntry[] = [];
140
+ await walkDir(rootDir, relPath, auth.agent, recursive, 0, depth, limit, entries);
141
+
142
+ return NextResponse.json({
143
+ path: relPath,
144
+ entries,
145
+ truncated: entries.length >= limit,
146
+ });
147
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tier-1 Raw FS — atomic move/rename.
3
+ *
4
+ * POST /api/agent/fs/move
5
+ * Body: { from: string, to: string, ifMatch?: string }
6
+ *
7
+ * Moves sidecar for .md files (R3).
8
+ * Locks source + dest in sorted order to avoid deadlock.
9
+ * Requires source read+mutate, dest mutate.
10
+ */
11
+ export const runtime = "nodejs";
12
+
13
+ import { rename, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { NextResponse } from "next/server";
16
+ import { checkAuth, enforceScope } from "@/lib/proof/auth";
17
+ import { getRootDir, safeRootPath } from "@/lib/root-dir";
18
+ import { withFileMutex } from "@/lib/proof/mutex";
19
+ import { moveSidecar } from "@/lib/proof/sidecar";
20
+ import { writeAuditRow } from "@/lib/proof/audit";
21
+ import { safeAbsPath, sha256ofBuf, extractShaHex, isMarkdown } from "@/lib/proof/raw-fs";
22
+ import { readFile } from "node:fs/promises";
23
+
24
+ function errJson(code: string, message: string, status: number): NextResponse {
25
+ return NextResponse.json({ error: code, message }, { status });
26
+ }
27
+
28
+ export async function POST(req: Request): Promise<NextResponse> {
29
+ const auth = await checkAuth(req);
30
+ if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
31
+
32
+ let body: { from?: unknown; to?: unknown; ifMatch?: unknown };
33
+ try {
34
+ body = (await req.json()) as typeof body;
35
+ } catch {
36
+ return errJson("INVALID_PAYLOAD", "Invalid JSON body", 400);
37
+ }
38
+
39
+ if (typeof body.from !== "string" || !body.from) {
40
+ return errJson("INVALID_PAYLOAD", "from (string) required", 400);
41
+ }
42
+ if (typeof body.to !== "string" || !body.to) {
43
+ return errJson("INVALID_PAYLOAD", "to (string) required", 400);
44
+ }
45
+
46
+ const fromRel = body.from;
47
+ const toRel = body.to;
48
+ const ifMatch = typeof body.ifMatch === "string" ? body.ifMatch : undefined;
49
+
50
+ // Basic traversal
51
+ if (!safeRootPath(fromRel)) return errJson("INVALID_PATH", "from: path traversal rejected", 400);
52
+ if (!safeRootPath(toRel)) return errJson("INVALID_PATH", "to: path traversal rejected", 400);
53
+
54
+ const fromAbs = await safeAbsPath(fromRel);
55
+ if (!fromAbs) return errJson("INVALID_PATH", "from: path rejected (symlink escape or denied)", 400);
56
+ const toAbs = await safeAbsPath(toRel);
57
+ if (!toAbs) return errJson("INVALID_PATH", "to: path rejected (symlink escape or denied)", 400);
58
+
59
+ // Self-move guard
60
+ if (fromAbs === toAbs) return errJson("INVALID_PATH", "from and to are the same path", 400);
61
+ if (toAbs.startsWith(fromAbs + path.sep)) {
62
+ return errJson("INVALID_PATH", "Cannot move a directory into itself", 400);
63
+ }
64
+
65
+ // Scope: source requires read+mutate; dest requires mutate
66
+ const sc1 = enforceScope(auth.agent, { filePath: fromRel, op: "read" });
67
+ if (!sc1.ok) return errJson(sc1.code, sc1.message, 403);
68
+ const sc2 = enforceScope(auth.agent, { filePath: fromRel, op: "mutate" });
69
+ if (!sc2.ok) return errJson(sc2.code, sc2.message, 403);
70
+ const sc3 = enforceScope(auth.agent, { filePath: toRel, op: "mutate" });
71
+ if (!sc3.ok) return errJson(sc3.code, sc3.message, 403);
72
+
73
+ // Verify source exists
74
+ try {
75
+ await stat(fromAbs);
76
+ } catch (e) {
77
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") {
78
+ return errJson("NOT_FOUND", "Source file not found", 404);
79
+ }
80
+ throw e;
81
+ }
82
+
83
+ // If-Match guard (R4)
84
+ let existingSha: string | undefined;
85
+ if (ifMatch) {
86
+ let buf: Buffer;
87
+ try {
88
+ buf = await readFile(fromAbs);
89
+ } catch {
90
+ return errJson("NOT_FOUND", "Source file not found", 404);
91
+ }
92
+ existingSha = sha256ofBuf(buf);
93
+ if (extractShaHex(ifMatch) !== extractShaHex(existingSha)) {
94
+ return errJson("PRECONDITION_FAILED", "If-Match sha256 mismatch", 412);
95
+ }
96
+ }
97
+
98
+ const rootDir = getRootDir();
99
+ const isMd = isMarkdown(fromRel);
100
+
101
+ // Lock source + dest in sorted order to avoid deadlock
102
+ const [first, second] = [fromRel, toRel].sort();
103
+
104
+ const doMove = async () => {
105
+ await rename(fromAbs, toAbs);
106
+ if (isMd) {
107
+ await moveSidecar(rootDir, fromRel, toRel);
108
+ }
109
+ writeAuditRow({
110
+ agentId: auth.agent.id,
111
+ op: "move",
112
+ path: fromRel,
113
+ newSha: toRel, // store destination in newSha field for audit trail
114
+ forced: false,
115
+ });
116
+ };
117
+
118
+ if (first === fromRel) {
119
+ await withFileMutex(first, () => withFileMutex(second, doMove));
120
+ } else {
121
+ await withFileMutex(first, () => withFileMutex(second, doMove));
122
+ }
123
+
124
+ return NextResponse.json({ from: fromRel, to: toRel });
125
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Tier-1 Raw FS — server-side search.
3
+ *
4
+ * POST /api/agent/fs/search
5
+ * Body: {
6
+ * kind: "grep" | "glob",
7
+ * query: string, // grep: regex string; glob: glob pattern
8
+ * path?: string, // root-relative start path (default: root)
9
+ * limit?: number, // max matches (default 200, hard cap 2000)
10
+ * }
11
+ *
12
+ * Returns { kind, query, matches: [{path, line?, col?, text?}], truncated }.
13
+ *
14
+ * - Pure JS (no shell interpolation, no rg dependency)
15
+ * - Skips binary files (null-byte heuristic) for grep
16
+ * - Skips .proof/ and .git/
17
+ * - Re-checks scope on every matched path
18
+ */
19
+ export const runtime = "nodejs";
20
+
21
+ import { readdir, readFile, stat } from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { NextResponse } from "next/server";
24
+ import { checkAuth, enforceScope } from "@/lib/proof/auth";
25
+ import { getRootDir, safeRootPath } from "@/lib/root-dir";
26
+ import { safeAbsPath, looksLikeBinary } from "@/lib/proof/raw-fs";
27
+ import { matchGlob } from "@/lib/proof/glob";
28
+ import type { Agent } from "@/lib/proof/registry";
29
+
30
+ const HARD_MAX_MATCHES = 2_000;
31
+ const HARD_MAX_SCAN_BYTES = 50 * 1024 * 1024; // 50 MB total
32
+ const SEARCH_TIMEOUT_MS = 10_000;
33
+
34
+ export interface SearchMatch {
35
+ path: string;
36
+ line?: number;
37
+ col?: number;
38
+ text?: string;
39
+ }
40
+
41
+ function errJson(code: string, message: string, status: number): NextResponse {
42
+ return NextResponse.json({ error: code, message }, { status });
43
+ }
44
+
45
+ const SKIP_DIRS = new Set([".proof", ".git", "node_modules", ".next"]);
46
+
47
+ // ── Walk helpers ──────────────────────────────────────────────────────────────
48
+
49
+ async function* walkFiles(rootDir: string, relDir: string): AsyncGenerator<string> {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ let items: any[];
52
+ try {
53
+ items = await readdir(path.join(rootDir, relDir), { withFileTypes: true }) as any[];
54
+ } catch {
55
+ return;
56
+ }
57
+ for (const item of items) {
58
+ if (SKIP_DIRS.has(item.name as string)) continue;
59
+ const childRel = relDir ? `${relDir}/${item.name as string}` : item.name as string;
60
+ if (item.isDirectory()) {
61
+ yield* walkFiles(rootDir, childRel);
62
+ } else if (item.isFile() || item.isSymbolicLink()) {
63
+ yield childRel;
64
+ }
65
+ }
66
+ }
67
+
68
+ // ── Grep ─────────────────────────────────────────────────────────────────────
69
+
70
+ async function grepSearch(
71
+ rootDir: string,
72
+ startRel: string,
73
+ pattern: string,
74
+ limit: number,
75
+ agent: Agent,
76
+ deadline: number,
77
+ ): Promise<{ matches: SearchMatch[]; truncated: boolean }> {
78
+ let regex: RegExp;
79
+ try {
80
+ regex = new RegExp(pattern, "d");
81
+ } catch {
82
+ // fallback without indices flag
83
+ try {
84
+ regex = new RegExp(pattern);
85
+ } catch {
86
+ throw new Error("Invalid regex pattern");
87
+ }
88
+ }
89
+
90
+ const matches: SearchMatch[] = [];
91
+ let scannedBytes = 0;
92
+
93
+ for await (const fileRel of walkFiles(rootDir, startRel)) {
94
+ if (matches.length >= limit) return { matches, truncated: true };
95
+ if (Date.now() > deadline) return { matches, truncated: true };
96
+
97
+ const sc = enforceScope(agent, { filePath: fileRel, op: "read" });
98
+ if (!sc.ok) continue;
99
+
100
+ let buf: Buffer;
101
+ try {
102
+ buf = await readFile(path.join(rootDir, fileRel));
103
+ } catch {
104
+ continue;
105
+ }
106
+
107
+ scannedBytes += buf.length;
108
+ if (scannedBytes > HARD_MAX_SCAN_BYTES) return { matches, truncated: true };
109
+
110
+ if (looksLikeBinary(buf)) continue;
111
+
112
+ const text = buf.toString("utf-8");
113
+ const lines = text.split("\n");
114
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
115
+ if (matches.length >= limit) return { matches, truncated: true };
116
+ const lineText = lines[lineIdx]!;
117
+ // Reset lastIndex for global regexes
118
+ regex.lastIndex = 0;
119
+ const m = regex.exec(lineText);
120
+ if (m) {
121
+ matches.push({
122
+ path: fileRel,
123
+ line: lineIdx + 1,
124
+ col: (m.index ?? 0) + 1,
125
+ text: lineText,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ return { matches, truncated: false };
132
+ }
133
+
134
+ // ── Glob ─────────────────────────────────────────────────────────────────────
135
+
136
+ async function globSearch(
137
+ rootDir: string,
138
+ startRel: string,
139
+ pattern: string,
140
+ limit: number,
141
+ agent: Agent,
142
+ deadline: number,
143
+ ): Promise<{ matches: SearchMatch[]; truncated: boolean }> {
144
+ const matches: SearchMatch[] = [];
145
+
146
+ for await (const fileRel of walkFiles(rootDir, startRel)) {
147
+ if (matches.length >= limit) return { matches, truncated: true };
148
+ if (Date.now() > deadline) return { matches, truncated: true };
149
+
150
+ // Match pattern against relative path OR just the filename
151
+ const baseName = path.basename(fileRel);
152
+ if (!matchGlob(pattern, fileRel) && !matchGlob(pattern, baseName)) continue;
153
+
154
+ const sc = enforceScope(agent, { filePath: fileRel, op: "read" });
155
+ if (!sc.ok) continue;
156
+
157
+ matches.push({ path: fileRel });
158
+ }
159
+
160
+ return { matches, truncated: false };
161
+ }
162
+
163
+ // ── Route ─────────────────────────────────────────────────────────────────────
164
+
165
+ export async function POST(req: Request): Promise<NextResponse> {
166
+ const auth = await checkAuth(req);
167
+ if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
168
+
169
+ let body: { kind?: unknown; query?: unknown; path?: unknown; limit?: unknown };
170
+ try {
171
+ body = (await req.json()) as typeof body;
172
+ } catch {
173
+ return errJson("INVALID_PAYLOAD", "Invalid JSON body", 400);
174
+ }
175
+
176
+ if (body.kind !== "grep" && body.kind !== "glob") {
177
+ return errJson("INVALID_PAYLOAD", 'kind must be "grep" or "glob"', 400);
178
+ }
179
+ if (typeof body.query !== "string" || !body.query) {
180
+ return errJson("INVALID_PAYLOAD", "query (string) required", 400);
181
+ }
182
+
183
+ const kind = body.kind as "grep" | "glob";
184
+ const query = body.query as string;
185
+ const startRelRaw = typeof body.path === "string" ? body.path : "";
186
+ const limit = Math.min(
187
+ typeof body.limit === "number" ? body.limit : 200,
188
+ HARD_MAX_MATCHES,
189
+ );
190
+
191
+ // Validate start path
192
+ if (startRelRaw) {
193
+ if (!safeRootPath(startRelRaw)) {
194
+ return errJson("INVALID_PATH", "path: traversal rejected", 400);
195
+ }
196
+ const safe = await safeAbsPath(startRelRaw);
197
+ if (!safe) return errJson("INVALID_PATH", "path: rejected (symlink escape or denied)", 400);
198
+ }
199
+
200
+ const rootDir = getRootDir();
201
+
202
+ // Verify start path is a directory
203
+ if (startRelRaw) {
204
+ try {
205
+ const st = await stat(path.join(rootDir, startRelRaw));
206
+ if (!st.isDirectory()) {
207
+ return errJson("NOT_A_DIRECTORY", "path must be a directory", 400);
208
+ }
209
+ } catch (e) {
210
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") {
211
+ return errJson("NOT_FOUND", "Start path not found", 404);
212
+ }
213
+ throw e;
214
+ }
215
+ }
216
+
217
+ const deadline = Date.now() + SEARCH_TIMEOUT_MS;
218
+ let result: { matches: SearchMatch[]; truncated: boolean };
219
+
220
+ try {
221
+ if (kind === "grep") {
222
+ result = await grepSearch(rootDir, startRelRaw, query, limit, auth.agent, deadline);
223
+ } else {
224
+ result = await globSearch(rootDir, startRelRaw, query, limit, auth.agent, deadline);
225
+ }
226
+ } catch (e) {
227
+ return errJson("SEARCH_ERROR", (e as Error).message, 400);
228
+ }
229
+
230
+ return NextResponse.json({
231
+ kind,
232
+ query,
233
+ matches: result.matches,
234
+ truncated: result.truncated,
235
+ });
236
+ }
@@ -12,7 +12,7 @@ import { checkRegisterRateLimit } from "@/lib/proof/register-rate-limit";
12
12
  export const runtime = "nodejs";
13
13
 
14
14
  const AGENT_ID_RE = /^ai:[a-z][a-z0-9-]{0,30}$/i;
15
- const VALID_OPS = new Set(["read", "mutate"]);
15
+ const VALID_OPS = new Set(["read", "mutate", "delete"]);
16
16
 
17
17
  function validateScope(s: Record<string, unknown>): AgentScope | { error: string } {
18
18
  if (!Array.isArray(s.paths) || s.paths.length === 0 || s.paths.length > 20) {
@@ -28,10 +28,10 @@ function validateScope(s: Record<string, unknown>): AgentScope | { error: string
28
28
  }
29
29
  for (const op of s.ops) {
30
30
  if (!VALID_OPS.has(op as string)) {
31
- return { error: `scope.ops values must be "read" or "mutate"` };
31
+ return { error: `scope.ops values must be "read", "mutate", or "delete"` };
32
32
  }
33
33
  }
34
- return { paths: s.paths as string[], ops: s.ops as Array<"read" | "mutate"> };
34
+ return { paths: s.paths as string[], ops: s.ops as Array<"read" | "mutate" | "delete"> };
35
35
  }
36
36
 
37
37
  /** Exported for reuse in approve route. */
@@ -4,6 +4,8 @@ import { NextRequest, NextResponse } from "next/server";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
 
7
+ const MAX_FILE_BYTES = 50_000_000; // 50 MB
8
+
7
9
  async function readVersion(): Promise<string> {
8
10
  try {
9
11
  const raw = await readFile(path.resolve(process.cwd(), "package.json"), "utf-8");
@@ -57,13 +59,53 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
57
59
  skillRaw: "/api/agents/skill",
58
60
  skillCli: "npx skills add anh-chu/wiki-viewer/agents/wiki-viewer-skill",
59
61
  routes: [
62
+ // Registration
60
63
  { method: "POST", path: "/api/agent/register", auth: "none", purpose: "Register an agent" },
61
64
  { method: "GET", path: "/api/agent/register/<regId>", auth: "none", purpose: "Poll registration status" },
62
- { method: "GET", path: "/api/agent/files/<path>.md", auth: "bearer+agent-id", purpose: "Read snapshot" },
63
- { method: "POST", path: "/api/agent/files/<path>.md", auth: "bearer+agent-id", purpose: "Apply ops" },
65
+ // Tier 2 Collab (markdown block-ops + provenance)
66
+ { method: "GET", path: "/api/agent/files/<path>.md", auth: "bearer+agent-id", purpose: "Read markdown snapshot (block-ops tier)" },
67
+ { method: "POST", path: "/api/agent/files/<path>.md", auth: "bearer+agent-id", purpose: "Apply block-ops" },
64
68
  { method: "GET", path: "/api/agent/events/<path>.md", auth: "bearer+agent-id", purpose: "Poll events" },
65
69
  { method: "POST", path: "/api/agent/events/<path>.md", auth: "bearer+agent-id", purpose: "Ack events" },
70
+ // Tier 1 — Raw FS (all file types)
71
+ { method: "GET", path: "/api/agent/fs/file/<path>", auth: "bearer+agent-id", purpose: "Read file bytes. Supports Range. Returns ETag (sha256), X-File-Size, X-File-Mtime, X-Collab-State." },
72
+ { method: "PUT", path: "/api/agent/fs/file/<path>", auth: "bearer+agent-id", purpose: "Atomic whole-file write. If-Match required for overwrites; omit for creates. ?mkdirs=true creates parents. ?force=true bypasses If-Match (audited)." },
73
+ { method: "DELETE", path: "/api/agent/fs/file/<path>", auth: "bearer+agent-id", purpose: "Delete file (+ sidecar for .md). Requires If-Match + delete scope op. ?recursive=true for directories." },
74
+ { method: "GET", path: "/api/agent/fs/ls/<path>", auth: "bearer+agent-id", purpose: "Directory listing. ?recursive&limit&depth. Scope-filtered. Excludes .proof/." },
75
+ { method: "POST", path: "/api/agent/fs/move", auth: "bearer+agent-id", purpose: "Move/rename. Body: {from, to, ifMatch?}. Moves .md sidecar too." },
76
+ { method: "POST", path: "/api/agent/fs/search", auth: "bearer+agent-id", purpose: "Server-side grep or glob. Body: {kind:'grep'|'glob', query, path?, glob?, limit?}." },
77
+ // Human presence (for editor lease — drives active collab-state)
78
+ { method: "POST", path: "/api/wiki/presence", auth: "session", purpose: "Human editor lease heartbeat. Body: {path, action:'open'|'heartbeat'|'close'}." },
66
79
  ],
80
+ capabilities: {
81
+ maxFileBytes: MAX_FILE_BYTES,
82
+ supportsRange: true,
83
+ ifMatchRequired: true,
84
+ forceBypass: true,
85
+ search: ["grep", "glob"],
86
+ globDialect: "**,*,?",
87
+ scopeOps: ["read", "mutate", "delete"],
88
+ collabStates: ["active", "tracked", "untracked", "not-markdown"],
89
+ collabPrecondition: "If-Collab-Match",
90
+ },
91
+ modes: {
92
+ rule: "Before editing a .md file, read it and check the X-Collab-State response header. If 'active', use block-ops (Tier 2) at the X-Collab-Snapshot URL so a human can review your changes. If 'tracked', prefer block-ops for semantic/prose edits; use raw fs only for mechanical/whole-file ops (reformat, regenerate). If 'untracked', raw fs is fine (a Tier-2 edit would create a sidecar). For non-markdown files, always use raw fs (Tier 2 does not apply). A raw PUT to an active .md is rejected 409 COLLAB_ACTIVE with the Tier-2 URL — switch to block-ops.",
93
+ collabStateHeaders: [
94
+ "X-Collab-State: active|tracked|untracked|not-markdown",
95
+ "X-Collab-Revision: <n>",
96
+ "X-Collab-Snapshot: /api/agent/files/<path>.md",
97
+ ],
98
+ tiers: {
99
+ tier1: "Raw FS — /api/agent/fs/* — all file types, fast, light audit (ETag/sha256, file.rawWritten event, audit table). No proof-spans.",
100
+ tier2: "Collab — /api/agent/files/*.md — markdown only, reviewable proof-spans, comments, suggestions. Human can accept/revert.",
101
+ },
102
+ },
103
+ mcpAdapter: {
104
+ package: "wiki-viewer-mcp",
105
+ invoke: "npx wiki-viewer-mcp",
106
+ env: ["WIKI_VIEWER_URL", "WIKI_VIEWER_TOKEN", "WIKI_VIEWER_AGENT_ID"],
107
+ description: "Thin MCP adapter mapping standard MCP filesystem tools onto these endpoints. Reads X-Collab-State and blocks or warns on raw writes to active .md files.",
108
+ },
67
109
  ops: [
68
110
  "block.replace",
69
111
  "block.insertAfter",
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
  import { NextResponse } from "next/server";
4
4
  import { checkOrigin } from "@/lib/auth/csrf";
5
5
  import { requireUser } from "@/lib/auth/server";
6
- import { safeRootPath } from "@/lib/root-dir";
6
+ import { safeRootPath, getRootDir } from "@/lib/root-dir";
7
+ import { moveSidecar } from "@/lib/proof/sidecar";
7
8
 
8
9
  export async function POST(request: Request) {
9
10
  const csrf = checkOrigin(request);
@@ -45,6 +46,14 @@ export async function POST(request: Request) {
45
46
 
46
47
  try {
47
48
  await rename(fromPath, toPath);
49
+
50
+ // Fix latent bug: sidecar was orphaned on .md renames (R3)
51
+ const fromExt = path.extname(body.from).toLowerCase();
52
+ if (fromExt === ".md" || fromExt === ".markdown") {
53
+ const rootDir = getRootDir();
54
+ await moveSidecar(rootDir, body.from, body.to);
55
+ }
56
+
48
57
  return NextResponse.json({ ok: true });
49
58
  } catch {
50
59
  return NextResponse.json({ error: "Move failed" }, { status: 500 });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * POST /api/wiki/presence
3
+ *
4
+ * Human editor heartbeat — sets or clears a human-edit lease so that
5
+ * computeCollabState can return "active" even before the first suggestion.
6
+ *
7
+ * Body: { path: string; action: "open" | "heartbeat" | "close" }
8
+ *
9
+ * Authenticated: requires a logged-in browser session (same as /api/wiki/watch).
10
+ * Agent tokens are NOT accepted here — this endpoint is human-only.
11
+ */
12
+ export const runtime = "nodejs";
13
+
14
+ import { NextResponse } from "next/server";
15
+ import { requireUser } from "@/lib/auth/server";
16
+ import { setLease, clearLease } from "@/lib/proof/lease";
17
+ import { safeRootPath } from "@/lib/root-dir";
18
+
19
+ function errJson(code: string, message: string, status: number): NextResponse {
20
+ return NextResponse.json({ error: code, message }, { status });
21
+ }
22
+
23
+ export async function POST(req: Request): Promise<NextResponse> {
24
+ const auth = await requireUser(req);
25
+ if (!auth.ok) return errJson("UNAUTHORIZED", "Not authenticated", 401);
26
+
27
+ let body: { path?: unknown; action?: unknown };
28
+ try {
29
+ body = (await req.json()) as { path?: unknown; action?: unknown };
30
+ } catch {
31
+ return errJson("INVALID_PAYLOAD", "Request body must be JSON", 400);
32
+ }
33
+
34
+ const relPath = typeof body.path === "string" ? body.path : null;
35
+ const action = typeof body.action === "string" ? body.action : null;
36
+
37
+ if (!relPath) return errJson("MISSING_PATH", "path is required", 400);
38
+ if (!action || !["open", "heartbeat", "close"].includes(action)) {
39
+ return errJson("INVALID_ACTION", "action must be open | heartbeat | close", 400);
40
+ }
41
+
42
+ // Basic traversal guard
43
+ if (!safeRootPath(relPath)) {
44
+ return errJson("INVALID_PATH", "Path traversal rejected", 400);
45
+ }
46
+
47
+ if (action === "open" || action === "heartbeat") {
48
+ setLease(relPath, auth.user.id);
49
+ } else {
50
+ // close
51
+ clearLease(relPath, auth.user.id);
52
+ }
53
+
54
+ return NextResponse.json({ ok: true });
55
+ }