@openparachute/vault 0.7.2-rc.6 → 0.7.2
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/core/src/seed-packs.ts +19 -4
- package/package.json +1 -1
- package/src/routes.ts +49 -2
- package/src/server.ts +9 -1
- package/src/storage.test.ts +122 -2
package/core/src/seed-packs.ts
CHANGED
|
@@ -382,6 +382,16 @@ file one document, or bring in one small batch — then stop and show them what
|
|
|
382
382
|
happened. Structure grows from real notes (that's Part 2's design vocabulary).
|
|
383
383
|
Setup is a relationship over many sessions, not an install step.
|
|
384
384
|
|
|
385
|
+
**Capture first, ask second.** If the person's very first message already names
|
|
386
|
+
something real — a live project, a decision they're weighing, a deadline —
|
|
387
|
+
**capture THAT as the first note from what they've told you, before asking for
|
|
388
|
+
more detail.** Don't interview them into an empty vault: the note you can
|
|
389
|
+
already write is worth more than the three you're still asking about, and your
|
|
390
|
+
questions land better as the follow-up to a captured thing ("got the commission
|
|
391
|
+
down — when's it due, and who's the client?") than as the opener. A good first
|
|
392
|
+
conversation that leaves the vault empty is a miss, not a success — something
|
|
393
|
+
they said should have stuck.
|
|
394
|
+
|
|
385
395
|
Five short guides ship in this vault for the person to read (tagged \`#guide\`):
|
|
386
396
|
[[Welcome to your vault 🪂]], [[Capture anything]], [[Tags and the graph]],
|
|
387
397
|
[[Connect your AI]], and [[Yours to keep]]. Point them there for anything you'd
|
|
@@ -484,7 +494,12 @@ These three axes are the heart of vault design. Use the right one for the job:
|
|
|
484
494
|
marked \`indexed: true\` to make it **queryable with operators** (\`query-notes
|
|
485
495
|
{ tag: "meeting", metadata: { held_on: { gte: "2026-01-01" } } }\`); indexing
|
|
486
496
|
is opt-in per field, not automatic. Add a schema (and index a field) when you
|
|
487
|
-
find yourself wanting to filter or sort on a value, not before.
|
|
497
|
+
find yourself wanting to filter or sort on a value, not before. **Field names
|
|
498
|
+
are global across the vault, not scoped per tag** — two tags that declare the
|
|
499
|
+
same field name must agree on its type. So prefer a tag-specific name
|
|
500
|
+
(\`price_tier\` on \`#restaurant\`, \`book_rating\` on \`#book\`) over a generic
|
|
501
|
+
shared one (a \`rating\` that means 1–10 on one tag and \`$\`–\`$$$$\` on another
|
|
502
|
+
collides); when in doubt, name the field for the tag it belongs to.
|
|
488
503
|
|
|
489
504
|
Rule of thumb: **type with tags, file with paths, make-it-queryable with
|
|
490
505
|
schemas.** Start minimal — invent tags as real notes need them, declare a
|
|
@@ -502,7 +517,7 @@ update-tag {
|
|
|
502
517
|
fields: {
|
|
503
518
|
held_on: { type: "string", indexed: true }, // queryable with operators
|
|
504
519
|
status: { type: "string", enum: ["scheduled", "done"], default: "scheduled" }, // explicit default — declare one to auto-fill
|
|
505
|
-
|
|
520
|
+
meeting_rating: { type: "integer" } // no default — stays unset until written; tag-specific name, not a bare rating
|
|
506
521
|
}
|
|
507
522
|
}
|
|
508
523
|
\`\`\`
|
|
@@ -556,8 +571,8 @@ A few behaviors worth knowing before you write at scale:
|
|
|
556
571
|
- **A schema field only back-fills when you declare an explicit \`default\`.**
|
|
557
572
|
When a note gets a tag whose schema declares a field with a \`default\`, an
|
|
558
573
|
unset field is filled with THAT value. A field with no \`default\` stays
|
|
559
|
-
genuinely absent — \`query-notes { metadata: {
|
|
560
|
-
reliably finds notes that never set \`
|
|
574
|
+
genuinely absent — \`query-notes { metadata: { meeting_rating: { exists: false } } }\`
|
|
575
|
+
reliably finds notes that never set \`meeting_rating\`. Declare \`default\` on a field
|
|
561
576
|
only when "unset" and "explicitly set to X" should read the same; leave it
|
|
562
577
|
off when you need to tell them apart.
|
|
563
578
|
- **Validation is advisory, never blocking — EXCEPT an \`indexed: true\`
|
package/package.json
CHANGED
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
|
-
|
|
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);
|
package/src/storage.test.ts
CHANGED
|
@@ -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
|
//
|