@openparachute/vault 0.7.2-rc.6 → 0.7.2-rc.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2-rc.6",
3
+ "version": "0.7.2-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -4255,7 +4255,30 @@ async function handleRetryLegacyInBody(
4255
4255
  // existing importers are unaffected.
4256
4256
  // ---------------------------------------------------------------------------
4257
4257
 
4258
- const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4258
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4259
+
4260
+ /**
4261
+ * Transport-level `Bun.serve` `maxRequestBodySize` ceiling (vault#588 FIX 2).
4262
+ *
4263
+ * The app-level caps above (`MAX_JSON_BODY_BYTES` 10MB, `MAX_UPLOAD_BYTES`
4264
+ * 100MB) only run AFTER the transport has already buffered the body: the
4265
+ * `Content-Length` pre-check in `parseJsonBody` short-circuits early, but a
4266
+ * chunked request with no `Content-Length` header falls through to the
4267
+ * post-parse backstop — which still means Bun buffered the whole thing into
4268
+ * memory first. Bun's *default* `maxRequestBodySize` (128MB) happened to sit
4269
+ * above `MAX_UPLOAD_BYTES` and so never bit in practice, but it was never
4270
+ * actually configured — an accident of the default, not a deliberate
4271
+ * ceiling wired to this codebase's own limits.
4272
+ *
4273
+ * Set explicitly here so the ceiling is legible and reconciled: it MUST be
4274
+ * >= `MAX_UPLOAD_BYTES` (a legitimate max-size attachment upload must not be
4275
+ * rejected at the transport layer before `/upload`'s own 100MB check ever
4276
+ * runs) with headroom for multipart overhead (boundary delimiters, per-part
4277
+ * headers — a few hundred bytes in practice, but the 20MB margin is
4278
+ * generous rather than exact). `MAX_JSON_BODY_BYTES` (10MB) is well under
4279
+ * `MAX_UPLOAD_BYTES`, so it never drives this number.
4280
+ */
4281
+ export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
4259
4282
 
4260
4283
  // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
4261
4284
  // arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
@@ -4359,7 +4382,31 @@ export async function handleStorage(
4359
4382
  const assets = assetsDir(vault);
4360
4383
 
4361
4384
  if (req.method === "POST" && path === "/upload") {
4362
- const form = await req.formData();
4385
+ // vault#588 FIX 1 — `req.formData()` throws on a malformed/non-multipart
4386
+ // body (bad boundary, truncated body, wrong Content-Type entirely). Left
4387
+ // uncaught, that throw escapes to server.ts's generic top-level catch: a
4388
+ // 500 with no `error_type` — the multipart-transport analog of the
4389
+ // `req.json()` gap LB7 fixed for the JSON routes (`parseJsonBody` above).
4390
+ // Catch it here and return the same `invalid_request` taxonomy entry
4391
+ // LB7b uses for "syntactically-parseable-transport but wrong/unusable
4392
+ // shape" (see docs/HTTP_API.md's error-taxonomy table) rather than
4393
+ // minting a new `invalid_form` type for what's ultimately the same bucket.
4394
+ // Typed off `req.formData()`'s own return, not the DOM lib `FormData`
4395
+ // global — `req: Request` resolves through undici's ambient types here,
4396
+ // whose `FormData` isn't structurally assignable to lib.dom's.
4397
+ let form: Awaited<ReturnType<typeof req.formData>>;
4398
+ try {
4399
+ form = await req.formData();
4400
+ } catch {
4401
+ return json(
4402
+ {
4403
+ error: "Request body must be valid multipart/form-data",
4404
+ error_type: "invalid_request",
4405
+ hint: "expected a multipart/form-data body with a `file` field",
4406
+ },
4407
+ 400,
4408
+ );
4409
+ }
4363
4410
  const file = form.get("file");
4364
4411
  if (!(file instanceof File)) {
4365
4412
  return json({ error: "file is required", error_type: "missing_required_field", field: "file" }, 400);
package/src/server.ts CHANGED
@@ -27,7 +27,7 @@ import { loadVaultTriggers } from "./triggers-api.ts";
27
27
  import { route } from "./routing.ts";
28
28
  import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
29
29
  import { setTranscriptionWorker } from "./transcription-registry.ts";
30
- import { assetsDir } from "./routes.ts";
30
+ import { assetsDir, MAX_REQUEST_BODY_BYTES } from "./routes.ts";
31
31
  import { resolveScribeAuthToken, ensureScribeBearer } from "./scribe-env.ts";
32
32
  import { getCachedScribeUrl } from "./scribe-discovery.ts";
33
33
  import { TranscribeCppProvider } from "./transcription/providers/transcribe-cpp.ts";
@@ -502,6 +502,14 @@ const server = Bun.serve({
502
502
  port,
503
503
  hostname,
504
504
  idleTimeout: 120, // seconds — webhook triggers can take a while
505
+ // vault#588 FIX 2 — explicit transport-level body-size ceiling. Without
506
+ // this, Bun's *default* maxRequestBodySize (128MB) is the only backstop
507
+ // for a chunked request with no Content-Length header (the app-level JSON
508
+ // cap in parseJsonBody's post-parse branch still buffers the full body
509
+ // first). MAX_REQUEST_BODY_BYTES is MAX_UPLOAD_BYTES (100MB) + headroom —
510
+ // see its doc comment in routes.ts for the reconciliation with the
511
+ // /upload and JSON-body app-level caps.
512
+ maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
505
513
  websocket: subscribeWs.handlers,
506
514
  async fetch(req, server) {
507
515
  const url = new URL(req.url);
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
17
- import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
17
+ import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
18
18
  import { join } from "path";
19
19
  import { tmpdir } from "os";
20
20
  import { Database } from "bun:sqlite";
@@ -30,7 +30,7 @@ const testDir = join(
30
30
  process.env.PARACHUTE_HOME = testDir;
31
31
  process.env.ASSETS_DIR = join(testDir, "assets");
32
32
 
33
- const { handleStorage } = await import("./routes.ts");
33
+ const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES } = await import("./routes.ts");
34
34
  const { expandTokenTagScope } = await import("./tag-scope.ts");
35
35
 
36
36
  // The upload-allowlist tests never touch the store (POST /upload writes to
@@ -176,6 +176,126 @@ describe("storage upload allowlist", () => {
176
176
  });
177
177
  });
178
178
 
179
+ // ---------------------------------------------------------------------------
180
+ // vault#588 FIX 1 — a malformed/non-multipart POST /upload body used to
181
+ // throw uncaught out of `req.formData()`, escaping to server.ts's generic
182
+ // top-level catch: a 500 with no `error_type`. Same class LB7 (vault.test.ts)
183
+ // fixed for `req.json()` on the JSON-bodied mutating routes, applied to the
184
+ // multipart transport. Every assertion here MUST fail without the fix.
185
+ // ---------------------------------------------------------------------------
186
+ describe("storage upload — malformed multipart body (vault#588 FIX 1)", () => {
187
+ test("garbage body + a multipart Content-Type header -> 400 invalid_request, not 500", async () => {
188
+ const req = new Request("http://localhost:1940/storage/upload", {
189
+ method: "POST",
190
+ body: "not a valid multipart body at all",
191
+ headers: { "Content-Type": "multipart/form-data; boundary=----doesNotMatchBody" },
192
+ });
193
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
194
+ expect(res.status).toBe(400);
195
+ const body = (await res.json()) as { error_type: string };
196
+ expect(body.error_type).toBe("invalid_request");
197
+ });
198
+
199
+ test("plain-text body with no multipart Content-Type at all -> 400 invalid_request, not 500", async () => {
200
+ const req = new Request("http://localhost:1940/storage/upload", {
201
+ method: "POST",
202
+ body: "just some text, not multipart",
203
+ headers: { "Content-Type": "text/plain" },
204
+ });
205
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
206
+ expect(res.status).toBe(400);
207
+ const body = (await res.json()) as { error_type: string };
208
+ expect(body.error_type).toBe("invalid_request");
209
+ });
210
+
211
+ test("empty body -> 400 invalid_request, not 500", async () => {
212
+ const req = new Request("http://localhost:1940/storage/upload", {
213
+ method: "POST",
214
+ body: "",
215
+ headers: { "Content-Type": "multipart/form-data; boundary=----empty" },
216
+ });
217
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
218
+ expect(res.status).toBe(400);
219
+ const body = (await res.json()) as { error_type: string };
220
+ expect(body.error_type).toBe("invalid_request");
221
+ });
222
+
223
+ test("well-formed multipart form MISSING the `file` field -> 400 missing_required_field (unchanged, not a TypeError)", async () => {
224
+ const form = new FormData();
225
+ form.set("not_file", "some value");
226
+ const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
227
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
228
+ expect(res.status).toBe(400);
229
+ const body = (await res.json()) as { error_type: string; field: string };
230
+ expect(body.error_type).toBe("missing_required_field");
231
+ expect(body.field).toBe("file");
232
+ });
233
+
234
+ test("a well-formed upload still succeeds (regression — the catch doesn't swallow the happy path)", async () => {
235
+ const res = await handleStorage(uploadRequest("still-works.pdf", "application/pdf"), "/upload", "default", uploadStore);
236
+ expect(res.status).toBe(201);
237
+ const body = (await res.json()) as { mimeType: string };
238
+ expect(body.mimeType).toBe("application/pdf");
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // vault#588 FIX 2 — explicit Bun.serve `maxRequestBodySize` transport ceiling.
244
+ // The JSON-body cap (MAX_JSON_BODY_BYTES) only gates AFTER the transport
245
+ // already buffered the body when Content-Length is absent (chunked
246
+ // requests); before this fix, the only backstop was Bun's unconfigured
247
+ // default (128MB). MAX_REQUEST_BODY_BYTES must sit >= MAX_UPLOAD_BYTES (the
248
+ // /upload app-level cap) with headroom, or a legitimate max-size attachment
249
+ // upload would be rejected at the transport layer before /upload's own
250
+ // 100MB check ever runs. These pin the reconciliation and the wiring;
251
+ // see routes.ts's MAX_REQUEST_BODY_BYTES doc comment for the full rationale.
252
+ // ---------------------------------------------------------------------------
253
+ describe("transport-level request-body ceiling (vault#588 FIX 2)", () => {
254
+ test("MAX_REQUEST_BODY_BYTES is comfortably >= MAX_UPLOAD_BYTES (never caps a legitimate attachment below its own app-level limit)", () => {
255
+ expect(MAX_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
256
+ expect(MAX_REQUEST_BODY_BYTES).toBeGreaterThanOrEqual(MAX_UPLOAD_BYTES);
257
+ // Pin the chosen ceiling (100MB upload cap + 20MB multipart-overhead
258
+ // headroom = 120MB) so a future edit that silently narrows it fails loudly.
259
+ expect(MAX_REQUEST_BODY_BYTES).toBe(120 * 1024 * 1024);
260
+ });
261
+
262
+ test("server.ts wires maxRequestBodySize to MAX_REQUEST_BODY_BYTES on the Bun.serve config", () => {
263
+ // A config-level assertion (per vault#588's own guidance — a real
264
+ // >MAX_REQUEST_BODY_BYTES streaming test isn't worth the fixture cost).
265
+ // server.ts's `Bun.serve({...})` is a module-level side effect (it boots
266
+ // the real listener on import), so we can't import it in-test; read the
267
+ // source instead to confirm the constant is actually wired in, not just
268
+ // defined and forgotten.
269
+ const serverSrc = readFileSync(join(import.meta.dir, "server.ts"), "utf8");
270
+ expect(serverSrc).toMatch(/import\s*\{[^}]*MAX_REQUEST_BODY_BYTES[^}]*\}\s*from\s*"\.\/routes\.ts"/);
271
+ expect(serverSrc).toMatch(/maxRequestBodySize:\s*MAX_REQUEST_BODY_BYTES/);
272
+ });
273
+
274
+ test("a legitimate upload at exactly MAX_UPLOAD_BYTES still succeeds (not capped below its own limit)", async () => {
275
+ const bytes = new Uint8Array(MAX_UPLOAD_BYTES);
276
+ const file = new File([bytes], "max-size.bin", { type: "application/octet-stream" });
277
+ const form = new FormData();
278
+ form.set("file", file);
279
+ const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
280
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
281
+ expect(res.status).toBe(201);
282
+ const body = (await res.json()) as { size: number };
283
+ expect(body.size).toBe(MAX_UPLOAD_BYTES);
284
+ });
285
+
286
+ test("one byte over MAX_UPLOAD_BYTES still rejects at the app-level gate (413 file_too_large, unaffected by the transport ceiling)", async () => {
287
+ const bytes = new Uint8Array(MAX_UPLOAD_BYTES + 1);
288
+ const file = new File([bytes], "over-size.bin", { type: "application/octet-stream" });
289
+ const form = new FormData();
290
+ form.set("file", file);
291
+ const req = new Request("http://localhost:1940/storage/upload", { method: "POST", body: form });
292
+ const res = await handleStorage(req, "/upload", "default", uploadStore);
293
+ expect(res.status).toBe(413);
294
+ const body = (await res.json()) as { error_type: string };
295
+ expect(body.error_type).toBe("file_too_large");
296
+ });
297
+ });
298
+
179
299
  // ---------------------------------------------------------------------------
180
300
  // GET byte-serve tag-scope enforcement (C0 adversarial-audit finding).
181
301
  //