@openparachute/vault 0.7.3-rc.1 → 0.7.3-rc.11

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 (75) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/attachment/policy.test.ts +66 -0
  4. package/core/src/attachment/policy.ts +131 -0
  5. package/core/src/attachment/tickets.test.ts +45 -0
  6. package/core/src/attachment/tickets.ts +117 -0
  7. package/core/src/attachment-tickets-tool.test.ts +286 -0
  8. package/core/src/conformance.ts +2 -1
  9. package/core/src/content-range.test.ts +127 -0
  10. package/core/src/content-range.ts +100 -0
  11. package/core/src/contract-typed-index.test.ts +4 -3
  12. package/core/src/core.test.ts +521 -4
  13. package/core/src/display-title.test.ts +190 -0
  14. package/core/src/embedding/chunker.test.ts +97 -0
  15. package/core/src/embedding/chunker.ts +180 -0
  16. package/core/src/embedding/provider.ts +108 -0
  17. package/core/src/embedding/staleness.test.ts +87 -0
  18. package/core/src/embedding/staleness.ts +83 -0
  19. package/core/src/embedding/vector-codec.test.ts +99 -0
  20. package/core/src/embedding/vector-codec.ts +60 -0
  21. package/core/src/embedding/vectors.test.ts +163 -0
  22. package/core/src/embedding/vectors.ts +135 -0
  23. package/core/src/expand.ts +11 -3
  24. package/core/src/indexed-fields.test.ts +9 -3
  25. package/core/src/indexed-fields.ts +9 -1
  26. package/core/src/lede.test.ts +96 -0
  27. package/core/src/mcp-semantic-search.test.ts +160 -0
  28. package/core/src/mcp.ts +712 -11
  29. package/core/src/notes.semantic-search.test.ts +131 -0
  30. package/core/src/notes.ts +313 -6
  31. package/core/src/query-warnings.ts +20 -0
  32. package/core/src/schema-defaults.ts +85 -1
  33. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  34. package/core/src/schema.ts +81 -1
  35. package/core/src/search-fts-v25.test.ts +9 -1
  36. package/core/src/search-query.test.ts +42 -0
  37. package/core/src/search-query.ts +27 -0
  38. package/core/src/search-title-boost.test.ts +125 -0
  39. package/core/src/seed-packs.test.ts +117 -1
  40. package/core/src/seed-packs.ts +217 -1
  41. package/core/src/store.semantic-search.test.ts +236 -0
  42. package/core/src/store.ts +162 -5
  43. package/core/src/tag-schemas.ts +27 -12
  44. package/core/src/types.ts +55 -1
  45. package/core/src/vault-projection.ts +55 -0
  46. package/package.json +6 -1
  47. package/src/add-pack.test.ts +32 -0
  48. package/src/attachment-bytes.ts +68 -0
  49. package/src/attachment-tickets.test.ts +475 -0
  50. package/src/attachment-tickets.ts +340 -0
  51. package/src/cli.ts +5 -1
  52. package/src/contract-errors.test.ts +2 -1
  53. package/src/embedding/capability.test.ts +33 -0
  54. package/src/embedding/capability.ts +34 -0
  55. package/src/embedding/external-api.test.ts +154 -0
  56. package/src/embedding/external-api.ts +144 -0
  57. package/src/embedding/onnx-transformers.test.ts +113 -0
  58. package/src/embedding/onnx-transformers.ts +141 -0
  59. package/src/embedding/select.test.ts +99 -0
  60. package/src/embedding/select.ts +92 -0
  61. package/src/embedding-worker.test.ts +300 -0
  62. package/src/embedding-worker.ts +226 -0
  63. package/src/mcp-http.ts +33 -4
  64. package/src/mcp-tools.ts +61 -14
  65. package/src/onboarding-seed.test.ts +64 -0
  66. package/src/read-attachment.test.ts +436 -0
  67. package/src/routes.ts +226 -96
  68. package/src/routing.ts +17 -0
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +28 -2
  71. package/src/storage.test.ts +200 -1
  72. package/src/vault-embeddings-capability.test.ts +82 -0
  73. package/src/vault-store-embedding-wiring.test.ts +69 -0
  74. package/src/vault-store.ts +53 -2
  75. package/src/vault.test.ts +48 -11
package/README.md CHANGED
@@ -30,7 +30,7 @@ For remote access from Claude Desktop or mobile apps, see [Deployment](#deployme
30
30
 
31
31
  A server on port 1940 with:
32
32
 
33
- - **MCP** — 9 tools for AI agents (notes, tags, graph, vault info)
33
+ - **MCP** — tools for AI agents (notes, tags, graph, vault info, attachments)
34
34
  - **REST API** — Full CRUD for notes, tags, links, full-text search
35
35
  - **Wikilink auto-linking** — `[[wikilinks]]` in note content automatically create links in the graph
36
36
  - **Obsidian import/export** — Bidirectional interop with Obsidian vaults
@@ -252,12 +252,14 @@ parachute-vault backup --schedule daily # hourly | daily | weekly | manua
252
252
  parachute-vault backup status # schedule, last run, destinations, next run
253
253
  ```
254
254
 
255
- ## MCP tools (9)
255
+ ## MCP tools (13 core, + attachments + admin)
256
256
 
257
257
  **Notes**: `query-notes` (universal read — single by ID/path, filter, search, graph neighborhood), `create-note` (single or batch), `update-note` (single or batch — content, tags, links, metadata), `delete-note`
258
- **Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`
258
+ **Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`, `rename-tag`, `merge-tags`
259
259
  **Graph**: `find-path` (BFS between two notes)
260
260
  **Vault**: `vault-info` (get/update description + stats)
261
+ **Admin**: `prune-schema`, `doctor` (taxonomy/metadata integrity scan), `manage-token`
262
+ **Attachments** (2, present when the door wires an attachment-ticket provider — always true on this server): `request-attachment-upload`, `request-attachment-download` — mint a short-lived, single-use ticket a runtime's shell spends directly at `/vault/{name}/tickets/{id}`; bytes never pass through the tool call. See [`docs/HTTP_API.md`](./docs/HTTP_API.md#attachment-tickets-vault611--wave-1-bun-only).
261
263
 
262
264
  ### Vault descriptions
263
265
 
@@ -0,0 +1,65 @@
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
+ }
@@ -0,0 +1,66 @@
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
+ });
@@ -0,0 +1,131 @@
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
+ }
@@ -0,0 +1,45 @@
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
+ });
@@ -0,0 +1,117 @@
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
+ }