@openparachute/vault 0.7.3-rc.6 → 0.7.3-rc.8

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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Attachment tickets — end-to-end lifecycle (mint via the real MCP tool
3
+ * call path, spend via the real unauthenticated `/vault/<name>/tickets/<id>`
4
+ * route dispatched through `routing.ts`'s `route()`). Covers the Wave 1
5
+ * security posture: single-use, TTL/expiry, size-cap, wrong-vault scoping,
6
+ * uniform 404, upload→row+transcribe, download streaming + path
7
+ * confinement, and the connect-time instructions block.
8
+ */
9
+
10
+ import { describe, test, expect } from "bun:test";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+
14
+ const testDir = join(
15
+ tmpdir(),
16
+ `vault-attachment-tickets-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
17
+ );
18
+ process.env.PARACHUTE_HOME = testDir;
19
+ // Deliberately NOT setting ASSETS_DIR (unlike storage.test.ts, which wants a
20
+ // single shared assets root for its own reasons): that env var is global to
21
+ // the whole `bun test` process (all files share one Node process), so
22
+ // setting it here would leak into every OTHER test file's assetsDir() calls
23
+ // too. Each vault below gets a unique, unset-ASSETS_DIR-default assets dir
24
+ // (`vaultDir(name)/assets`) scoped under this file's own unique
25
+ // PARACHUTE_HOME — fully isolated without needing the override.
26
+
27
+ const { route } = await import("./routing.ts");
28
+ const { handleScopedMcp } = await import("./mcp-http.ts");
29
+ const { getServerInstruction } = await import("./mcp-tools.ts");
30
+ const { writeVaultConfig } = await import("./config.ts");
31
+ const { getVaultStore } = await import("./vault-store.ts");
32
+ const { getSharedAttachmentTicketProvider } = await import("./attachment-tickets.ts");
33
+ const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
34
+
35
+ /** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
36
+ async function routeReq(req: Request): Promise<Response> {
37
+ return route(req, new URL(req.url).pathname);
38
+ }
39
+
40
+ function freshVault(prefix: string): string {
41
+ const name = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
43
+ return name;
44
+ }
45
+
46
+ /** Bypasses `authenticateVaultRequest` the same way vault.test.ts's scope-tier tests do — a fabricated already-resolved auth object. */
47
+ function mcpAuth(scopes: string[]) {
48
+ return {
49
+ permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
50
+ scopes,
51
+ legacyDerived: false,
52
+ scoped_tags: null,
53
+ } as any;
54
+ }
55
+
56
+ async function callTool(vaultName: string, name: string, args: Record<string, unknown>): Promise<any> {
57
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
60
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
61
+ });
62
+ const res = await handleScopedMcp(req, vaultName, mcpAuth(["vault:read", "vault:write"]));
63
+ const body = (await res.json()) as any;
64
+ if (body.error) {
65
+ const err = new Error(body.error.message);
66
+ Object.assign(err, body.error.data ?? {});
67
+ throw err;
68
+ }
69
+ return JSON.parse(body.result.content[0].text);
70
+ }
71
+
72
+ describe("attachment tickets — upload lifecycle", () => {
73
+ test("mint → spend → row registered with size + original_name; second spend of the same ticket 404s", async () => {
74
+ const vaultName = freshVault("tickets-upload");
75
+ const store = getVaultStore(vaultName);
76
+ const note = await store.createNote("# Target\n", { path: "target" });
77
+
78
+ const mint = await callTool(vaultName, "request-attachment-upload", {
79
+ note: note.id,
80
+ filename: "photo.png",
81
+ size_bytes: 5,
82
+ mime_type: "image/png",
83
+ });
84
+ expect(mint.method).toBe("PUT");
85
+ expect(mint.url).toContain(`/vault/${vaultName}/tickets/`);
86
+ expect(mint.max_bytes).toBe(5);
87
+
88
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
89
+ const spendRes = await routeReq(
90
+ new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: bytes }),
91
+ );
92
+ expect(spendRes.status).toBe(201);
93
+ const attachment = (await spendRes.json()) as any;
94
+ expect(attachment.noteId).toBe(note.id);
95
+ expect(attachment.mimeType).toBe("image/png");
96
+ expect(attachment.metadata.original_name).toBe("photo.png");
97
+ expect(attachment.metadata.size).toBe(5);
98
+
99
+ const rows = await store.getAttachments(note.id);
100
+ expect(rows.length).toBe(1);
101
+ expect(rows[0]!.id).toBe(attachment.id);
102
+
103
+ // Single-use: the ticket is gone after one spend — uniform 404, no
104
+ // oracle distinguishing "spent" from "never existed".
105
+ const secondRes = await routeReq(
106
+ new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: new Uint8Array([9]) }),
107
+ );
108
+ expect(secondRes.status).toBe(404);
109
+ const secondBody = (await secondRes.json()) as any;
110
+ expect(secondBody.error_type).toBe("not_found");
111
+ expect(typeof secondBody.how_to).toBe("string");
112
+
113
+ // A second upload wasn't registered.
114
+ expect((await store.getAttachments(note.id)).length).toBe(1);
115
+ });
116
+
117
+ test("POST also spends an upload ticket (accepted alongside the advertised PUT)", async () => {
118
+ const vaultName = freshVault("tickets-upload-post");
119
+ const store = getVaultStore(vaultName);
120
+ const note = await store.createNote("# T\n", { path: "t" });
121
+ const mint = await callTool(vaultName, "request-attachment-upload", {
122
+ note: note.id,
123
+ filename: "a.txt",
124
+ size_bytes: 3,
125
+ });
126
+ const res = await routeReq(
127
+ new Request(mint.url, { method: "POST", headers: { "content-type": "text/plain" }, body: new Uint8Array([1, 2, 3]) }),
128
+ );
129
+ expect(res.status).toBe(201);
130
+ });
131
+
132
+ test("size-cap: an upload exceeding the ticket's declared size_bytes is rejected 413 and no row is created", async () => {
133
+ const vaultName = freshVault("tickets-size-cap");
134
+ const store = getVaultStore(vaultName);
135
+ const note = await store.createNote("# T\n", { path: "t" });
136
+ const mint = await callTool(vaultName, "request-attachment-upload", {
137
+ note: note.id,
138
+ filename: "small.bin",
139
+ size_bytes: 3,
140
+ });
141
+ const res = await routeReq(
142
+ new Request(mint.url, {
143
+ method: "PUT",
144
+ headers: { "content-type": "application/octet-stream" },
145
+ body: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
146
+ }),
147
+ );
148
+ expect(res.status).toBe(413);
149
+ const body = (await res.json()) as any;
150
+ expect(body.error_type).toBe("file_too_large");
151
+ expect(body.limit).toBe(3);
152
+ expect(typeof body.how_to).toBe("string");
153
+ expect((await store.getAttachments(note.id)).length).toBe(0);
154
+ });
155
+
156
+ test("a mismatched Content-Length header alone is rejected 413 before the body is even read", async () => {
157
+ const vaultName = freshVault("tickets-cl-cap");
158
+ const store = getVaultStore(vaultName);
159
+ const note = await store.createNote("# T\n", { path: "t" });
160
+ const mint = await callTool(vaultName, "request-attachment-upload", {
161
+ note: note.id,
162
+ filename: "small.bin",
163
+ size_bytes: 3,
164
+ });
165
+ const res = await routeReq(
166
+ new Request(mint.url, {
167
+ method: "PUT",
168
+ headers: { "content-type": "application/octet-stream", "content-length": "1000000" },
169
+ body: new Uint8Array([1, 2, 3]),
170
+ }),
171
+ );
172
+ expect(res.status).toBe(413);
173
+ });
174
+
175
+ test("expiry: a ticket past its expires_at is rejected 404 even though it was never spent", async () => {
176
+ const vaultName = freshVault("tickets-expiry");
177
+ const store = getVaultStore(vaultName);
178
+ const note = await store.createNote("# T\n", { path: "t" });
179
+
180
+ const provider = getSharedAttachmentTicketProvider();
181
+ const id = "e".repeat(64);
182
+ await provider.put({
183
+ id,
184
+ kind: "upload",
185
+ vaultName,
186
+ createdAt: Date.now() - 1000,
187
+ expiresAt: Date.now() - 1, // already expired
188
+ noteId: note.id,
189
+ filename: "late.bin",
190
+ mimeType: "application/octet-stream",
191
+ sizeBytes: 10,
192
+ });
193
+
194
+ const res = await routeReq(
195
+ new Request(`http://localhost:1940/vault/${vaultName}/tickets/${id}`, {
196
+ method: "PUT",
197
+ headers: { "content-type": "application/octet-stream" },
198
+ body: new Uint8Array([1]),
199
+ }),
200
+ );
201
+ expect(res.status).toBe(404);
202
+ expect((await store.getAttachments(note.id)).length).toBe(0);
203
+ });
204
+
205
+ test("wrong-vault scope: a ticket minted for vault A can't be spent against vault B's URL", async () => {
206
+ const vaultA = freshVault("tickets-vault-a");
207
+ const vaultB = freshVault("tickets-vault-b");
208
+ const storeA = getVaultStore(vaultA);
209
+ const note = await storeA.createNote("# T\n", { path: "t" });
210
+
211
+ const mint = await callTool(vaultA, "request-attachment-upload", {
212
+ note: note.id,
213
+ filename: "a.bin",
214
+ size_bytes: 3,
215
+ });
216
+ const ticketId = mint.url.split("/tickets/")[1] as string;
217
+
218
+ const crossVaultRes = await routeReq(
219
+ new Request(`http://localhost:1940/vault/${vaultB}/tickets/${ticketId}`, {
220
+ method: "PUT",
221
+ headers: { "content-type": "application/octet-stream" },
222
+ body: new Uint8Array([1, 2, 3]),
223
+ }),
224
+ );
225
+ expect(crossVaultRes.status).toBe(404);
226
+
227
+ // `take()` deletes on lookup regardless of which vault asked — a
228
+ // wrong-vault spend attempt still burns the ticket (single-use is
229
+ // enforced by the delete, vault-match is a SEPARATE check layered on
230
+ // top). Documenting this: the ticket is gone even for the correct
231
+ // vault after the cross-vault probe above.
232
+ const correctRes = await routeReq(
233
+ new Request(mint.url, {
234
+ method: "PUT",
235
+ headers: { "content-type": "application/octet-stream" },
236
+ body: new Uint8Array([1, 2, 3]),
237
+ }),
238
+ );
239
+ expect(correctRes.status).toBe(404);
240
+ });
241
+
242
+ test("transcribe: true rides through to the attachment row and stamps the note's transcribe_stub", async () => {
243
+ const vaultName = freshVault("tickets-transcribe");
244
+ const store = getVaultStore(vaultName);
245
+ const note = await store.createNote("# Voice memo\n", { path: "memo" });
246
+
247
+ const mint = await callTool(vaultName, "request-attachment-upload", {
248
+ note: note.id,
249
+ filename: "memo.wav",
250
+ size_bytes: 4,
251
+ transcribe: true,
252
+ });
253
+ const res = await routeReq(
254
+ new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
255
+ );
256
+ expect(res.status).toBe(201);
257
+ const attachment = (await res.json()) as any;
258
+ expect(attachment.metadata.transcribe_status).toBe("pending");
259
+ expect(attachment.metadata.transcribe_origin).toBe("legacy");
260
+
261
+ const updatedNote = await store.getNote(note.id);
262
+ expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
263
+ });
264
+
265
+ test("blocked extension is refused at MINT — no ticket is ever created", async () => {
266
+ const vaultName = freshVault("tickets-blocked-ext");
267
+ const store = getVaultStore(vaultName);
268
+ const note = await store.createNote("# T\n", { path: "t" });
269
+ await expect(
270
+ callTool(vaultName, "request-attachment-upload", { note: note.id, filename: "evil.svg", size_bytes: 10 }),
271
+ ).rejects.toThrow();
272
+ });
273
+ });
274
+
275
+ describe("attachment tickets — download lifecycle", () => {
276
+ test("mint → spend streams the exact bytes with the attachment's content-type", async () => {
277
+ const vaultName = freshVault("tickets-download");
278
+ const store = getVaultStore(vaultName);
279
+ const note = await store.createNote("# T\n", { path: "t" });
280
+
281
+ const uploadMint = await callTool(vaultName, "request-attachment-upload", {
282
+ note: note.id,
283
+ filename: "data.bin",
284
+ size_bytes: 4,
285
+ mime_type: "application/octet-stream",
286
+ });
287
+ const uploadRes = await routeReq(
288
+ new Request(uploadMint.url, {
289
+ method: "PUT",
290
+ headers: { "content-type": "application/octet-stream" },
291
+ body: new Uint8Array([10, 20, 30, 40]),
292
+ }),
293
+ );
294
+ const attachment = (await uploadRes.json()) as any;
295
+
296
+ const downloadMint = await callTool(vaultName, "request-attachment-download", {
297
+ attachment_id: attachment.id,
298
+ });
299
+ expect(downloadMint.method).toBe("GET");
300
+ expect(downloadMint.mime_type).toBe("application/octet-stream");
301
+
302
+ const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
303
+ expect(downloadRes.status).toBe(200);
304
+ expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
305
+ expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
306
+ const body = new Uint8Array(await downloadRes.arrayBuffer());
307
+ expect(Array.from(body)).toEqual([10, 20, 30, 40]);
308
+
309
+ // Single-use on the download side too.
310
+ const secondRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
311
+ expect(secondRes.status).toBe(404);
312
+ });
313
+
314
+ test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
315
+ const vaultName = freshVault("tickets-confinement");
316
+ const store = getVaultStore(vaultName);
317
+ const note = await store.createNote("# T\n", { path: "t" });
318
+ // A row with a traversal path could only arrive via a bug elsewhere
319
+ // (tickets themselves never accept a caller-supplied path) — this pins
320
+ // the download spend route's OWN confinement guard as defense-in-depth.
321
+ const attachment = await store.addAttachment(note.id, "../../../../etc/passwd", "text/plain");
322
+
323
+ const downloadMint = await callTool(vaultName, "request-attachment-download", {
324
+ attachment_id: attachment.id,
325
+ });
326
+ const res = await routeReq(new Request(downloadMint.url, { method: "GET" }));
327
+ expect(res.status).toBe(404);
328
+ });
329
+ });
330
+
331
+ describe("attachment tickets — discoverability", () => {
332
+ test("attachmentsInstructionBlock teaches the ticket tools when enabled, and omits them when not", () => {
333
+ const enabled = attachmentsInstructionBlock({ ticketsEnabled: true });
334
+ expect(enabled).toContain("request-attachment-upload");
335
+ expect(enabled).toContain("request-attachment-download");
336
+ expect(enabled).toContain("curl_example");
337
+
338
+ const disabled = attachmentsInstructionBlock({ ticketsEnabled: false });
339
+ expect(disabled).not.toContain("request-attachment-upload");
340
+ // The REST fallback recipe is always present, wired or not.
341
+ expect(disabled).toContain("/storage/upload");
342
+ });
343
+
344
+ test("bun's connect-time getServerInstruction includes the Attachments section and teaches the ticket tools", async () => {
345
+ const vaultName = freshVault("tickets-instructions");
346
+ const md = await getServerInstruction(vaultName);
347
+ expect(md).toContain("## Attachments");
348
+ expect(md).toContain("request-attachment-upload");
349
+ });
350
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Attachment tickets — bun's runtime-lane implementation (Wave 1).
3
+ *
4
+ * `InProcessAttachmentTicketProvider` is the process-wide (not per-vault)
5
+ * ticket store — a plain `Map` keyed by the 256-bit ticket id, which is
6
+ * already globally unguessable, so there's no need to shard by vault. A
7
+ * daemon restart drops every outstanding ticket: acceptable given the
8
+ * ≤30-minute TTL (a caller mid-upload across a restart just re-mints), and
9
+ * simpler than persisting single-use, short-lived state to disk. Cloud's
10
+ * future mirror persists to the vault's Durable Object instead — see
11
+ * `AttachmentTicketProvider`'s doc comment (`core/src/attachment/tickets.ts`).
12
+ *
13
+ * `handleTicketSpend` is the bare (no auth beyond the ticket itself) HTTP
14
+ * handler `src/routing.ts` dispatches to for `/vault/<name>/tickets/<id>`
15
+ * — BEFORE `authenticateVaultRequest` runs, deliberately: the ticket IS
16
+ * the credential, and a bearer-less shell (curl) must be able to spend it.
17
+ */
18
+
19
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "fs";
20
+ import { join, normalize } from "path";
21
+ import type { Store } from "../core/src/types.ts";
22
+ import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
+ import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
24
+ import { assetsDir, readVaultConfig } from "./config.ts";
25
+ import { shouldAutoTranscribe } from "./auto-transcribe.ts";
26
+ import { invalidateUsageCache } from "./usage.ts";
27
+
28
+ function json(data: unknown, status = 200): Response {
29
+ return Response.json(data, { status });
30
+ }
31
+
32
+ /**
33
+ * Process-wide, in-memory ticket store (see module doc comment for the
34
+ * restart-drops-tickets tradeoff). `take` deletes unconditionally on
35
+ * lookup — single-use is enforced by the delete itself, not by a separate
36
+ * expiry check; `takeValidTicket` below still checks `expiresAt` after
37
+ * `take` returns, collapsing "expired" into the same null as "spent" /
38
+ * "unknown" so the HTTP layer can give a uniform 404 (no oracle).
39
+ */
40
+ class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
41
+ private readonly tickets = new Map<string, AttachmentTicket>();
42
+
43
+ async put(ticket: AttachmentTicket): Promise<void> {
44
+ this.tickets.set(ticket.id, ticket);
45
+ }
46
+
47
+ async take(id: string): Promise<AttachmentTicket | null> {
48
+ const ticket = this.tickets.get(id);
49
+ if (!ticket) return null;
50
+ this.tickets.delete(id);
51
+ return ticket;
52
+ }
53
+ }
54
+
55
+ let sharedProvider: InProcessAttachmentTicketProvider | undefined;
56
+
57
+ /** The one shared ticket provider for this process (see class doc comment above). */
58
+ export function getSharedAttachmentTicketProvider(): AttachmentTicketProvider {
59
+ if (!sharedProvider) sharedProvider = new InProcessAttachmentTicketProvider();
60
+ return sharedProvider;
61
+ }
62
+
63
+ /** Test-only: force a fresh provider so ticket state doesn't leak between test files. */
64
+ export function resetSharedAttachmentTicketProviderForTests(): void {
65
+ sharedProvider = undefined;
66
+ }
67
+
68
+ /**
69
+ * Take + validate a ticket against the URL's vault name and the spend
70
+ * route's expected kind (a GET must not spend an upload ticket, etc.).
71
+ * Collapses spent / expired / unknown / wrong-vault / wrong-kind into the
72
+ * SAME `null` — the uniform-404 no-oracle posture the design calls for
73
+ * (an adversary probing an unguessable URL learns nothing about WHY it
74
+ * failed).
75
+ */
76
+ async function takeValidTicket(
77
+ provider: AttachmentTicketProvider,
78
+ id: string,
79
+ vaultName: string,
80
+ kind: AttachmentTicket["kind"],
81
+ ): Promise<AttachmentTicket | null> {
82
+ const ticket = await provider.take(id);
83
+ if (!ticket) return null;
84
+ if (ticket.kind !== kind || ticket.vaultName !== vaultName || ticket.expiresAt < Date.now()) {
85
+ return null;
86
+ }
87
+ return ticket;
88
+ }
89
+
90
+ function ticketNotFound(): Response {
91
+ return json(
92
+ {
93
+ error: "Not found",
94
+ error_type: "not_found",
95
+ how_to:
96
+ "tickets are single-use and short-lived — mint a new one with request-attachment-upload / request-attachment-download",
97
+ },
98
+ 404,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Dispatch a ticket spend request for `/vault/<name>/tickets/<id>`. PUT or
104
+ * POST spends an upload ticket (the mint tool advertises PUT; POST is
105
+ * accepted too for runtimes/proxies that rewrite methods); GET spends a
106
+ * download ticket. Any other method, or a method that doesn't match the
107
+ * ticket's own kind, collapses to the same 404 `takeValidTicket` uses —
108
+ * no oracle on which kind a given id was minted for.
109
+ */
110
+ export async function handleTicketSpend(
111
+ req: Request,
112
+ ticketId: string,
113
+ vaultName: string,
114
+ store: Store,
115
+ ): Promise<Response> {
116
+ const provider = getSharedAttachmentTicketProvider();
117
+
118
+ if (req.method === "PUT" || req.method === "POST") {
119
+ return handleUploadSpend(req, ticketId, vaultName, store, provider);
120
+ }
121
+ if (req.method === "GET") {
122
+ return handleDownloadSpend(ticketId, vaultName, store, provider);
123
+ }
124
+ return ticketNotFound();
125
+ }
126
+
127
+ async function handleUploadSpend(
128
+ req: Request,
129
+ ticketId: string,
130
+ vaultName: string,
131
+ store: Store,
132
+ provider: AttachmentTicketProvider,
133
+ ): Promise<Response> {
134
+ const ticket = await takeValidTicket(provider, ticketId, vaultName, "upload");
135
+ if (!ticket || !ticket.noteId || !ticket.filename || !ticket.mimeType) return ticketNotFound();
136
+
137
+ // Declared size at mint IS the enforced cap here — tighter than the flat
138
+ // REST ceiling, per the ticket's own promise (`max_bytes` in the mint
139
+ // response). A ticket somehow minted with no declared size fails closed
140
+ // (0 bytes allowed) rather than falling back to the flat 100 MiB cap.
141
+ const declaredMax = ticket.sizeBytes ?? 0;
142
+
143
+ const contentLengthHeader = req.headers.get("content-length");
144
+ if (contentLengthHeader) {
145
+ const declared = Number(contentLengthHeader);
146
+ if (Number.isFinite(declared) && declared > declaredMax) {
147
+ return json(
148
+ {
149
+ error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
150
+ error_type: "file_too_large",
151
+ limit: declaredMax,
152
+ got: declared,
153
+ how_to: "mint a new ticket with an accurate size_bytes",
154
+ },
155
+ 413,
156
+ );
157
+ }
158
+ }
159
+
160
+ const buffer = Buffer.from(await req.arrayBuffer());
161
+ if (buffer.length > declaredMax) {
162
+ return json(
163
+ {
164
+ error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
165
+ error_type: "file_too_large",
166
+ limit: declaredMax,
167
+ got: buffer.length,
168
+ how_to: "mint a new ticket with an accurate size_bytes",
169
+ },
170
+ 413,
171
+ );
172
+ }
173
+
174
+ // Same on-disk write discipline as REST's POST /storage/upload
175
+ // (src/routes.ts) — server-generated path, never caller-controlled.
176
+ const ext = sanitizeAttachmentExtension(ticket.filename);
177
+ const date = new Date().toISOString().split("T")[0]!;
178
+ const dir = join(assetsDir(vaultName), date);
179
+ mkdirSync(dir, { recursive: true });
180
+ const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
181
+ writeFileSync(join(dir, filename), buffer);
182
+ const relativePath = `${date}/${filename}`;
183
+
184
+ invalidateUsageCache(vaultName);
185
+
186
+ // Mirrors the REST POST /notes/:id/attachments transcribe decision
187
+ // exactly (src/routes.ts): explicit opt-in (here, `transcribe: true` at
188
+ // mint) wins; otherwise infer from mime-type + the owning vault's
189
+ // auto-transcribe toggle.
190
+ const explicitOptIn = ticket.transcribe === true;
191
+ const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
192
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
193
+ const attMeta: Record<string, unknown> = {
194
+ original_name: ticket.filename,
195
+ size: buffer.length,
196
+ };
197
+ if (explicitOptIn || autoOptIn) {
198
+ attMeta.transcribe_status = "pending";
199
+ attMeta.transcribe_requested_at = new Date().toISOString();
200
+ attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
201
+ }
202
+
203
+ const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
204
+
205
+ if (explicitOptIn) {
206
+ const note = await store.getNote(ticket.noteId);
207
+ if (note) {
208
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
209
+ if (noteMeta.transcribe_stub !== true) {
210
+ await store.updateNote(ticket.noteId, {
211
+ metadata: { ...noteMeta, transcribe_stub: true },
212
+ skipUpdatedAt: true,
213
+ });
214
+ }
215
+ }
216
+ }
217
+
218
+ // Ticket mint + register in one shot (vs REST's two-step upload-then-
219
+ // attach) — the spend response is the attachment row itself, so the
220
+ // runtime can hand the id straight back to the model.
221
+ return json(attachment, 201);
222
+ }
223
+
224
+ async function handleDownloadSpend(
225
+ ticketId: string,
226
+ vaultName: string,
227
+ store: Store,
228
+ provider: AttachmentTicketProvider,
229
+ ): Promise<Response> {
230
+ const ticket = await takeValidTicket(provider, ticketId, vaultName, "download");
231
+ if (!ticket || !ticket.attachmentId) return ticketNotFound();
232
+
233
+ // The row can vanish between mint and spend (rare — a delete racing a
234
+ // slow curl); fold that into the same uniform 404 rather than a 500.
235
+ const attachment = await store.getAttachment(ticket.attachmentId);
236
+ if (!attachment) return ticketNotFound();
237
+
238
+ const assets = assetsDir(vaultName);
239
+ const filePath = normalize(join(assets, attachment.path));
240
+ if (!filePath.startsWith(normalize(assets)) || !existsSync(filePath)) {
241
+ return json(
242
+ {
243
+ error: "Not found",
244
+ error_type: "attachment_binary_missing",
245
+ how_to: "the attachment row exists but its bytes are gone — this ticket can't be re-spent",
246
+ },
247
+ 404,
248
+ );
249
+ }
250
+
251
+ const stat = statSync(filePath);
252
+ const fileBuffer = readFileSync(filePath);
253
+ return new Response(fileBuffer, {
254
+ status: 200,
255
+ headers: {
256
+ "Content-Type": attachment.mimeType || "application/octet-stream",
257
+ "Content-Length": String(stat.size),
258
+ // Same defense-in-depth as the existing GET /storage/<path> byte-serve
259
+ // (src/routes.ts) — never let a browser MIME-sniff a stored asset into
260
+ // an active type.
261
+ "X-Content-Type-Options": "nosniff",
262
+ },
263
+ });
264
+ }
package/src/mcp-http.ts CHANGED
@@ -109,7 +109,7 @@ export async function handleScopedMcp(
109
109
  const instruction = await getServerInstruction(vaultName, auth);
110
110
  return handleMcp(
111
111
  req,
112
- () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
112
+ () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null, req),
113
113
  `parachute-vault/${vaultName}`,
114
114
  vaultName,
115
115
  auth,
@@ -229,6 +229,8 @@ export async function handleMcp(
229
229
  tag?: string;
230
230
  cycle?: unknown;
231
231
  referencing_tags?: unknown;
232
+ /** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
233
+ how_to?: string;
232
234
  };
233
235
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
234
236
  // values that are structurally invalid rather than merely "no
@@ -399,6 +401,16 @@ export async function handleMcp(
399
401
  error_type: e.error_type,
400
402
  field: e.field,
401
403
  hint: e.hint,
404
+ // Forwarded when present (undefined keys are fine — JSON-RPC
405
+ // `data` just omits them) so a `structuredError()` call site that
406
+ // stamps extra fields — `limit`/`got`/`extension` (size/type
407
+ // refusals) or `how_to` (attachment-tickets' JIT-docs field, §2c)
408
+ // — isn't silently truncated down to error_type/field/hint by
409
+ // this backstop.
410
+ ...(e.limit !== undefined ? { limit: e.limit } : {}),
411
+ ...(e.got !== undefined ? { got: e.got } : {}),
412
+ ...(e.extension !== undefined ? { extension: e.extension } : {}),
413
+ ...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
402
414
  });
403
415
  }
404
416
  return {