@openparachute/vault 0.7.3-rc.6 → 0.7.3-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/mcp.ts +243 -2
- package/core/src/vault-projection.ts +48 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/routes.ts +12 -91
- package/src/routing.ts +17 -0
- package/src/vault.test.ts +19 -11
|
@@ -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 {
|