@openparachute/vault 0.7.3-rc.8 → 0.7.3

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 (44) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/content-range-constants.ts +19 -0
  4. package/core/src/content-range.test.ts +127 -0
  5. package/core/src/content-range.ts +105 -8
  6. package/core/src/core.test.ts +66 -4
  7. package/core/src/expand.ts +11 -3
  8. package/core/src/lede.test.ts +96 -0
  9. package/core/src/mcp-manifest.test.ts +200 -0
  10. package/core/src/mcp-manifest.ts +736 -0
  11. package/core/src/mcp.ts +357 -607
  12. package/core/src/notes.ts +69 -10
  13. package/core/src/vault-projection.ts +17 -10
  14. package/package.json +1 -1
  15. package/src/attachment-bytes.ts +68 -0
  16. package/src/attachment-tickets.test.ts +126 -1
  17. package/src/attachment-tickets.ts +77 -1
  18. package/src/auth-hub-jwt.test.ts +118 -1
  19. package/src/auth.ts +64 -0
  20. package/src/config.test.ts +16 -0
  21. package/src/config.ts +17 -0
  22. package/src/embedding/select.test.ts +58 -30
  23. package/src/embedding/select.ts +62 -21
  24. package/src/live-frame-parity.test.ts +21 -0
  25. package/src/mcp-http.ts +20 -3
  26. package/src/mcp-tools.ts +15 -3
  27. package/src/oauth-discovery.ts +31 -0
  28. package/src/read-attachment.test.ts +436 -0
  29. package/src/routes.ts +80 -4
  30. package/src/routing.test.ts +229 -4
  31. package/src/routing.ts +135 -23
  32. package/src/scopes.ts +22 -0
  33. package/src/server.ts +17 -8
  34. package/src/storage.test.ts +200 -1
  35. package/src/subscriptions.ts +13 -1
  36. package/src/transcription-worker.test.ts +151 -0
  37. package/src/transcription-worker.ts +113 -52
  38. package/src/vault-embeddings-capability.test.ts +28 -6
  39. package/src/vault-store-embedding-wiring.test.ts +25 -16
  40. package/src/vault-store.ts +32 -16
  41. package/src/vault.test.ts +26 -13
  42. package/src/ws-server.ts +9 -1
  43. package/src/ws-subscribe.test.ts +87 -0
  44. package/src/ws-subscribe.ts +25 -6
package/core/src/notes.ts CHANGED
@@ -2778,6 +2778,22 @@ export const DISPLAY_TITLE_MAX_LEN = 120;
2778
2778
  */
2779
2779
  const FRONTMATTER_SCAN_LINES = 100;
2780
2780
 
2781
+ /**
2782
+ * Shared by `computeDisplayTitle` and `computeLede`: given `content` already
2783
+ * split into `lines`, return the index of the first line that counts as the
2784
+ * start of the DOCUMENT body — after a leading closed frontmatter block, if
2785
+ * `content` opens with one (see `computeDisplayTitle`'s frontmatter-skip
2786
+ * doc for the full rationale). `0` when there's no leading frontmatter.
2787
+ */
2788
+ function skipLeadingFrontmatter(lines: string[]): number {
2789
+ if (lines[0]?.trim() !== "---") return 0;
2790
+ const scanLimit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
2791
+ for (let i = 1; i < scanLimit; i++) {
2792
+ if (lines[i]?.trim() === "---") return i + 1;
2793
+ }
2794
+ return 0;
2795
+ }
2796
+
2781
2797
  /**
2782
2798
  * Derive a note's display title: the first non-empty line of `content`,
2783
2799
  * with a leading markdown heading marker (`#` through `######`) and its
@@ -2807,16 +2823,7 @@ const FRONTMATTER_SCAN_LINES = 100;
2807
2823
  export function computeDisplayTitle(content: string | null | undefined): string | null {
2808
2824
  if (!content) return null;
2809
2825
  const lines = content.split("\n");
2810
- let startIndex = 0;
2811
- if (lines[0]?.trim() === "---") {
2812
- const scanLimit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
2813
- for (let i = 1; i < scanLimit; i++) {
2814
- if (lines[i]?.trim() === "---") {
2815
- startIndex = i + 1;
2816
- break;
2817
- }
2818
- }
2819
- }
2826
+ const startIndex = skipLeadingFrontmatter(lines);
2820
2827
  for (let i = startIndex; i < lines.length; i++) {
2821
2828
  const stripped = lines[i]!.replace(/^#{1,6}\s*/, "").trim();
2822
2829
  if (stripped === "") continue;
@@ -2829,6 +2836,58 @@ export function computeDisplayTitle(content: string | null | undefined): string
2829
2836
  return null;
2830
2837
  }
2831
2838
 
2839
+ /** Max code points in a computed `lede` (summaries-as-content, 2026-07-17 dialogue). */
2840
+ export const LEDE_MAX_LEN = 400;
2841
+
2842
+ /**
2843
+ * Derive a note's "lede": the first non-empty PARAGRAPH after the title
2844
+ * line (a run of consecutive non-blank lines, whitespace-collapsed to one
2845
+ * line, truncated to `LEDE_MAX_LEN` code points). `null` when there's no
2846
+ * paragraph after the title — a title-only note has no lede to report, and
2847
+ * callers must not fall back to repeating the title itself.
2848
+ *
2849
+ * This is the machinery half of a soft convention (2026-07-17 dialogue):
2850
+ * summaries work better as visible CONTENT — a note's opening paragraph —
2851
+ * than as hidden metadata, because visible text gets corrected by the notes
2852
+ * that reference it while hidden metadata rots unnoticed. Nothing validates
2853
+ * that a note actually opens with a title + lede; this function just reports
2854
+ * what it finds, honestly, using the SAME title-line rule as
2855
+ * `computeDisplayTitle` (including its frontmatter skip) so a caller that
2856
+ * shows both title and lede sees them agree on where the title ends.
2857
+ */
2858
+ export function computeLede(content: string | null | undefined): string | null {
2859
+ if (!content) return null;
2860
+ const lines = content.split("\n");
2861
+ const bodyStart = skipLeadingFrontmatter(lines);
2862
+
2863
+ let titleLine = -1;
2864
+ for (let i = bodyStart; i < lines.length; i++) {
2865
+ if (lines[i]!.replace(/^#{1,6}\s*/, "").trim() !== "") {
2866
+ titleLine = i;
2867
+ break;
2868
+ }
2869
+ }
2870
+ if (titleLine === -1) return null; // no title at all — nothing to find a lede after
2871
+
2872
+ let i = titleLine + 1;
2873
+ while (i < lines.length && lines[i]!.trim() === "") i++; // skip blank lines after the title
2874
+
2875
+ const paragraphLines: string[] = [];
2876
+ while (i < lines.length && lines[i]!.trim() !== "") {
2877
+ paragraphLines.push(lines[i]!);
2878
+ i++;
2879
+ }
2880
+ if (paragraphLines.length === 0) return null; // title-only note
2881
+
2882
+ const paragraph = paragraphLines.join(" ").replace(/\s+/g, " ").trim();
2883
+ if (paragraph === "") return null;
2884
+
2885
+ const codePoints = Array.from(paragraph);
2886
+ return codePoints.length > LEDE_MAX_LEN
2887
+ ? codePoints.slice(0, LEDE_MAX_LEN).join("")
2888
+ : paragraph;
2889
+ }
2890
+
2832
2891
  /**
2833
2892
  * Convert a full Note into its lean index shape:
2834
2893
  * drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a
@@ -283,27 +283,34 @@ function sqliteToUserType(t: string): string {
283
283
  * its own context on them (bytes never ride MCP either way).
284
284
  *
285
285
  * `ticketsEnabled` reflects whether THIS door has wired an
286
- * `AttachmentTicketProvider` (bun: always, as of this PR; cloud: not yet
287
- * — its mirror is a separate PR). An unwired door omits BOTH the ticket
288
- * tools from `tools/list` (see `generateMcpTools`'s `attachmentTickets`
289
- * opt) AND the ticket-tool sentences here the brief never dangles a
290
- * pointer at a tool the agent can't actually call.
286
+ * `AttachmentTicketProvider` (bun: always, as of the Wave 1 PR; cloud: not
287
+ * yet — its mirror is a separate PR). `readEnabled` reflects whether it's
288
+ * wired an `AttachmentBytesProvider` (bun: always, as of this PR — Wave 2).
289
+ * An unwired seam omits BOTH its tool(s) from `tools/list` (see
290
+ * `generateMcpTools`'s `attachmentTickets` / `attachmentBytes` opts) AND
291
+ * its sentence here — the brief never dangles a pointer at a tool the
292
+ * agent can't actually call.
291
293
  *
292
294
  * Kept as a single dense paragraph (not its own multi-line list) to stay
293
295
  * inside the connect-time brief's token budget — see
294
296
  * `projectionToMarkdown`'s doc comment.
295
297
  */
296
- export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
298
+ export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean; readEnabled?: boolean }): string {
297
299
  const sentences: string[] = [
298
- "Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
300
+ "Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP tool RESULTS unless you ask for them).",
299
301
  ];
300
302
  if (opts.ticketsEnabled) {
301
303
  sentences.push(
302
- "To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
304
+ "To move bytes without spending your own context, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
305
+ );
306
+ }
307
+ if (opts.readEnabled) {
308
+ sentences.push(
309
+ "To read an attachment directly into this conversation, call `read-attachment` — text comes back as a paginated `content` slice, images as a real image you can see, audio/video as a transcript pointer (never raw bytes), and PDF/other binary formats point you at a download ticket instead.",
303
310
  );
304
311
  }
305
312
  sentences.push(
306
- "If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer`. Audio attached with `transcribe: true` is transcribed automatically.",
313
+ "If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer` (honors a `Range: bytes=a-b` header for partial reads). Audio attached with `transcribe: true` is transcribed automatically.",
307
314
  );
308
315
  return sentences.join(" ");
309
316
  }
@@ -344,7 +351,7 @@ export function projectionToMarkdown(args: {
344
351
  * that hasn't wired ticket support yet (or a test fixture) never
345
352
  * accidentally advertises tools it can't back.
346
353
  */
347
- attachments?: { ticketsEnabled: boolean };
354
+ attachments?: { ticketsEnabled: boolean; readEnabled?: boolean };
348
355
  }): string {
349
356
  const { vaultName, description, projection, coordinates } = args;
350
357
  const stats = projection.stats;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.8",
3
+ "version": "0.7.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Attachment bytes — bun's filesystem implementation of the model-lane
3
+ * (Wave 2) `AttachmentBytesProvider` seam (`core/src/attachment/bytes-provider.ts`).
4
+ *
5
+ * Stateless by design (unlike the ticket provider, there's no in-memory
6
+ * state to share across requests) — a fresh instance per MCP session is
7
+ * cheap, so `createFsAttachmentBytesProvider` is a plain factory, not a
8
+ * shared singleton.
9
+ *
10
+ * `readRange` uses `Bun.file(path).slice(start, end)` — a bounded,
11
+ * positional read (Bun resolves the slice lazily via `pread` under the
12
+ * hood), never the whole-file `readFileSync` this design explicitly moves
13
+ * away from (see `handleStorage`'s REST `Range` support in `src/routes.ts`
14
+ * for the sibling fix on the byte-serve side).
15
+ */
16
+
17
+ import { existsSync, statSync } from "fs";
18
+ import { join, normalize } from "path";
19
+ import type { Attachment } from "../core/src/types.ts";
20
+ import type { AttachmentBytesProvider } from "../core/src/attachment/bytes-provider.ts";
21
+ import { assetsDir } from "./config.ts";
22
+ import { transcriptPathFor } from "./transcript-note.ts";
23
+ import { getVaultStore } from "./vault-store.ts";
24
+
25
+ /**
26
+ * Resolve + confine an attachment's on-disk path under this vault's assets
27
+ * dir. Same guard as the ticket download spend route (`src/attachment-tickets.ts`)
28
+ * and the REST byte-serve route (`src/routes.ts`): normalize, then require
29
+ * the result to still start with the (normalized) assets root — a stored
30
+ * `path` can never resolve outside it, but a defense-in-depth check costs
31
+ * nothing. Returns `null` on a traversal attempt (treated identically to
32
+ * "file doesn't exist" by every caller here).
33
+ */
34
+ function resolveConfinedPath(vaultName: string, attachment: Attachment): string | null {
35
+ const assets = assetsDir(vaultName);
36
+ const filePath = normalize(join(assets, attachment.path));
37
+ if (!filePath.startsWith(normalize(assets))) return null;
38
+ return filePath;
39
+ }
40
+
41
+ class FsAttachmentBytesProvider implements AttachmentBytesProvider {
42
+ constructor(private readonly vaultName: string) {}
43
+
44
+ async stat(attachment: Attachment): Promise<{ size: number } | null> {
45
+ const filePath = resolveConfinedPath(this.vaultName, attachment);
46
+ if (!filePath || !existsSync(filePath)) return null;
47
+ return { size: statSync(filePath).size };
48
+ }
49
+
50
+ async readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array> {
51
+ const filePath = resolveConfinedPath(this.vaultName, attachment);
52
+ if (!filePath) return new Uint8Array(0);
53
+ const slice = Bun.file(filePath).slice(start, end);
54
+ return new Uint8Array(await slice.arrayBuffer());
55
+ }
56
+
57
+ async resolveTranscriptNote(attachment: Attachment): Promise<{ id: string; path: string } | null> {
58
+ const store = getVaultStore(this.vaultName);
59
+ const note = await store.getNoteByPath(transcriptPathFor(attachment.path));
60
+ if (!note) return null;
61
+ return { id: note.id, path: note.path ?? transcriptPathFor(attachment.path) };
62
+ }
63
+ }
64
+
65
+ /** Build a fresh `AttachmentBytesProvider` scoped to one vault. Cheap — safe to call per-request. */
66
+ export function createFsAttachmentBytesProvider(vaultName: string): AttachmentBytesProvider {
67
+ return new FsAttachmentBytesProvider(vaultName);
68
+ }
@@ -29,7 +29,15 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
29
29
  const { getServerInstruction } = await import("./mcp-tools.ts");
30
30
  const { writeVaultConfig } = await import("./config.ts");
31
31
  const { getVaultStore } = await import("./vault-store.ts");
32
- const { getSharedAttachmentTicketProvider } = await import("./attachment-tickets.ts");
32
+ const {
33
+ getSharedAttachmentTicketProvider,
34
+ InProcessAttachmentTicketProvider,
35
+ sweepExpiredAttachmentTickets,
36
+ startAttachmentTicketSweep,
37
+ stopAttachmentTicketSweep,
38
+ } = await import("./attachment-tickets.ts");
39
+ const { generateTicketId } = await import("../core/src/attachment/tickets.ts");
40
+ import type { AttachmentTicket } from "../core/src/attachment/tickets.ts";
33
41
  const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
34
42
 
35
43
  /** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
@@ -347,4 +355,121 @@ describe("attachment tickets — discoverability", () => {
347
355
  expect(md).toContain("## Attachments");
348
356
  expect(md).toContain("request-attachment-upload");
349
357
  });
358
+
359
+ test("attachmentsInstructionBlock teaches read-attachment when readEnabled, and omits it when not", () => {
360
+ const enabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: true });
361
+ expect(enabled).toContain("read-attachment");
362
+
363
+ const disabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: false });
364
+ expect(disabled).not.toContain("read-attachment");
365
+ });
366
+
367
+ test("bun's connect-time getServerInstruction teaches read-attachment too", async () => {
368
+ const vaultName = freshVault("tickets-instructions-read");
369
+ const md = await getServerInstruction(vaultName);
370
+ expect(md).toContain("read-attachment");
371
+ });
372
+ });
373
+
374
+ describe("attachment ticket sweep (vault#612)", () => {
375
+ // Isolated instance — no risk of touching the one process-wide provider
376
+ // every OTHER test in this file (and every other test FILE, via
377
+ // getSharedAttachmentTicketProvider) also shares.
378
+ function ticketExpiringAt(id: string, expiresAt: number): AttachmentTicket {
379
+ return {
380
+ id,
381
+ kind: "download",
382
+ vaultName: "sweep-unit-test",
383
+ createdAt: expiresAt - 60_000,
384
+ expiresAt,
385
+ attachmentId: "att-1",
386
+ };
387
+ }
388
+
389
+ test("sweepExpired drops only expired-unspent tickets and returns the count dropped", async () => {
390
+ const provider = new InProcessAttachmentTicketProvider();
391
+ const now = Date.now();
392
+ await provider.put(ticketExpiringAt("expired-1", now - 5000));
393
+ await provider.put(ticketExpiringAt("expired-2", now - 1));
394
+ await provider.put(ticketExpiringAt("fresh-1", now + 60_000));
395
+ expect(provider.size()).toBe(3);
396
+
397
+ const dropped = provider.sweepExpired(now);
398
+ expect(dropped).toBe(2);
399
+ expect(provider.size()).toBe(1);
400
+
401
+ // Dropped tickets are gone — take() returns null, same as "never existed".
402
+ expect(await provider.take("expired-1")).toBeNull();
403
+ expect(await provider.take("expired-2")).toBeNull();
404
+ // The unexpired ticket survived the sweep and is still spendable.
405
+ const fresh = await provider.take("fresh-1");
406
+ expect(fresh?.id).toBe("fresh-1");
407
+ });
408
+
409
+ test("a ticket expiring exactly `now` counts as expired (< comparison, not <=)", async () => {
410
+ const provider = new InProcessAttachmentTicketProvider();
411
+ const now = Date.now();
412
+ await provider.put(ticketExpiringAt("boundary", now));
413
+ expect(provider.sweepExpired(now)).toBe(0); // expiresAt === now is NOT yet expired
414
+ expect(provider.sweepExpired(now + 1)).toBe(1); // one ms later, it is
415
+ });
416
+
417
+ test("a no-op sweep (nothing expired) drops nothing", () => {
418
+ const provider = new InProcessAttachmentTicketProvider();
419
+ expect(provider.sweepExpired(Date.now())).toBe(0);
420
+ expect(provider.size()).toBe(0);
421
+ });
422
+
423
+ test("sweepExpiredAttachmentTickets delegates to the shared provider (unique ids — safe alongside concurrent tests)", async () => {
424
+ const provider = getSharedAttachmentTicketProvider();
425
+ const now = Date.now();
426
+ const expiredId = generateTicketId();
427
+ const freshId = generateTicketId();
428
+ await provider.put(ticketExpiringAt(expiredId, now - 1));
429
+ await provider.put(ticketExpiringAt(freshId, now + 60_000));
430
+
431
+ sweepExpiredAttachmentTickets(now);
432
+
433
+ expect(await provider.take(expiredId)).toBeNull();
434
+ const fresh = await provider.take(freshId);
435
+ expect(fresh?.id).toBe(freshId);
436
+ });
437
+
438
+ test("sweepExpiredAttachmentTickets always returns a number (the `?? 0` fallback path never throws)", () => {
439
+ // By this point in the suite the shared provider already exists (other
440
+ // tests above created it) — this doesn't re-prove the true
441
+ // never-created case in isolation, but pins the return type/no-throw
442
+ // contract the periodic sweep timer depends on every tick.
443
+ expect(typeof sweepExpiredAttachmentTickets()).toBe("number");
444
+ });
445
+
446
+ test("start/stop the periodic sweep: idempotent start, a short-interval real timer actually drops an expired ticket, clean stop", async () => {
447
+ // Poll via `size()`, NOT `take(id)` — `take()` deletes unconditionally
448
+ // on any lookup (expired or not; see its own doc comment), so polling
449
+ // with it would consume the ticket itself on the FIRST poll — before
450
+ // the timer ever fires — and the test would pass for the wrong reason.
451
+ const provider = getSharedAttachmentTicketProvider() as InstanceType<typeof InProcessAttachmentTicketProvider>;
452
+ const now = Date.now();
453
+ const id = generateTicketId();
454
+ await provider.put(ticketExpiringAt(id, now - 1)); // already expired
455
+ const baseline = provider.size();
456
+
457
+ startAttachmentTicketSweep(15); // 15ms — short enough to observe within the test timeout
458
+ startAttachmentTicketSweep(15); // idempotent — no-op, doesn't create a second timer
459
+
460
+ // Poll briefly rather than a single fixed sleep — bounded by the test
461
+ // runner's own timeout.
462
+ let sizeDropped = false;
463
+ for (let i = 0; i < 20 && !sizeDropped; i++) {
464
+ await new Promise((r) => setTimeout(r, 15));
465
+ if (provider.size() < baseline) sizeDropped = true;
466
+ }
467
+ stopAttachmentTicketSweep();
468
+ stopAttachmentTicketSweep(); // idempotent — no-op on an already-stopped sweep
469
+
470
+ expect(sizeDropped).toBe(true);
471
+ // Confirm it was genuinely OUR ticket the sweep dropped, not a
472
+ // coincidental size change from something else.
473
+ expect(await provider.take(id)).toBeNull();
474
+ });
350
475
  });
@@ -37,7 +37,11 @@ function json(data: unknown, status = 200): Response {
37
37
  * `take` returns, collapsing "expired" into the same null as "spent" /
38
38
  * "unknown" so the HTTP layer can give a uniform 404 (no oracle).
39
39
  */
40
- class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
40
+ // Exported (alongside the shared-singleton accessors below) so
41
+ // vault#612's sweep logic can be unit-tested against an ISOLATED instance
42
+ // — no risk of a test's reset touching the one process-wide provider every
43
+ // OTHER test file's ticket mint/spend flow also shares.
44
+ export class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
41
45
  private readonly tickets = new Map<string, AttachmentTicket>();
42
46
 
43
47
  async put(ticket: AttachmentTicket): Promise<void> {
@@ -50,6 +54,30 @@ class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
50
54
  this.tickets.delete(id);
51
55
  return ticket;
52
56
  }
57
+
58
+ /**
59
+ * Drop every unspent ticket whose TTL has elapsed (vault#612). `take()`
60
+ * already enforces expiry AT SPEND TIME — a caller can never successfully
61
+ * spend a stale ticket — so this is purely a memory-hygiene backstop: an
62
+ * agent that mints and then abandons the flow (network drop, a curl that
63
+ * never runs) would otherwise leave its ticket in this Map forever. Returns
64
+ * the count dropped, for test assertions / logging.
65
+ */
66
+ sweepExpired(now: number = Date.now()): number {
67
+ let dropped = 0;
68
+ for (const [id, ticket] of this.tickets) {
69
+ if (ticket.expiresAt < now) {
70
+ this.tickets.delete(id);
71
+ dropped++;
72
+ }
73
+ }
74
+ return dropped;
75
+ }
76
+
77
+ /** Test-only visibility into how many tickets are currently held. */
78
+ size(): number {
79
+ return this.tickets.size;
80
+ }
53
81
  }
54
82
 
55
83
  let sharedProvider: InProcessAttachmentTicketProvider | undefined;
@@ -65,6 +93,54 @@ export function resetSharedAttachmentTicketProviderForTests(): void {
65
93
  sharedProvider = undefined;
66
94
  }
67
95
 
96
+ /** Test-only: how many tickets the shared provider currently holds (0 if never created). */
97
+ export function sharedAttachmentTicketCountForTests(): number {
98
+ return sharedProvider?.size() ?? 0;
99
+ }
100
+
101
+ /**
102
+ * Drop every expired-unspent ticket from the shared provider (vault#612). A
103
+ * no-op (returns 0) before the shared provider has ever been created — the
104
+ * periodic sweep below calls this unconditionally, so this must tolerate
105
+ * running before the first mint.
106
+ */
107
+ export function sweepExpiredAttachmentTickets(now: number = Date.now()): number {
108
+ return sharedProvider?.sweepExpired(now) ?? 0;
109
+ }
110
+
111
+ /**
112
+ * Sweep cadence (vault#612) — same cadence family as `EmbeddingWorker`'s
113
+ * default sweep interval (`src/embedding-worker.ts`'s `DEFAULT_SWEEP_MS`).
114
+ * Tickets are short-TTL (10-30 min, `computeTicketTtlMs`) and low-volume, so
115
+ * a 30s sweep is comfortably frequent without being wasteful.
116
+ */
117
+ const TICKET_SWEEP_INTERVAL_MS = 30_000;
118
+
119
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
120
+
121
+ /**
122
+ * Start the periodic expired-ticket sweep. No-op if already started. Mirrors
123
+ * `EmbeddingWorker.start()`'s shape (`.unref()` so the timer never keeps the
124
+ * process alive on its own — `server.ts`'s graceful-shutdown path still
125
+ * calls `stopAttachmentTicketSweep()` explicitly for a clean stop, same as
126
+ * `embeddingWorker.stop()`).
127
+ */
128
+ export function startAttachmentTicketSweep(intervalMs: number = TICKET_SWEEP_INTERVAL_MS): void {
129
+ if (sweepTimer) return;
130
+ sweepTimer = setInterval(() => {
131
+ sweepExpiredAttachmentTickets();
132
+ }, intervalMs);
133
+ sweepTimer.unref?.();
134
+ }
135
+
136
+ /** Stop the periodic expired-ticket sweep. */
137
+ export function stopAttachmentTicketSweep(): void {
138
+ if (sweepTimer) {
139
+ clearInterval(sweepTimer);
140
+ sweepTimer = null;
141
+ }
142
+ }
143
+
68
144
  /**
69
145
  * Take + validate a ticket against the URL's vault name and the spend
70
146
  * route's expected kind (a GET must not spend an upload ticket, etc.).
@@ -28,7 +28,7 @@ import { tmpdir } from "os";
28
28
  import { generateKeyPair, exportJWK, SignJWT } from "jose";
29
29
  import { writeVaultConfig, readVaultConfig } from "./config.ts";
30
30
  import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
31
- import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
31
+ import { authenticateVaultRequest, authenticateGlobalRequest, deriveVaultFromToken } from "./auth.ts";
32
32
  import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
33
33
 
34
34
  interface Keypair {
@@ -705,3 +705,120 @@ describe("pvt_* DROP (vault#282 Stage 2 — unvalidatable)", () => {
705
705
  expect("error" in result).toBe(false);
706
706
  });
707
707
  });
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // deriveVaultFromToken — the derivation precedence for the canonical root
711
+ // `/mcp` endpoint (U1). This function READS a validated token's claims to name
712
+ // the target vault; it never authorizes (the router re-dispatches through the
713
+ // full per-vault machinery). These cases isolate the three naming sources —
714
+ // narrowed scope / `aud=vault.<name>` / single-element `vault_scope` — and the
715
+ // fail-closed rules (agree → one name; disagree or none → `not_derivable`).
716
+ // The end-to-end routing.test.ts covers the wired behavior; this pins the
717
+ // precedence logic directly.
718
+ // ---------------------------------------------------------------------------
719
+ describe("deriveVaultFromToken — root /mcp vault derivation (U1)", () => {
720
+ test("all three sources agree → that vault", async () => {
721
+ const token = await signJwt(kp, {
722
+ iss: fixture.origin,
723
+ aud: "vault.journal",
724
+ scope: "vault:journal:write",
725
+ vaultScope: ["journal"],
726
+ });
727
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
728
+ });
729
+
730
+ test("narrowed scope alone names the vault (non-vault aud, no vault_scope)", async () => {
731
+ const token = await signJwt(kp, {
732
+ iss: fixture.origin,
733
+ aud: "urn:opaque-resource",
734
+ scope: "vault:journal:read",
735
+ });
736
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
737
+ });
738
+
739
+ test("aud=vault.<name> alone names the vault (broad scope names nothing)", async () => {
740
+ const token = await signJwt(kp, {
741
+ iss: fixture.origin,
742
+ aud: "vault.journal",
743
+ scope: "vault:read",
744
+ });
745
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
746
+ });
747
+
748
+ test("single-element vault_scope alone names the vault", async () => {
749
+ const token = await signJwt(kp, {
750
+ iss: fixture.origin,
751
+ aud: "urn:opaque-resource",
752
+ scope: "vault:read",
753
+ vaultScope: ["journal"],
754
+ });
755
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
756
+ });
757
+
758
+ test("multi-element vault_scope is NOT a single name → not_derivable (nothing else names)", async () => {
759
+ // Phase-2 multi-vault shape: a multi-element vault_scope doesn't name ONE
760
+ // vault, so at the single-vault root it can't be the sole source.
761
+ const token = await signJwt(kp, {
762
+ iss: fixture.origin,
763
+ aud: "urn:opaque-resource",
764
+ scope: "vault:read",
765
+ vaultScope: ["journal", "work"],
766
+ });
767
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
768
+ });
769
+
770
+ test("sources disagree (scope vs aud) → not_derivable, never a guess", async () => {
771
+ const token = await signJwt(kp, {
772
+ iss: fixture.origin,
773
+ aud: "vault.work",
774
+ scope: "vault:journal:write",
775
+ });
776
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
777
+ });
778
+
779
+ test("no source names a vault → not_derivable", async () => {
780
+ const token = await signJwt(kp, {
781
+ iss: fixture.origin,
782
+ aud: "urn:opaque-resource",
783
+ scope: "vault:read",
784
+ });
785
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
786
+ });
787
+
788
+ test("no bearer → no_bearer", async () => {
789
+ const req = new Request("https://vault.test/mcp");
790
+ expect(await deriveVaultFromToken(req)).toEqual({ error: "no_bearer" });
791
+ });
792
+
793
+ test("non-JWT bearer (operator / legacy shape) names no vault → not_derivable", async () => {
794
+ // The operator VAULT_AUTH_TOKEN and legacy YAML keys are vault-agnostic —
795
+ // they can't route the token-derived root endpoint (they keep working at
796
+ // the per-vault URL).
797
+ expect(await deriveVaultFromToken(bearer("opaque-operator-secret"))).toEqual({
798
+ error: "not_derivable",
799
+ });
800
+ });
801
+
802
+ test("expired JWT → not_derivable (validated with the full trust kernel)", async () => {
803
+ const token = await signJwt(kp, {
804
+ iss: fixture.origin,
805
+ aud: "vault.journal",
806
+ scope: "vault:journal:write",
807
+ ttlSeconds: -10, // already expired
808
+ });
809
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
810
+ });
811
+
812
+ test("revoked JWT → not_derivable (revocation runs in derivation too)", async () => {
813
+ const jti = "u1-derive-revoked";
814
+ const token = await signJwt(kp, {
815
+ iss: fixture.origin,
816
+ aud: "vault.journal",
817
+ scope: "vault:journal:write",
818
+ jti,
819
+ });
820
+ fixture.setRevoked([jti]);
821
+ resetRevocationCache();
822
+ expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
823
+ });
824
+ });
package/src/auth.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  hasScope,
31
31
  hasScopeForVault,
32
32
  legacyPermissionToScopes,
33
+ narrowedVaultNames,
33
34
  SCOPE_ADMIN,
34
35
  SCOPE_READ,
35
36
  SCOPE_WRITE,
@@ -712,3 +713,66 @@ export async function authenticateGlobalRequest(
712
713
  }
713
714
  return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
714
715
  }
716
+
717
+ /**
718
+ * Outcome of deriving a target vault from a request's bearer at the canonical
719
+ * root `/mcp` endpoint (U1). `vaultName` on success; a coarse failure `error`
720
+ * otherwise. Both failure reasons map to the SAME 401 + root-PRM challenge at
721
+ * the router — the distinction exists for logging/tests, never leaks to the
722
+ * client. `no_bearer` = no credential presented; `not_derivable` = a credential
723
+ * that names no single vault (non-JWT operator/legacy bearer, invalid /
724
+ * expired / revoked JWT, or a JWT whose scope / `aud` / `vault_scope` sources
725
+ * name zero or conflicting vaults).
726
+ */
727
+ export type VaultDerivation =
728
+ | { vaultName: string }
729
+ | { error: "no_bearer" | "not_derivable" };
730
+
731
+ /**
732
+ * Derive the target vault from a request's bearer token WITHOUT authorizing the
733
+ * request. The caller re-dispatches the derived name through the full per-vault
734
+ * auth machinery (`authenticateVaultRequest`), which re-validates the token
735
+ * WITH the audience pin — derive-then-redispatch, so a bad derivation FAILS the
736
+ * inner check rather than bypassing it (defense in depth). This function only
737
+ * reads the claims well enough to name the vault; it is never the authorization
738
+ * gate.
739
+ *
740
+ * Only hub-issued JWTs name a vault. The operator `VAULT_AUTH_TOKEN` and legacy
741
+ * YAML keys are vault-agnostic (they name no resource — see scopes.ts), so they
742
+ * return `not_derivable` here and keep working at the URL-addressed
743
+ * `/vault/<name>/*` surface. The JWT is validated with the SAME scope-guard
744
+ * trust kernel the per-vault path uses (signature, `iss` pin, `jti` +
745
+ * revocation, expiry) but WITHOUT `expectedAudience` — at the root we don't yet
746
+ * know which audience to expect; that pin is re-applied by the re-dispatch.
747
+ *
748
+ * From the validated claims, three independent sources can name a vault:
749
+ * 1. a narrowed `vault:<name>:<verb>` scope,
750
+ * 2. an `aud` of the form `vault.<name>`,
751
+ * 3. a single-element `vault_scope` claim.
752
+ * On a hub-minted token these AGREE. We collect every name any source provides
753
+ * and require EXACTLY ONE distinct name: zero (nothing named a vault) and
754
+ * two-or-more (the sources disagree) both fail closed with `not_derivable`. We
755
+ * never pick a winner from a precedence order — an ambiguous or unnamed token
756
+ * gets the discovery challenge, not a silent guess.
757
+ */
758
+ export async function deriveVaultFromToken(req: Request): Promise<VaultDerivation> {
759
+ const key = extractApiKey(req);
760
+ if (!key) return { error: "no_bearer" };
761
+ if (!looksLikeJwt(key)) return { error: "not_derivable" };
762
+ let claims;
763
+ try {
764
+ // No `expectedAudience`: the per-vault trust kernel minus the aud pin
765
+ // (which the re-dispatch re-applies). A bad signature / iss / expiry /
766
+ // revoked jti throws here → not_derivable → the standard 401 challenge.
767
+ claims = await validateHubJwt(key, {});
768
+ } catch {
769
+ return { error: "not_derivable" };
770
+ }
771
+ const named = new Set<string>();
772
+ for (const name of narrowedVaultNames(claims.scopes)) named.add(name);
773
+ const audMatch = claims.aud?.match(/^vault\.(.+)$/);
774
+ if (audMatch) named.add(audMatch[1]!);
775
+ if (claims.vaultScope.length === 1) named.add(claims.vaultScope[0]!);
776
+ if (named.size !== 1) return { error: "not_derivable" };
777
+ return { vaultName: [...named][0]! };
778
+ }