@openparachute/vault 0.7.3-rc.12 → 0.7.3-rc.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.
Files changed (48) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/cli.ts +1 -5
  25. package/src/contract-errors.test.ts +1 -2
  26. package/src/mcp-http.ts +4 -33
  27. package/src/mcp-tools.ts +14 -61
  28. package/src/onboarding-seed.test.ts +0 -64
  29. package/src/routes.ts +95 -92
  30. package/src/routing.ts +0 -17
  31. package/src/server.ts +0 -7
  32. package/src/storage.test.ts +1 -200
  33. package/src/transcription-worker.test.ts +0 -151
  34. package/src/transcription-worker.ts +52 -113
  35. package/src/vault.test.ts +11 -48
  36. package/core/src/attachment/bytes-provider.ts +0 -65
  37. package/core/src/attachment/policy.test.ts +0 -66
  38. package/core/src/attachment/policy.ts +0 -131
  39. package/core/src/attachment/tickets.test.ts +0 -45
  40. package/core/src/attachment/tickets.ts +0 -117
  41. package/core/src/attachment-tickets-tool.test.ts +0 -286
  42. package/core/src/display-title.test.ts +0 -190
  43. package/core/src/lede.test.ts +0 -96
  44. package/core/src/search-title-boost.test.ts +0 -125
  45. package/src/attachment-bytes.ts +0 -68
  46. package/src/attachment-tickets.test.ts +0 -475
  47. package/src/attachment-tickets.ts +0 -340
  48. package/src/read-attachment.test.ts +0 -436
package/src/vault.test.ts CHANGED
@@ -2139,22 +2139,6 @@ describe("HTTP /notes", async () => {
2139
2139
  expect(body[0]).not.toHaveProperty("content");
2140
2140
  expect(body[0]).toHaveProperty("byteSize");
2141
2141
  expect(body[0]).toHaveProperty("preview");
2142
- expect(body[0]).toHaveProperty("displayTitle");
2143
- });
2144
-
2145
- // Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
2146
- test("GET /notes lean shape carries the computed displayTitle", async () => {
2147
- await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
2148
- const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2149
- const body = await res.json() as any[];
2150
- expect(body[0].displayTitle).toBe("Meeting Notes");
2151
- });
2152
-
2153
- test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
2154
- await store.createNote("", { path: "empty" });
2155
- const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2156
- const body = await res.json() as any[];
2157
- expect(body[0].displayTitle).toBeNull();
2158
2142
  });
2159
2143
 
2160
2144
  test("GET /notes?include_content=true returns full notes", async () => {
@@ -6625,15 +6609,8 @@ describe("stateless MCP transport", async () => {
6625
6609
  expect(toolNames).not.toContain("merge-tags");
6626
6610
  // Admin tools (vault#376) are hidden too
6627
6611
  expect(toolNames).not.toContain("manage-token");
6628
- // request-attachment-download and read-attachment are read-tier
6629
- // (upload is write-tier).
6630
- expect(toolNames).toContain("request-attachment-download");
6631
- expect(toolNames).not.toContain("request-attachment-upload");
6632
- expect(toolNames).toContain("read-attachment");
6633
- // Read tier is exactly 7 tools (doctor added by the re-tier;
6634
- // request-attachment-download by the attachment-tickets design;
6635
- // read-attachment by Wave 2).
6636
- expect(toolNames.length).toBe(7);
6612
+ // Read tier is exactly 5 tools (doctor added by the re-tier).
6613
+ expect(toolNames.length).toBe(5);
6637
6614
 
6638
6615
  closeAllStores();
6639
6616
  });
@@ -6914,23 +6891,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6914
6891
  return names;
6915
6892
  }
6916
6893
 
6917
- test("vault:read sees exactly the 7 read tools (doctor moved admin → read; read-attachment and request-attachment-download are read-tier)", async () => {
6894
+ test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
6918
6895
  const names = await listToolNames(["vault:read"]);
6919
6896
  expect(new Set(names)).toEqual(
6920
- new Set([
6921
- "query-notes",
6922
- "list-tags",
6923
- "find-path",
6924
- "vault-info",
6925
- "doctor",
6926
- "request-attachment-download",
6927
- "read-attachment",
6928
- ]),
6897
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
6929
6898
  );
6930
- expect(names.length).toBe(7);
6899
+ expect(names.length).toBe(5);
6931
6900
  });
6932
6901
 
6933
- test("vault:read + vault:write sees the 11 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
6902
+ test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
6934
6903
  const names = await listToolNames(["vault:read", "vault:write"]);
6935
6904
  expect(new Set(names)).toEqual(
6936
6905
  new Set([
@@ -6942,12 +6911,9 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6942
6911
  "create-note",
6943
6912
  "update-note",
6944
6913
  "delete-note",
6945
- "request-attachment-upload",
6946
- "request-attachment-download",
6947
- "read-attachment",
6948
6914
  ]),
6949
6915
  );
6950
- expect(names.length).toBe(11);
6916
+ expect(names.length).toBe(8);
6951
6917
  expect(names).not.toContain("manage-token");
6952
6918
  // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6953
6919
  // admin-tier — structure/taxonomy curation, not content authorship.
@@ -6959,7 +6925,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6959
6925
  expect(names).toContain("delete-note");
6960
6926
  });
6961
6927
 
6962
- test("vault:admin sees all 17 tools including manage-token + prune-schema + the tag-schema tools + all three attachment tools", async () => {
6928
+ test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
6963
6929
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6964
6930
  expect(names).toContain("manage-token");
6965
6931
  expect(names).toContain("prune-schema");
@@ -6968,13 +6934,10 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6968
6934
  expect(names).toContain("delete-tag");
6969
6935
  expect(names).toContain("rename-tag");
6970
6936
  expect(names).toContain("merge-tags");
6971
- expect(names).toContain("request-attachment-upload");
6972
- expect(names).toContain("request-attachment-download");
6973
- expect(names).toContain("read-attachment");
6974
- expect(names.length).toBe(17);
6937
+ expect(names.length).toBe(14);
6975
6938
  });
6976
6939
 
6977
- test("legacy-derived full token sees all 17 tools (back-compat)", async () => {
6940
+ test("legacy-derived full token sees all 14 tools (back-compat)", async () => {
6978
6941
  const { handleScopedMcp } = await import("./mcp-http.ts");
6979
6942
  const { writeVaultConfig } = await import("./config.ts");
6980
6943
  const { closeAllStores } = await import("./vault-store.ts");
@@ -7007,7 +6970,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
7007
6970
  } as any);
7008
6971
  const body = await res.json() as any;
7009
6972
  const names: string[] = body.result.tools.map((t: any) => t.name);
7010
- expect(names.length).toBe(17);
6973
+ expect(names.length).toBe(14);
7011
6974
  expect(names).toContain("manage-token");
7012
6975
  expect(names).toContain("prune-schema");
7013
6976
  expect(names).toContain("doctor");
@@ -1,65 +0,0 @@
1
- /**
2
- * `AttachmentBytesProvider` — the model-lane (Wave 2) byte-access seam.
3
- * Mirrors `AttachmentTicketProvider` (`./tickets.ts`): a per-door
4
- * implementation binds this to its own storage (bun: local fs, see
5
- * `src/attachment-bytes.ts`; a future cloud implementation: ranged R2 GETs
6
- * through the vault DO). Core stays storage-unaware — the `read-attachment`
7
- * tool (`core/src/mcp.ts`) calls only through this interface, so bun and a
8
- * future cloud implementation can never drift on the read contract.
9
- *
10
- * Deliberately narrow: stat + a bounded positional read, plus one optional
11
- * hook for the audio transcript pointer. All POLICY (mime-family dispatch,
12
- * size caps, range validation, tag-scope) lives in the `read-attachment`
13
- * tool itself — same division as the ticket seam (`GenerateMcpToolsOpts`'s
14
- * doc comment in `core/src/mcp.ts`).
15
- *
16
- * D10 (attachments-for-agents design): "tools omitted when unwired" — a
17
- * door that hasn't wired this seam simply never passes `attachmentBytes` to
18
- * `generateMcpTools`, so `read-attachment` is absent from `tools/list`
19
- * entirely, not merely erroring on call.
20
- */
21
-
22
- import type { Attachment } from "../types.js";
23
-
24
- /**
25
- * 4 MiB raw-bytes cap on the image branch of `read-attachment` (D3): the
26
- * largest honest budget under Claude's ~5 MB image API limit, with room for
27
- * base64 blow-up (~5.6 MiB on the wire) and DO-transient headroom on a
28
- * future cloud implementation. Enforced BEFORE a read is attempted — the
29
- * tool calls `stat()` first and refuses over-cap without ever calling
30
- * `readRange()`.
31
- */
32
- export const MAX_ATTACHMENT_IMAGE_BYTES = 4 * 1024 * 1024;
33
-
34
- export interface AttachmentBytesProvider {
35
- /**
36
- * Byte size of the attachment's stored bytes, or `null` when the row
37
- * exists but its bytes don't — e.g. an `audio_retention` eviction after a
38
- * successful transcription (`src/transcription-worker.ts` unlinks the
39
- * file on `until_transcribed` / `never` retention), or any other
40
- * out-of-band loss. Drives the `attachment_binary_missing` refusal.
41
- */
42
- stat(attachment: Attachment): Promise<{ size: number } | null>;
43
- /**
44
- * Read the half-open byte range `[start, end)`. Bounded by construction —
45
- * callers never ask for the whole file when only a window is needed (the
46
- * text path); the image path DOES read start-to-end, but only after
47
- * `stat()` has already confirmed the file is under
48
- * {@link MAX_ATTACHMENT_IMAGE_BYTES}. `start`/`end` are always within
49
- * `[0, size]` as reported by a prior `stat()` call on the same attachment
50
- * — implementations don't need to re-clamp defensively, though doing so
51
- * costs nothing.
52
- */
53
- readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array>;
54
- /**
55
- * OPTIONAL: resolve the sibling transcript note for a completed
56
- * audio/video transcription (bun: `<attachment-path>.transcript`, see
57
- * `transcriptPathFor` in `src/transcript-note.ts`). Omitted by a door
58
- * whose transcript instead lives in the owning note's body (the design's
59
- * cloud path, D... — no separate sibling note to point at) — the
60
- * `read-attachment` audio branch falls back to `note_id` alone (the
61
- * owning note) in that case, per the design's "on cloud the transcript
62
- * lives in the owning note's body, so `note_id` is the pointer."
63
- */
64
- resolveTranscriptNote?(attachment: Attachment): Promise<{ id: string; path: string } | null>;
65
- }
@@ -1,66 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import {
3
- BLOCKED_ATTACHMENT_EXTENSIONS,
4
- ATTACHMENT_MIME_TYPES,
5
- sanitizeAttachmentExtension,
6
- mimeForAttachmentExtension,
7
- } from "./policy.ts";
8
-
9
- describe("sanitizeAttachmentExtension", () => {
10
- test("extracts a lowercased extension", () => {
11
- expect(sanitizeAttachmentExtension("Photo.PNG")).toBe(".png");
12
- });
13
-
14
- test("strips trailing dots/whitespace before extracting — evil.html. can't slip past the blocklist as an empty extension", () => {
15
- expect(sanitizeAttachmentExtension("evil.html.")).toBe(".html");
16
- expect(sanitizeAttachmentExtension("evil.svg ")).toBe(".svg");
17
- });
18
-
19
- test("a dotfile with no real extension reports none", () => {
20
- expect(sanitizeAttachmentExtension(".bashrc")).toBe("");
21
- });
22
-
23
- test("a filename with no dot reports none", () => {
24
- expect(sanitizeAttachmentExtension("README")).toBe("");
25
- });
26
- });
27
-
28
- describe("BLOCKED_ATTACHMENT_EXTENSIONS — active-content set", () => {
29
- test("blocks the same-origin-XSS extensions", () => {
30
- for (const ext of [".html", ".htm", ".xhtml", ".shtml", ".xht", ".svg", ".xml", ".js", ".mjs", ".cjs", ".css"]) {
31
- expect(BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)).toBe(true);
32
- }
33
- });
34
-
35
- test("does not block ordinary document/media extensions", () => {
36
- for (const ext of [".png", ".pdf", ".csv", ".epub", ".zip", ".mp3", ".docx"]) {
37
- expect(BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)).toBe(false);
38
- }
39
- });
40
- });
41
-
42
- describe("mimeForAttachmentExtension", () => {
43
- test("resolves a curated extension", () => {
44
- expect(mimeForAttachmentExtension(".png")).toBe("image/png");
45
- expect(mimeForAttachmentExtension(".pdf")).toBe("application/pdf");
46
- });
47
-
48
- test("falls back to application/octet-stream for an uncurated extension", () => {
49
- expect(mimeForAttachmentExtension(".azw3")).toBe("application/octet-stream");
50
- });
51
-
52
- test("no MIME entry maps to a browser-active type (INVARIANT)", () => {
53
- const activeTypes = new Set([
54
- "text/html",
55
- "image/svg+xml",
56
- "application/xhtml+xml",
57
- "text/javascript",
58
- "application/wasm",
59
- "text/css",
60
- ]);
61
- for (const mime of Object.values(ATTACHMENT_MIME_TYPES)) {
62
- const bare = mime.split(";")[0]!.trim();
63
- expect(activeTypes.has(bare)).toBe(false);
64
- }
65
- });
66
- });
@@ -1,131 +0,0 @@
1
- /**
2
- * Shared attachment upload policy — extension blocklist + MIME lookup.
3
- *
4
- * Canonical source for every upload path into this vault's attachment
5
- * storage: the REST `POST /storage/upload` handler (`src/routes.ts`, which
6
- * imports these constants rather than declaring its own) and the
7
- * attachment-ticket mint/spend path (`core/src/mcp.ts`'s
8
- * `request-attachment-upload` tool + `src/attachment-tickets.ts`'s spend
9
- * route). One list, so a blocked extension can never diverge between the
10
- * two upload doors into this vault (vault attachment-tickets design,
11
- * "shared BLOCKED_EXTENSIONS").
12
- *
13
- * Deliberately dependency-free (no `node:path`) — this module is imported
14
- * by `core/src/mcp.ts`, which a Cloudflare Worker (cloud's runtime) bundles
15
- * directly; a hand-rolled `extname` keeps that bundle Node-compat-free.
16
- */
17
-
18
- // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
19
- // arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
20
- // accept ANY upload EXCEPT the handful of types a browser can execute as
21
- // active content in our origin when served back from /storage/. (The prior
22
- // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
23
- // not allowed".)
24
- //
25
- // BLOCKED — same-origin-XSS / active-content set:
26
- // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
27
- // .svg XML image — embeds <script>
28
- // .xml can carry XSLT / be parsed as XHTML
29
- // .js/.mjs/.cjs JavaScript
30
- // .css style-injection / UI-redress vector
31
- //
32
- // Two independent guards keep every STORED file inert when served:
33
- // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
34
- // every other extension serves as application/octet-stream — a download,
35
- // never rendered.
36
- // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
37
- // a browser can't sniff an octet-stream body into an executable type.
38
- // The blocklist is belt-and-suspenders on top of those: even if a future MIME
39
- // entry or an upstream proxy weakened (1) or (2), these extensions still never
40
- // land on disk. If a future use case needs SVG, sanitize on read (strip
41
- // <script>/<foreignObject>) and revisit.
42
- export const BLOCKED_ATTACHMENT_EXTENSIONS = new Set([
43
- ".html", ".htm", ".xhtml", ".shtml", ".xht",
44
- ".svg",
45
- ".xml",
46
- ".js", ".mjs", ".cjs",
47
- ".css",
48
- ]);
49
-
50
- // Explicit MIME types for the commonly-previewed formats. Anything accepted
51
- // but absent here serves as application/octet-stream — a download, never
52
- // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
53
- // these map to an active type (text/html, image/svg+xml), so a served asset
54
- // can't execute script; `nosniff` on the GET response makes that ironclad.
55
- //
56
- // INVARIANT: never add an entry that maps to a browser-active type —
57
- // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
58
- // application/wasm, text/css. Doing so re-enables same-origin execution for
59
- // that extension (and would mean it must also join BLOCKED_ATTACHMENT_EXTENSIONS).
60
- export const ATTACHMENT_MIME_TYPES: Record<string, string> = {
61
- // Audio
62
- ".wav": "audio/wav",
63
- ".mp3": "audio/mpeg",
64
- ".m4a": "audio/mp4",
65
- ".ogg": "audio/ogg",
66
- ".oga": "audio/ogg",
67
- ".opus": "audio/opus",
68
- ".aac": "audio/aac",
69
- ".flac": "audio/flac",
70
- ".webm": "audio/webm",
71
- // Image
72
- ".png": "image/png",
73
- ".jpg": "image/jpeg",
74
- ".jpeg": "image/jpeg",
75
- ".gif": "image/gif",
76
- ".webp": "image/webp",
77
- ".bmp": "image/bmp",
78
- ".tiff": "image/tiff",
79
- ".tif": "image/tiff",
80
- ".heic": "image/heic",
81
- ".heif": "image/heif",
82
- ".avif": "image/avif",
83
- // Video
84
- ".mp4": "video/mp4",
85
- ".m4v": "video/x-m4v",
86
- ".mov": "video/quicktime",
87
- // Documents / ebooks / data
88
- ".pdf": "application/pdf",
89
- ".epub": "application/epub+zip",
90
- ".mobi": "application/x-mobipocket-ebook",
91
- ".txt": "text/plain; charset=utf-8",
92
- ".md": "text/markdown; charset=utf-8",
93
- ".markdown": "text/markdown; charset=utf-8",
94
- ".rtf": "application/rtf",
95
- ".csv": "text/csv; charset=utf-8",
96
- ".tsv": "text/tab-separated-values; charset=utf-8",
97
- ".json": "application/json; charset=utf-8",
98
- ".doc": "application/msword",
99
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
100
- ".ppt": "application/vnd.ms-powerpoint",
101
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
102
- ".xls": "application/vnd.ms-excel",
103
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
104
- ".odt": "application/vnd.oasis.opendocument.text",
105
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
106
- ".odp": "application/vnd.oasis.opendocument.presentation",
107
- ".zip": "application/zip",
108
- };
109
-
110
- /** Node-`path`-free `extname`: lowercased, leading-dot dotfiles ("`.bashrc`") report no extension — same edge case Node's `path.extname` treats as extensionless. */
111
- function extnameOf(filename: string): string {
112
- const idx = filename.lastIndexOf(".");
113
- if (idx <= 0) return "";
114
- return filename.slice(idx).toLowerCase();
115
- }
116
-
117
- /**
118
- * Extract the sanitized, lowercased extension from a caller-supplied
119
- * filename. Strips trailing dots/whitespace FIRST so `evil.html.` /
120
- * `evil.svg ` can't slip past `BLOCKED_ATTACHMENT_EXTENSIONS`
121
- * (`extname("evil.html.")` would otherwise be `"."`, not `.html`) — mirrors
122
- * `src/routes.ts`'s upload-handler sanitization exactly.
123
- */
124
- export function sanitizeAttachmentExtension(filename: string): string {
125
- return extnameOf(filename.replace(/[.\s]+$/, ""));
126
- }
127
-
128
- /** MIME type for a (already-sanitized) extension, `application/octet-stream` when uncurated — never an active/executable type (see the INVARIANT above). */
129
- export function mimeForAttachmentExtension(ext: string): string {
130
- return ATTACHMENT_MIME_TYPES[ext] ?? "application/octet-stream";
131
- }
@@ -1,45 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import { computeTicketTtlMs, generateTicketId, MAX_TICKET_UPLOAD_BYTES } from "./tickets.ts";
3
-
4
- describe("computeTicketTtlMs — Aaron-ratified TTL scaling (10 min base + 10s/MiB, 30 min cap)", () => {
5
- test("no declared size → base TTL (10 minutes)", () => {
6
- expect(computeTicketTtlMs()).toBe(10 * 60 * 1000);
7
- });
8
-
9
- test("zero or negative size → base TTL (fails safe, not zero-TTL)", () => {
10
- expect(computeTicketTtlMs(0)).toBe(10 * 60 * 1000);
11
- expect(computeTicketTtlMs(-5)).toBe(10 * 60 * 1000);
12
- });
13
-
14
- test("1 MiB declared → base + 10s", () => {
15
- expect(computeTicketTtlMs(1024 * 1024)).toBe(10 * 60 * 1000 + 10 * 1000);
16
- });
17
-
18
- test("10 MiB declared → base + 100s", () => {
19
- expect(computeTicketTtlMs(10 * 1024 * 1024)).toBe(10 * 60 * 1000 + 100 * 1000);
20
- });
21
-
22
- test("100 MiB declared (under the cap's crossover at 120 MiB) is base + 1000s, not yet capped", () => {
23
- expect(computeTicketTtlMs(100 * 1024 * 1024)).toBe(10 * 60 * 1000 + 1000 * 1000);
24
- });
25
-
26
- test("a declared size past the crossover point (200 MiB) is capped at 30 minutes flat", () => {
27
- expect(computeTicketTtlMs(200 * 1024 * 1024)).toBe(30 * 60 * 1000);
28
- });
29
- });
30
-
31
- describe("generateTicketId", () => {
32
- test("is a 64-char hex string (256 bits)", () => {
33
- const id = generateTicketId();
34
- expect(id).toMatch(/^[0-9a-f]{64}$/);
35
- });
36
-
37
- test("is unique across many calls (no Math.random-grade collisions)", () => {
38
- const ids = new Set(Array.from({ length: 1000 }, () => generateTicketId()));
39
- expect(ids.size).toBe(1000);
40
- });
41
- });
42
-
43
- test("MAX_TICKET_UPLOAD_BYTES mirrors REST's 100 MiB upload ceiling", () => {
44
- expect(MAX_TICKET_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
45
- });
@@ -1,117 +0,0 @@
1
- /**
2
- * Attachment tickets — the runtime lane (Wave 1) of the two-lane
3
- * attachments-for-agents design. Bytes never touch the MCP session:
4
- * `request-attachment-upload` / `request-attachment-download`
5
- * (`core/src/mcp.ts`) mint a short-lived, single-use, capability-in-URL
6
- * ticket; a runtime with a shell (never the model) spends it directly
7
- * against a bare HTTP endpoint outside the authed API tree.
8
- *
9
- * This module owns the wire-shape-defining pieces so both doors can't
10
- * drift: the ticket record shape, TTL computation, and id generation. The
11
- * `AttachmentTicketProvider` seam is deliberately dumb — a single-use KV,
12
- * no policy — because ALL policy (note/attachment resolution, tag-scope,
13
- * size cap, extension sanitize) runs at mint time in the MCP tools
14
- * themselves, pinned here so a future cloud implementation can't drift on
15
- * the wire shape or the error taxonomy.
16
- */
17
-
18
- export type AttachmentTicketKind = "upload" | "download";
19
-
20
- /**
21
- * A minted, not-yet-spent ticket. `sizeBytes` is the DECLARED size at mint
22
- * — upload: caller-asserted (`request-attachment-upload`'s `size_bytes`,
23
- * enforced as a hard cap at spend, see `MAX_TICKET_UPLOAD_BYTES`);
24
- * download: best-effort from `attachments.metadata.size` when the row
25
- * carries one (advisory only — never enforced, since a download ticket
26
- * never writes bytes).
27
- */
28
- export interface AttachmentTicket {
29
- id: string;
30
- kind: AttachmentTicketKind;
31
- vaultName: string;
32
- createdAt: number;
33
- expiresAt: number;
34
- mimeType?: string;
35
- sizeBytes?: number;
36
- /** Upload only. */
37
- noteId?: string;
38
- filename?: string;
39
- transcribe?: boolean;
40
- /** Download only. */
41
- attachmentId?: string;
42
- }
43
-
44
- /**
45
- * Storage seam for ticket state (D10 — "tools omitted when unwired"). A
46
- * door that hasn't implemented this yet passes no `attachmentTickets` opt
47
- * into `generateMcpTools` — `request-attachment-upload` /
48
- * `request-attachment-download` are then ABSENT from the tool list
49
- * entirely (not merely erroring on call), so an agent is never shown an
50
- * affordance the runtime can't back.
51
- *
52
- * "State lives where the bytes live" (design decision): bun implements
53
- * this as an in-process `Map` (`src/attachment-tickets.ts`) — a daemon
54
- * restart drops every outstanding ticket, which is acceptable at a
55
- * ≤30-minute TTL and is documented at that call site. A future cloud
56
- * implementation persists ticket rows in the vault's Durable Object
57
- * (`ctx.storage`, atomic delete-on-spend under `transactionSync`).
58
- */
59
- export interface AttachmentTicketProvider {
60
- /** Persist a freshly-minted ticket. */
61
- put(ticket: AttachmentTicket): Promise<void>;
62
- /**
63
- * Atomic check-and-consume. Returns the ticket exactly once — a second
64
- * call for the same id (already spent), an id past its `expiresAt`, or
65
- * an id that never existed all return `null`. Collapsing all three into
66
- * one shape is deliberate: the spend route gives an unguessable-URL
67
- * adversary no oracle distinguishing "wrong id" from "right id, too
68
- * late" — the caller still separately checks `expiresAt` against
69
- * wall-clock (a `take` implementation MAY also drop expired entries
70
- * opportunistically, but callers must not rely on that for correctness).
71
- */
72
- take(id: string): Promise<AttachmentTicket | null>;
73
- }
74
-
75
- /**
76
- * Absolute REST upload ceiling (mirrors `MAX_UPLOAD_BYTES`,
77
- * `src/routes.ts`) — a ticket can never promise more than REST itself
78
- * accepts for the same bytes. Enforced at MINT (a `size_bytes` over this
79
- * is rejected before a ticket is even created) — the ticket's own
80
- * `sizeBytes` (always `<=` this) is the tighter, per-ticket cap the spend
81
- * route actually enforces.
82
- */
83
- export const MAX_TICKET_UPLOAD_BYTES = 100 * 1024 * 1024;
84
-
85
- const TICKET_TTL_BASE_MS = 10 * 60 * 1000; // 10 minutes
86
- const TICKET_TTL_PER_MIB_MS = 10 * 1000; // +10s per declared MiB
87
- const TICKET_TTL_MAX_MS = 30 * 60 * 1000; // hard cap, 30 minutes
88
-
89
- /**
90
- * TTL for a minted ticket, in ms. 10 minutes base + 10 seconds per
91
- * declared MiB (a slow tether on a large upload gets proportionally more
92
- * room), hard-capped at 30 minutes regardless of size (Aaron-ratified
93
- * amendment over the spec's original flat 120s). A size-less mint
94
- * (download ticket for a row with no known size) gets the base TTL.
95
- * Single-use is enforced separately and unconditionally by
96
- * `AttachmentTicketProvider.take` — TTL only bounds how long an UNSPENT
97
- * ticket stays valid, never how many times a spent one can be reused.
98
- */
99
- export function computeTicketTtlMs(sizeBytes?: number): number {
100
- if (!sizeBytes || sizeBytes <= 0) return TICKET_TTL_BASE_MS;
101
- const mib = sizeBytes / (1024 * 1024);
102
- return Math.min(TICKET_TTL_BASE_MS + mib * TICKET_TTL_PER_MIB_MS, TICKET_TTL_MAX_MS);
103
- }
104
-
105
- /**
106
- * 256-bit random ticket id, hex-encoded (64 chars). Capability-in-URL — the
107
- * ticket's entropy is the ONLY thing standing between "knows the URL" and
108
- * "can spend it" (no auth beyond the ticket itself), so this is Web Crypto
109
- * `getRandomValues`, never `Math.random()`. Global `crypto` — available in
110
- * both Bun and the Cloudflare Workers runtime, so this module stays
111
- * runtime-agnostic.
112
- */
113
- export function generateTicketId(): string {
114
- const bytes = new Uint8Array(32);
115
- crypto.getRandomValues(bytes);
116
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
117
- }