@openparachute/vault 0.7.3-rc.5 → 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.
@@ -4,6 +4,7 @@ import {
4
4
  escapeFtsToken,
5
5
  buildLiteralSearchQuery,
6
6
  isValidSearchMode,
7
+ extractLiteralBoostTerms,
7
8
  } from "./search-query.js";
8
9
 
9
10
  describe("escapeFtsToken", () => {
@@ -147,3 +148,44 @@ describe("isValidSearchMode / SEARCH_MODES", () => {
147
148
  expect(isValidSearchMode(["literal"])).toBe(false);
148
149
  });
149
150
  });
151
+
152
+ describe("extractLiteralBoostTerms (search title-boost input, title axis)", () => {
153
+ it("lowercases and whitespace-splits", () => {
154
+ expect(extractLiteralBoostTerms("Budget Review")).toEqual(["budget", "review"]);
155
+ });
156
+
157
+ it("collapses whitespace runs", () => {
158
+ expect(extractLiteralBoostTerms("widgets gadgets")).toEqual(["widgets", "gadgets"]);
159
+ });
160
+
161
+ it("trims leading/trailing whitespace", () => {
162
+ expect(extractLiteralBoostTerms(" widgets ")).toEqual(["widgets"]);
163
+ });
164
+
165
+ it("strips literal quote characters a caller typed, unlike buildLiteralSearchQuery", () => {
166
+ // buildLiteralSearchQuery treats a manually-typed `"` as ordinary
167
+ // content (escaped into the FTS phrase); the boost-term extractor
168
+ // strips it instead, since a title-string comparison shouldn't
169
+ // require the title to contain a literal quote mark.
170
+ expect(extractLiteralBoostTerms(`"eleven-day capping delay"`)).toEqual([
171
+ "eleven-day",
172
+ "capping",
173
+ "delay",
174
+ ]);
175
+ });
176
+
177
+ it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
178
+ expect(extractLiteralBoostTerms("eleven-day")).toEqual(["eleven-day"]);
179
+ expect(extractLiteralBoostTerms("18.6")).toEqual(["18.6"]);
180
+ expect(extractLiteralBoostTerms("didn't")).toEqual(["didn't"]);
181
+ });
182
+
183
+ it("returns an empty array for blank/whitespace-only input", () => {
184
+ expect(extractLiteralBoostTerms("")).toEqual([]);
185
+ expect(extractLiteralBoostTerms(" ")).toEqual([]);
186
+ });
187
+
188
+ it("sanitizes control characters as separators, matching buildLiteralSearchQuery", () => {
189
+ expect(extractLiteralBoostTerms("hello\x00world")).toEqual(["hello", "world"]);
190
+ });
191
+ });
@@ -153,3 +153,30 @@ export function buildLiteralSearchQuery(raw: string): LiteralSearchQuery {
153
153
  const tokens = cleaned.trim().split(/\s+/).filter(Boolean);
154
154
  return { query: tokens.map(escapeFtsToken).join(" "), isEmpty: false };
155
155
  }
156
+
157
+ /**
158
+ * Extract lowercase whitespace-delimited terms from raw literal-mode search
159
+ * text, for the search title-boost post-rank (title axis, ratified
160
+ * 2026-07-17) — NOT used to build the FTS5 MATCH query itself (that's
161
+ * `buildLiteralSearchQuery`, above). Sanitizes control characters the same
162
+ * way, plus strips any literal `"` a caller typed (kept as ordinary content
163
+ * by `buildLiteralSearchQuery`, but a display-title substring check
164
+ * shouldn't require the title to contain a literal quote mark).
165
+ *
166
+ * This is a heuristic re-rank signal, not a second index — it deliberately
167
+ * does not attempt FTS5-tokenizer parity (stemming, unicode normalization).
168
+ * It only needs to answer "does the note's first line plausibly contain the
169
+ * query," not reproduce FTS5's match semantics exactly. Restricted to
170
+ * literal-mode callers by convention (see `core/src/notes.ts` `searchNotes`)
171
+ * — `search_mode: "advanced"` text carries FTS5 boolean/column/prefix
172
+ * operators (`AND`, `path:`, `foo*`) that would leak into the term list as
173
+ * false "content" if tokenized the same naive way.
174
+ */
175
+ export function extractLiteralBoostTerms(raw: string): string[] {
176
+ const cleaned = raw.replace(CONTROL_CHARS, " ").toLowerCase();
177
+ return cleaned
178
+ .trim()
179
+ .split(/\s+/)
180
+ .map((t) => t.replace(/^"+|"+$/g, ""))
181
+ .filter(Boolean);
182
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Search title-boost (title axis, ratified 2026-07-17): literal-mode
3
+ * search results whose `displayTitle` (first non-empty content line, NOT
4
+ * `path`) contains every query term are post-ranked ahead of body-only
5
+ * matches. No-migration — an in-memory re-rank of the already-fetched
6
+ * page, not a `notes_fts` schema change (see `applySearchTitleBoost` /
7
+ * `boostTitleMatches` in `notes.ts`).
8
+ *
9
+ * This is a DIFFERENT axis from the existing `path`-weighted bm25 ranking
10
+ * covered in `search-fts-v25.test.ts` — a note's title here is its first
11
+ * CONTENT line, not its `path`.
12
+ */
13
+ import { describe, it, expect, beforeEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { SqliteStore } from "./store.js";
16
+
17
+ let store: SqliteStore;
18
+ let db: Database;
19
+
20
+ beforeEach(() => {
21
+ db = new Database(":memory:");
22
+ store = new SqliteStore(db);
23
+ });
24
+
25
+ describe("search title-boost", () => {
26
+ it("ranks a first-line (title) match above a body-only match", async () => {
27
+ // Body-only match: "budget" appears only deep in the body, first line
28
+ // is unrelated.
29
+ await store.createNote(
30
+ "Weekly Standup\nlots of unrelated notes here, but somewhere we discuss the budget in passing",
31
+ { path: "standup-notes" },
32
+ );
33
+ // First-line (title) match: "Budget" is the first line itself.
34
+ await store.createNote("Budget Review\nQ3 numbers", { path: "budget-review" });
35
+
36
+ const hits = await store.searchNotes("budget");
37
+ expect(hits.length).toBeGreaterThanOrEqual(2);
38
+ expect(hits[0].path).toBe("budget-review");
39
+ });
40
+
41
+ it("requires ALL query terms to be present in the first line to boost (a partial title match doesn't qualify)", async () => {
42
+ // "budget" is in the title but "review" is only in the body — a
43
+ // PARTIAL title match, which stays in the body-only tier.
44
+ await store.createNote("Budget notes\nsee the quarterly review for details", { path: "partial" });
45
+ // Both "budget" and "review" are in the title — a FULL title match.
46
+ await store.createNote("Budget Review\nQ3 numbers", { path: "full-match" });
47
+
48
+ const hits = await store.searchNotes("budget review");
49
+ expect(hits.map((n) => n.path)).toEqual(["full-match", "partial"]);
50
+ });
51
+
52
+ it("ties (both notes in the same tier) keep the existing relevance order", async () => {
53
+ // Two title-matching notes plus one body-only note. Comparing literal
54
+ // mode (boosted) against advanced mode with the same plain-word query
55
+ // (identical FTS5 syntax, so identical underlying bm25 order, but no
56
+ // boost pass) isolates the boost's effect: within the title-match
57
+ // tier, the boost must preserve whatever relative order the
58
+ // underlying relevance ranking already gave — a stable sort, not a
59
+ // fresh comparator that could reshuffle ties.
60
+ await store.createNote("Project Widget\nfirst, mentions widget again for relevance", { path: "w1" });
61
+ await store.createNote("Project Widget\nsecond", { path: "w2" });
62
+ await store.createNote("Unrelated\nbody-only mention of widget project", { path: "body-only" });
63
+
64
+ const boosted = await store.searchNotes("widget project");
65
+ const rawOrder = await store.searchNotes("widget project", { mode: "advanced" });
66
+
67
+ const titleTierBoosted = boosted.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
68
+ const titleTierRaw = rawOrder.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
69
+ expect(titleTierBoosted).toEqual(titleTierRaw);
70
+ // The title-match tier precedes the body-only note in the boosted result.
71
+ expect(boosted.map((n) => n.path).indexOf("body-only")).toBeGreaterThan(
72
+ Math.max(...["w1", "w2"].map((p) => boosted.map((n) => n.path).indexOf(p))),
73
+ );
74
+ });
75
+
76
+ it("does not disturb ordering when an explicit sort is requested", async () => {
77
+ await store.createNote("Zebra body-only mention of gadget", { path: "z" });
78
+ await store.createNote("Gadget Launch\nfirst line title match", { path: "a" });
79
+
80
+ const asc = await store.searchNotes("gadget", { sort: "asc" });
81
+ // created_at ASC means "z" (created first) comes before "a" — the
82
+ // title-boost must NOT override an explicit sort.
83
+ expect(asc.map((n) => n.path)).toEqual(["z", "a"]);
84
+ });
85
+
86
+ it("does not apply the boost under search_mode: advanced (raw FTS5 syntax, not naively tokenizable)", async () => {
87
+ await store.createNote("Body only mentions gadget in passing text here", { path: "body-only" });
88
+ await store.createNote("Gadget\nfirst line title match", { path: "title-match" });
89
+
90
+ const advanced = await store.searchNotes("gadget", { mode: "advanced" });
91
+ const literal = await store.searchNotes("gadget");
92
+
93
+ // Literal mode boosts the title match to the front.
94
+ expect(literal[0].path).toBe("title-match");
95
+ // Advanced mode is unaffected by the boost — order comes from bm25
96
+ // relevance alone (title-match still likely ranks first via the
97
+ // EXISTING path-weighted bm25, but that's a different mechanism; the
98
+ // test here just confirms advanced mode doesn't throw and returns
99
+ // both notes, not that ordering matches/mismatches literal mode).
100
+ expect(advanced.map((n) => n.path).sort()).toEqual(["body-only", "title-match"]);
101
+ });
102
+
103
+ it("a query with no results is unaffected (empty array in, empty array out)", async () => {
104
+ const hits = await store.searchNotes("nonexistent_term_xyz");
105
+ expect(hits).toEqual([]);
106
+ });
107
+ });
package/core/src/types.ts CHANGED
@@ -400,6 +400,12 @@ export interface NoteIndex {
400
400
  metadata?: Record<string, unknown>;
401
401
  byteSize: number;
402
402
  preview: string;
403
+ /** Derived from the first non-empty line of content (title axis, ratified
404
+ * 2026-07-17) — NEVER stored, computed fresh at read time by
405
+ * `computeDisplayTitle`. `null` when content has no non-empty line.
406
+ * Surfaces decide how to render a `null` title (e.g. a timestamp/path
407
+ * fallback); core just reports the honest content-derived value. */
408
+ displayTitle: string | null;
403
409
  /** Opt-in link degree (see `Note.linkCount`). */
404
410
  linkCount?: number;
405
411
  /** Full-text search relevance score (see `Note.score`). Carried onto the
@@ -272,6 +272,42 @@ function sqliteToUserType(t: string): string {
272
272
  return t.toLowerCase();
273
273
  }
274
274
 
275
+ // ---------------------------------------------------------------------------
276
+ // Attachments orientation block (attachment-tickets design, Wave 0+1)
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * The Attachments orientation block, rendered into the connect-time
281
+ * markdown brief (`projectionToMarkdown`) so every agent learns, every
282
+ * session, how to move file bytes into/out of this vault without spending
283
+ * its own context on them (bytes never ride MCP either way).
284
+ *
285
+ * `ticketsEnabled` reflects whether THIS door has wired an
286
+ * `AttachmentTicketProvider` (bun: always, as of this PR; cloud: not yet
287
+ * — its mirror is a separate PR). An unwired door omits BOTH the ticket
288
+ * tools from `tools/list` (see `generateMcpTools`'s `attachmentTickets`
289
+ * opt) AND the ticket-tool sentences here — the brief never dangles a
290
+ * pointer at a tool the agent can't actually call.
291
+ *
292
+ * Kept as a single dense paragraph (not its own multi-line list) to stay
293
+ * inside the connect-time brief's token budget — see
294
+ * `projectionToMarkdown`'s doc comment.
295
+ */
296
+ export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
297
+ const sentences: string[] = [
298
+ "Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
299
+ ];
300
+ if (opts.ticketsEnabled) {
301
+ sentences.push(
302
+ "To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
303
+ );
304
+ }
305
+ sentences.push(
306
+ "If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer`. Audio attached with `transcribe: true` is transcribed automatically.",
307
+ );
308
+ return sentences.join(" ");
309
+ }
310
+
275
311
  // ---------------------------------------------------------------------------
276
312
  // Markdown rendering — for getServerInstruction
277
313
  // ---------------------------------------------------------------------------
@@ -302,6 +338,13 @@ export function projectionToMarkdown(args: {
302
338
  * known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
303
339
  */
304
340
  coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
341
+ /**
342
+ * Attachments orientation (see `attachmentsInstructionBlock`). Omitted
343
+ * defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
344
+ * that hasn't wired ticket support yet (or a test fixture) never
345
+ * accidentally advertises tools it can't back.
346
+ */
347
+ attachments?: { ticketsEnabled: boolean };
305
348
  }): string {
306
349
  const { vaultName, description, projection, coordinates } = args;
307
350
  const stats = projection.stats;
@@ -418,6 +461,11 @@ export function projectionToMarkdown(args: {
418
461
  lines.push("");
419
462
  lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
420
463
 
464
+ lines.push("");
465
+ lines.push("## Attachments");
466
+ lines.push("");
467
+ lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
468
+
421
469
  // Scripting pointer block: the connect-time brief used to dead-end on
422
470
  // querying — an agent had no path to "how do I script/automate against this
423
471
  // vault." Point at the guide rather than inlining it, to keep this brief
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.5",
3
+ "version": "0.7.3-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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
+ });