@openparachute/vault 0.7.2 → 0.7.3-rc.10
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/README.md +5 -3
- 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/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +62 -13
|
@@ -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/auth.ts
CHANGED
|
@@ -280,14 +280,20 @@ export function isMethodAllowed(method: string, permission: TokenPermission): bo
|
|
|
280
280
|
return READ_METHODS.has(method);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// RFC 7235: the auth-scheme token is case-insensitive (`Bearer`/`bearer`/
|
|
284
|
+
// `BEARER` are all the same scheme) — only the token68/credentials that
|
|
285
|
+
// follow are case-sensitive. `\s+` (not a single literal space) tolerates
|
|
286
|
+
// the rare client that sends more than one space after the scheme.
|
|
287
|
+
const BEARER_PREFIX = /^Bearer\s+/i;
|
|
288
|
+
|
|
283
289
|
/**
|
|
284
290
|
* Extract API key/token from request.
|
|
285
291
|
* Priority: Authorization header → X-API-Key header → ?key= query param.
|
|
286
292
|
*/
|
|
287
293
|
export function extractApiKey(req: Request): string | null {
|
|
288
294
|
const authHeader = req.headers.get("authorization");
|
|
289
|
-
if (authHeader
|
|
290
|
-
return authHeader.
|
|
295
|
+
if (authHeader && BEARER_PREFIX.test(authHeader)) {
|
|
296
|
+
return authHeader.replace(BEARER_PREFIX, "");
|
|
291
297
|
}
|
|
292
298
|
const xApiKey = req.headers.get("x-api-key");
|
|
293
299
|
if (xApiKey) return xApiKey;
|
package/src/cli.ts
CHANGED
|
@@ -4842,7 +4842,11 @@ async function cmdAddPack(args: string[]) {
|
|
|
4842
4842
|
console.log(` = note ${path} (already exists — left untouched)`);
|
|
4843
4843
|
}
|
|
4844
4844
|
for (const tag of result.tags) {
|
|
4845
|
-
|
|
4845
|
+
if (result.preservedTagDescriptions.includes(tag)) {
|
|
4846
|
+
console.log(` ~ tag ${tag} (kept user description)`);
|
|
4847
|
+
} else {
|
|
4848
|
+
console.log(` ~ tag ${tag} (upserted)`);
|
|
4849
|
+
}
|
|
4846
4850
|
}
|
|
4847
4851
|
if (result.seededNotes.length === 0 && result.tags.length === 0) {
|
|
4848
4852
|
console.log(" Nothing to add — everything was already in place.");
|
|
@@ -315,7 +315,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
315
315
|
expect(record?.fields ?? null).toBeFalsy();
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
-
// Positive control — every one of the
|
|
318
|
+
// Positive control — every one of the recognized types (indexable or
|
|
319
319
|
// not) is accepted without complaint.
|
|
320
320
|
it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
|
|
321
321
|
const req = new Request("http://localhost/api/tags/widget", {
|
|
@@ -329,6 +329,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
|
329
329
|
e: { type: "array" },
|
|
330
330
|
f: { type: "object" },
|
|
331
331
|
g: { type: "reference" },
|
|
332
|
+
h: { type: "date" },
|
|
332
333
|
},
|
|
333
334
|
}),
|
|
334
335
|
});
|