wiki-viewer 1.6.0 → 1.7.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 (128) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/routes-manifest.json +6 -0
  5. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  24. package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
  33. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
  39. package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
  40. package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
  41. package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
  42. package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
  43. package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
  44. package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
  45. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
  47. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +2 -2
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  55. package/.next/standalone/.next/server/app/page.js +1 -1
  56. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/signin/page.js +1 -1
  59. package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  61. package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
  62. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
  64. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
  65. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
  70. package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_00650~e._.js → 0~w0_katex_dist_katex_mjs_0b8ra51._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
  77. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
  105. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  106. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  107. package/.next/standalone/.next/server/pages/404.html +1 -1
  108. package/.next/standalone/.next/server/pages/500.html +1 -1
  109. package/.next/standalone/.next/static/chunks/{0-qve5g~myvo-.js → 0vwkqw_rofm-c.js} +7 -7
  110. package/.next/standalone/package.json +2 -2
  111. package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
  112. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
  113. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
  114. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
  115. package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
  116. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
  117. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +35 -5
  118. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
  119. package/.next/standalone/pnpm-lock.yaml +3 -3
  120. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
  121. package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
  122. package/.next/standalone/src/app/page.tsx +20 -0
  123. package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
  124. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +2 -2
  126. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
  127. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
  128. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_ssgManifest.js +0 -0
@@ -24,6 +24,7 @@ import {
24
24
  isMarkdown,
25
25
  atomicWrite,
26
26
  ensureParentDir,
27
+ looksLikeBinary,
27
28
  } from "@/lib/proof/raw-fs";
28
29
  import { computeCollabState } from "@/lib/proof/collab-state";
29
30
 
@@ -125,16 +126,28 @@ export async function GET(
125
126
  });
126
127
  }
127
128
 
128
- // ── PUT ──────────────────────────────────────────────────────────────────────
129
+ // ── Shared mutation path (PUT + PATCH) ────────────────────────────────────────
129
130
 
130
- export async function PUT(
131
+ /**
132
+ * Single code path for all Tier-1 byte mutations so PUT and PATCH cannot drift
133
+ * on auth/scope/If-Match/R6/atomicWrite/reconcile/audit.
134
+ *
135
+ * `computeNewBody` receives the existing file buffer (or null if absent) and
136
+ * returns the new bytes, or an error response to short-circuit (e.g. PATCH
137
+ * match-count mismatch). `op` is the audit verb ("put" | "patch").
138
+ */
139
+ async function applyMutation(
131
140
  req: Request,
132
- { params }: { params: Promise<{ path: string[] }> },
141
+ segments: string[],
142
+ opts: {
143
+ op: "put" | "patch";
144
+ allowCreate: boolean;
145
+ computeNewBody: (existing: Buffer | null) => Buffer | NextResponse;
146
+ },
133
147
  ): Promise<NextResponse> {
134
148
  const auth = await checkAuth(req);
135
149
  if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
136
150
 
137
- const { path: segments } = await params;
138
151
  const relPath = rel(segments);
139
152
  const url = new URL(req.url);
140
153
  const mkdirs = url.searchParams.get("mkdirs") === "true";
@@ -150,56 +163,52 @@ export async function PUT(
150
163
  if (!scope.ok) return errJson(scope.code, scope.message, 403);
151
164
 
152
165
  const ifMatch = req.headers.get("if-match");
153
- const bodyBuf = Buffer.from(await req.arrayBuffer());
154
- const newSha = sha256ofBuf(bodyBuf);
155
-
156
- // Check existing file
157
- let existingSha: string | undefined;
158
- let existed = false;
159
- try {
160
- const existing = await readFile(absPath);
161
- existingSha = sha256ofBuf(existing);
162
- existed = true;
163
- } catch (e) {
164
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
165
- }
166
+ const ifCollabMatch = req.headers.get("if-collab-match");
167
+ const rootDir = getRootDir();
166
168
 
167
- if (existed) {
168
- // Overwrite: If-Match required by default (unless ?force=true)
169
- if (!force && !ifMatch) {
170
- return errJson(
171
- "PRECONDITION_REQUIRED",
172
- "If-Match header required for overwrites (use ?force=true to bypass with audit)",
173
- 412,
174
- );
169
+ // The whole read-existing → precondition → compute → write → reconcile → audit
170
+ // sequence runs inside the mutex for .md (so R6 and reconcile are atomic with
171
+ // the write); non-.md runs it without the mutex.
172
+ const doMutation = async (): Promise<NextResponse> => {
173
+ // Read existing
174
+ let existingBuf: Buffer | null = null;
175
+ let existingSha: string | undefined;
176
+ try {
177
+ existingBuf = await readFile(absPath);
178
+ existingSha = sha256ofBuf(existingBuf);
179
+ } catch (e) {
180
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
175
181
  }
176
- if (ifMatch) {
177
- const provided = extractShaHex(ifMatch);
178
- const current = extractShaHex(existingSha!);
179
- if (provided !== current) {
182
+ const existed = existingBuf !== null;
183
+
184
+ if (existed) {
185
+ // Overwrite: If-Match required by default (unless ?force=true)
186
+ if (!force && !ifMatch) {
187
+ return errJson(
188
+ "PRECONDITION_REQUIRED",
189
+ "If-Match header required for overwrites (use ?force=true to bypass with audit)",
190
+ 412,
191
+ );
192
+ }
193
+ if (ifMatch && extractShaHex(ifMatch) !== extractShaHex(existingSha!)) {
180
194
  return errJson("PRECONDITION_FAILED", "If-Match sha256 mismatch", 412);
181
195
  }
196
+ } else {
197
+ if (!opts.allowCreate) {
198
+ return errJson("NOT_FOUND", "File not found", 404);
199
+ }
200
+ const parentOk = await ensureParentDir(absPath, mkdirs);
201
+ if (!parentOk) {
202
+ return errJson(
203
+ "PARENT_NOT_FOUND",
204
+ "Parent directory does not exist (use ?mkdirs=true to create)",
205
+ 400,
206
+ );
207
+ }
182
208
  }
183
- } else {
184
- // Create: ensure parent dir exists
185
- const parentOk = await ensureParentDir(absPath, mkdirs);
186
- if (!parentOk) {
187
- return errJson(
188
- "PARENT_NOT_FOUND",
189
- "Parent directory does not exist (use ?mkdirs=true to create)",
190
- 400,
191
- );
192
- }
193
- }
194
209
 
195
- const rootDir = getRootDir();
196
- // R6: for .md, re-check collab state atomically inside write mutex
197
- const ifCollabMatch = req.headers.get("if-collab-match");
198
-
199
- if (isMarkdown(relPath)) {
200
- // R1: acquire shared mutex; R2: reconcile eagerly inside it
201
- return await withFileMutex(relPath, async () => {
202
- // R6: atomic collab-state check (closes TOCTOU race)
210
+ // R6: for .md, re-check collab state atomically (inside mutex)
211
+ if (isMarkdown(relPath)) {
203
212
  const { state, revision, snapshotUrl } = await computeCollabState(rootDir, relPath);
204
213
  if (state === "active") {
205
214
  const matchVal = ifCollabMatch?.trim();
@@ -216,9 +225,17 @@ export async function PUT(
216
225
  );
217
226
  }
218
227
  }
228
+ }
229
+
230
+ // Compute new bytes (PUT: request body; PATCH: str-replace on existing)
231
+ const computed = opts.computeNewBody(existingBuf);
232
+ if (computed instanceof NextResponse) return computed;
233
+ const bodyBuf = computed;
234
+ const newSha = sha256ofBuf(bodyBuf);
219
235
 
220
- await atomicWrite(absPath, bodyBuf);
236
+ await atomicWrite(absPath, bodyBuf);
221
237
 
238
+ if (isMarkdown(relPath)) {
222
239
  const content = bodyBuf.toString("utf-8");
223
240
  const sidecar = (await readSidecar(rootDir, relPath)) ?? emptySidecar(relPath);
224
241
  await reconcileSidecar({
@@ -230,44 +247,119 @@ export async function PUT(
230
247
  eventType: "file.rawWritten",
231
248
  fingerprint: newSha,
232
249
  });
250
+ }
233
251
 
234
- writeAuditRow({
235
- agentId: auth.agent.id,
236
- op: "put",
237
- path: relPath,
238
- oldSha: existingSha,
239
- newSha,
240
- forced: force,
241
- });
252
+ writeAuditRow({
253
+ agentId: auth.agent.id,
254
+ op: opts.op,
255
+ path: relPath,
256
+ oldSha: existingSha,
257
+ newSha,
258
+ forced: force,
259
+ });
242
260
 
243
- const st = await stat(absPath);
244
- return NextResponse.json({
245
- path: relPath,
246
- sha256: newSha,
247
- size: st.size,
248
- mtime: st.mtime.toISOString(),
249
- created: !existed,
250
- });
261
+ const st = await stat(absPath);
262
+ return NextResponse.json({
263
+ path: relPath,
264
+ sha256: newSha,
265
+ size: st.size,
266
+ mtime: st.mtime.toISOString(),
267
+ created: !existed,
251
268
  });
252
- }
269
+ };
253
270
 
254
- // Non-.md: atomic write + audit (no mutex, no sidecar)
255
- await atomicWrite(absPath, bodyBuf);
256
- writeAuditRow({
257
- agentId: auth.agent.id,
271
+ return isMarkdown(relPath) ? withFileMutex(relPath, doMutation) : doMutation();
272
+ }
273
+
274
+ // ── PUT ──────────────────────────────────────────────────────────────────────
275
+
276
+ export async function PUT(
277
+ req: Request,
278
+ { params }: { params: Promise<{ path: string[] }> },
279
+ ): Promise<NextResponse> {
280
+ const { path: segments } = await params;
281
+ const bodyBuf = Buffer.from(await req.arrayBuffer());
282
+ return applyMutation(req, segments, {
258
283
  op: "put",
259
- path: relPath,
260
- oldSha: existingSha,
261
- newSha,
262
- forced: force,
284
+ allowCreate: true,
285
+ computeNewBody: () => bodyBuf,
263
286
  });
264
- const st = await stat(absPath);
265
- return NextResponse.json({
266
- path: relPath,
267
- sha256: newSha,
268
- size: st.size,
269
- mtime: st.mtime.toISOString(),
270
- created: !existed,
287
+ }
288
+
289
+ // ── PATCH ─────────────────────────────────────────────────────────────────────
290
+ // Server-side str-replace so agents send only the change, not the whole file.
291
+ // Strict: exact substring (no regex), text/UTF-8 only, If-Match required,
292
+ // expectedOccurrences must match exactly (default 1). Shares applyMutation, so
293
+ // lock / R6 / reconcile / audit behave identically to PUT.
294
+
295
+ const MAX_PATCH_STRING = 1_000_000; // 1MB cap on find/replace strings
296
+
297
+ export async function PATCH(
298
+ req: Request,
299
+ { params }: { params: Promise<{ path: string[] }> },
300
+ ): Promise<NextResponse> {
301
+ const { path: segments } = await params;
302
+
303
+ let body: { find?: unknown; replace?: unknown; expectedOccurrences?: unknown };
304
+ try {
305
+ body = (await req.json()) as typeof body;
306
+ } catch {
307
+ return errJson("INVALID_PAYLOAD", "Body must be JSON {find, replace, expectedOccurrences?}", 400);
308
+ }
309
+ const find = body.find;
310
+ const replace = body.replace;
311
+ if (typeof find !== "string" || typeof replace !== "string") {
312
+ return errJson("INVALID_PAYLOAD", "find and replace must be strings", 400);
313
+ }
314
+ if (find.length === 0) {
315
+ return errJson("INVALID_PAYLOAD", "find must not be empty", 400);
316
+ }
317
+ if (find.length > MAX_PATCH_STRING || replace.length > MAX_PATCH_STRING) {
318
+ return errJson("PAYLOAD_TOO_LARGE", "find/replace exceeds 1MB limit", 413);
319
+ }
320
+ let expected = 1;
321
+ if (body.expectedOccurrences !== undefined) {
322
+ if (typeof body.expectedOccurrences !== "number" || !Number.isInteger(body.expectedOccurrences) || body.expectedOccurrences < 1) {
323
+ return errJson("INVALID_PAYLOAD", "expectedOccurrences must be a positive integer", 400);
324
+ }
325
+ expected = body.expectedOccurrences;
326
+ }
327
+
328
+ return applyMutation(req, segments, {
329
+ op: "patch",
330
+ allowCreate: false, // patch only edits existing files
331
+ computeNewBody: (existing) => {
332
+ if (existing === null) return errJson("NOT_FOUND", "File not found", 404);
333
+ if (looksLikeBinary(existing)) {
334
+ return errJson("UNSUPPORTED", "Cannot patch binary / non-text file", 415);
335
+ }
336
+ let text: string;
337
+ try {
338
+ text = new TextDecoder("utf-8", { fatal: true }).decode(existing);
339
+ } catch {
340
+ return errJson("UNSUPPORTED", "File is not valid UTF-8", 415);
341
+ }
342
+ // Count exact occurrences (no regex).
343
+ let count = 0;
344
+ let idx = text.indexOf(find);
345
+ while (idx !== -1) {
346
+ count++;
347
+ idx = text.indexOf(find, idx + find.length);
348
+ }
349
+ if (count !== expected) {
350
+ return NextResponse.json(
351
+ {
352
+ error: "MATCH_COUNT_MISMATCH",
353
+ message: `Expected ${expected} occurrence(s) of find, found ${count}. Re-read the file or adjust expectedOccurrences.`,
354
+ found: count,
355
+ expected,
356
+ },
357
+ { status: 422 },
358
+ );
359
+ }
360
+ // Replace all (count == expected) occurrences, literally.
361
+ return Buffer.from(text.split(find).join(replace), "utf-8");
362
+ },
271
363
  });
272
364
  }
273
365
 
@@ -0,0 +1,99 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import JSZip from "jszip";
6
+ import { NextResponse } from "next/server";
7
+ import { requireUser } from "@/lib/auth/server";
8
+ import { safeRootPath } from "@/lib/root-dir";
9
+
10
+ // Skip noise that should never end up in a downloaded archive.
11
+ const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".proof"]);
12
+
13
+ function contentDisposition(filename: string): string {
14
+ // RFC 5987: ASCII fallback + UTF-8 encoded form for non-ASCII names.
15
+ const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "'");
16
+ const encoded = encodeURIComponent(filename);
17
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
18
+ }
19
+
20
+ async function addDirToZip(
21
+ zip: JSZip,
22
+ absDir: string,
23
+ zipPrefix: string,
24
+ ): Promise<void> {
25
+ const names = await readdir(absDir);
26
+ for (const name of names) {
27
+ if (SKIP_DIRS.has(name)) continue;
28
+ const abs = path.join(absDir, name);
29
+ const info = await stat(abs);
30
+ const zipPath = zipPrefix ? `${zipPrefix}/${name}` : name;
31
+ if (info.isDirectory()) {
32
+ await addDirToZip(zip, abs, zipPath);
33
+ } else if (info.isFile()) {
34
+ zip.file(zipPath, await readFile(abs));
35
+ }
36
+ }
37
+ }
38
+
39
+ export async function GET(request: Request) {
40
+ const auth = await requireUser(request);
41
+ if (!auth.ok)
42
+ return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
43
+
44
+ const { searchParams } = new URL(request.url);
45
+ const rel = searchParams.get("path") ?? "";
46
+ const target = safeRootPath(rel);
47
+ if (!target)
48
+ return NextResponse.json({ error: "Invalid path" }, { status: 400 });
49
+
50
+ let info: Awaited<ReturnType<typeof stat>>;
51
+ try {
52
+ info = await stat(target);
53
+ } catch {
54
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
55
+ }
56
+
57
+ const baseName = path.basename(target) || "download";
58
+
59
+ if (info.isFile()) {
60
+ const webStream = Readable.toWeb(
61
+ createReadStream(target),
62
+ ) as ReadableStream;
63
+ return new Response(webStream, {
64
+ headers: {
65
+ "Content-Type": "application/octet-stream",
66
+ "Content-Length": String(info.size),
67
+ "Content-Disposition": contentDisposition(baseName),
68
+ "Cache-Control": "private, no-store",
69
+ },
70
+ });
71
+ }
72
+
73
+ if (info.isDirectory()) {
74
+ const zip = new JSZip();
75
+ try {
76
+ await addDirToZip(zip, target, "");
77
+ } catch {
78
+ return NextResponse.json(
79
+ { error: "Failed to read folder" },
80
+ { status: 500 },
81
+ );
82
+ }
83
+ const buffer = await zip.generateAsync({
84
+ type: "nodebuffer",
85
+ compression: "DEFLATE",
86
+ compressionOptions: { level: 6 },
87
+ });
88
+ return new Response(new Uint8Array(buffer), {
89
+ headers: {
90
+ "Content-Type": "application/zip",
91
+ "Content-Length": String(buffer.length),
92
+ "Content-Disposition": contentDisposition(`${baseName}.zip`),
93
+ "Cache-Control": "private, no-store",
94
+ },
95
+ });
96
+ }
97
+
98
+ return NextResponse.json({ error: "Unsupported path" }, { status: 400 });
99
+ }
@@ -6,6 +6,7 @@ import {
6
6
  Check,
7
7
  ChevronDown,
8
8
  ChevronRight,
9
+ Download,
9
10
  File,
10
11
  FilePlus,
11
12
  FileText,
@@ -742,6 +743,16 @@ export default function Page() {
742
743
  }
743
744
  }
744
745
 
746
+ function handleDownload(node: TreeNode) {
747
+ const url = `/api/wiki/download?path=${encodeURIComponent(node.path)}`;
748
+ const a = document.createElement("a");
749
+ a.href = url;
750
+ a.download = node.type === "file" ? node.name : `${node.name}.zip`;
751
+ document.body.appendChild(a);
752
+ a.click();
753
+ a.remove();
754
+ }
755
+
745
756
  async function handleDelete() {
746
757
  if (!deletingPath) return;
747
758
  await fetch("/api/wiki", {
@@ -956,6 +967,15 @@ export default function Page() {
956
967
  </Button>
957
968
  </>
958
969
  )}
970
+ <Button
971
+ size="sm"
972
+ variant="ghost"
973
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
974
+ title={node.type === "file" ? "Download" : "Download as zip"}
975
+ onClick={() => handleDownload(node)}
976
+ >
977
+ <Download className="h-3 w-3" />
978
+ </Button>
959
979
  <Button
960
980
  size="sm"
961
981
  variant="ghost"
@@ -23,6 +23,7 @@ import { _resetAuditDb } from "../../lib/proof/audit.js";
23
23
  // Route handlers (loaded after env is set)
24
24
  let fileGET: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
25
25
  let filePUT: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
26
+ let filePATCH: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
26
27
  let fileDELETE: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
27
28
  let lsGET: (req: Request, ctx: { params: Promise<{ path?: string[] }> }) => Promise<Response>;
28
29
  let movePOST: (req: Request) => Promise<Response>;
@@ -103,6 +104,7 @@ before(async () => {
103
104
  const fileRoute = await import("../../app/api/agent/fs/file/[...path]/route.js");
104
105
  fileGET = fileRoute.GET;
105
106
  filePUT = fileRoute.PUT;
107
+ filePATCH = fileRoute.PATCH;
106
108
  fileDELETE = fileRoute.DELETE;
107
109
 
108
110
  const lsRoute = await import("../../app/api/agent/fs/ls/[[...path]]/route.js");
@@ -822,3 +824,150 @@ test("traversal: .proof/ denied on PUT", async () => {
822
824
  const res = await filePUT(req, makeCtx([".proof", "inject.json"]));
823
825
  assert.equal(res.status, 400);
824
826
  });
827
+
828
+ // ── PATCH (server-side str-replace) ──────────────────────────────────────────
829
+
830
+ test("PATCH: str-replace on .md succeeds, sends only find/replace", async () => {
831
+ const orig = Buffer.from("# Title\n\nHello world. Keep this.\n");
832
+ await writeFile(path.join(tmpRoot, "patch1.md"), orig);
833
+ const req = new Request(fileUrl("patch1.md"), {
834
+ method: "PATCH",
835
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
836
+ body: JSON.stringify({ find: "Hello world", replace: "Goodbye moon" }),
837
+ });
838
+ const res = await filePATCH(req, makeCtx(["patch1.md"]));
839
+ assert.equal(res.status, 200, await res.text());
840
+ const out = await readFile(path.join(tmpRoot, "patch1.md"), "utf-8");
841
+ assert.equal(out, "# Title\n\nGoodbye moon. Keep this.\n");
842
+ });
843
+
844
+ test("PATCH: missing If-Match → 412", async () => {
845
+ const orig = Buffer.from("alpha beta gamma");
846
+ await writeFile(path.join(tmpRoot, "patch2.txt"), orig);
847
+ const req = new Request(fileUrl("patch2.txt"), {
848
+ method: "PATCH",
849
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json" },
850
+ body: JSON.stringify({ find: "beta", replace: "BETA" }),
851
+ });
852
+ const res = await filePATCH(req, makeCtx(["patch2.txt"]));
853
+ assert.equal(res.status, 412);
854
+ });
855
+
856
+ test("PATCH: If-Match mismatch → 412", async () => {
857
+ const orig = Buffer.from("alpha beta gamma");
858
+ await writeFile(path.join(tmpRoot, "patch3.txt"), orig);
859
+ const req = new Request(fileUrl("patch3.txt"), {
860
+ method: "PATCH",
861
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": "sha256:deadbeef" },
862
+ body: JSON.stringify({ find: "beta", replace: "BETA" }),
863
+ });
864
+ const res = await filePATCH(req, makeCtx(["patch3.txt"]));
865
+ assert.equal(res.status, 412);
866
+ });
867
+
868
+ test("PATCH: zero matches → 422 MATCH_COUNT_MISMATCH", async () => {
869
+ const orig = Buffer.from("alpha beta gamma");
870
+ await writeFile(path.join(tmpRoot, "patch4.txt"), orig);
871
+ const req = new Request(fileUrl("patch4.txt"), {
872
+ method: "PATCH",
873
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
874
+ body: JSON.stringify({ find: "nonexistent", replace: "x" }),
875
+ });
876
+ const res = await filePATCH(req, makeCtx(["patch4.txt"]));
877
+ assert.equal(res.status, 422);
878
+ const j = await res.json();
879
+ assert.equal(j.error, "MATCH_COUNT_MISMATCH");
880
+ assert.equal(j.found, 0);
881
+ });
882
+
883
+ test("PATCH: multiple matches but expected 1 → 422", async () => {
884
+ const orig = Buffer.from("foo bar foo baz foo");
885
+ await writeFile(path.join(tmpRoot, "patch5.txt"), orig);
886
+ const req = new Request(fileUrl("patch5.txt"), {
887
+ method: "PATCH",
888
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
889
+ body: JSON.stringify({ find: "foo", replace: "X" }),
890
+ });
891
+ const res = await filePATCH(req, makeCtx(["patch5.txt"]));
892
+ assert.equal(res.status, 422);
893
+ const j = await res.json();
894
+ assert.equal(j.found, 3);
895
+ });
896
+
897
+ test("PATCH: expectedOccurrences matching multi replaces all", async () => {
898
+ const orig = Buffer.from("foo bar foo baz foo");
899
+ await writeFile(path.join(tmpRoot, "patch6.txt"), orig);
900
+ const req = new Request(fileUrl("patch6.txt"), {
901
+ method: "PATCH",
902
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
903
+ body: JSON.stringify({ find: "foo", replace: "X", expectedOccurrences: 3 }),
904
+ });
905
+ const res = await filePATCH(req, makeCtx(["patch6.txt"]));
906
+ assert.equal(res.status, 200, await res.text());
907
+ const out = await readFile(path.join(tmpRoot, "patch6.txt"), "utf-8");
908
+ assert.equal(out, "X bar X baz X");
909
+ });
910
+
911
+ test("PATCH: empty find → 400", async () => {
912
+ const orig = Buffer.from("abc");
913
+ await writeFile(path.join(tmpRoot, "patch7.txt"), orig);
914
+ const req = new Request(fileUrl("patch7.txt"), {
915
+ method: "PATCH",
916
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
917
+ body: JSON.stringify({ find: "", replace: "x" }),
918
+ });
919
+ const res = await filePATCH(req, makeCtx(["patch7.txt"]));
920
+ assert.equal(res.status, 400);
921
+ });
922
+
923
+ test("PATCH: binary file → 415", async () => {
924
+ const orig = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00, 0x10]);
925
+ await writeFile(path.join(tmpRoot, "patch8.bin"), orig);
926
+ const req = new Request(fileUrl("patch8.bin"), {
927
+ method: "PATCH",
928
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
929
+ body: JSON.stringify({ find: "x", replace: "y" }),
930
+ });
931
+ const res = await filePATCH(req, makeCtx(["patch8.bin"]));
932
+ assert.equal(res.status, 415);
933
+ });
934
+
935
+ test("PATCH: nonexistent file → 404 (no create)", async () => {
936
+ const req = new Request(fileUrl("patch-missing.txt"), {
937
+ method: "PATCH",
938
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": "sha256:abc" },
939
+ body: JSON.stringify({ find: "a", replace: "b" }),
940
+ });
941
+ const res = await filePATCH(req, makeCtx(["patch-missing.txt"]));
942
+ assert.equal(res.status, 404);
943
+ });
944
+
945
+ test("PATCH: .md emits file.rawWritten + reconciles (same path as PUT)", async () => {
946
+ const orig = Buffer.from("# H\n\noriginal paragraph here\n");
947
+ await writeFile(path.join(tmpRoot, "patch-md.md"), orig);
948
+ const req = new Request(fileUrl("patch-md.md"), {
949
+ method: "PATCH",
950
+ headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
951
+ body: JSON.stringify({ find: "original", replace: "updated" }),
952
+ });
953
+ const res = await filePATCH(req, makeCtx(["patch-md.md"]));
954
+ assert.equal(res.status, 200, await res.text());
955
+ const sidecar = await readSidecar(tmpRoot, "patch-md.md");
956
+ assert.ok(sidecar, "sidecar created");
957
+ const events = sidecar!.events.filter((e) => e.type === "file.rawWritten");
958
+ assert.ok(events.length > 0, "file.rawWritten event present");
959
+ const newContent = await readFile(path.join(tmpRoot, "patch-md.md"), "utf-8");
960
+ assert.equal(sidecar!.fingerprint, sha256(Buffer.from(newContent)), "fingerprint matches new content (eager reconcile)");
961
+ });
962
+
963
+ test("PATCH: requires mutate scope", async () => {
964
+ const orig = Buffer.from("hello");
965
+ await writeFile(path.join(tmpRoot, "patch-scope.txt"), orig);
966
+ const req = new Request(fileUrl("patch-scope.txt"), {
967
+ method: "PATCH",
968
+ headers: { ...agentHeaders(READ_TOKEN, "ai:read-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
969
+ body: JSON.stringify({ find: "hello", replace: "hi" }),
970
+ });
971
+ const res = await filePATCH(req, makeCtx(["patch-scope.txt"]));
972
+ assert.equal(res.status, 403);
973
+ });