@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.
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ BLOCKED_ATTACHMENT_EXTENSIONS,
4
+ ATTACHMENT_MIME_TYPES,
5
+ sanitizeAttachmentExtension,
6
+ mimeForAttachmentExtension,
7
+ } from "./policy.ts";
8
+
9
+ describe("sanitizeAttachmentExtension", () => {
10
+ test("extracts a lowercased extension", () => {
11
+ expect(sanitizeAttachmentExtension("Photo.PNG")).toBe(".png");
12
+ });
13
+
14
+ test("strips trailing dots/whitespace before extracting — evil.html. can't slip past the blocklist as an empty extension", () => {
15
+ expect(sanitizeAttachmentExtension("evil.html.")).toBe(".html");
16
+ expect(sanitizeAttachmentExtension("evil.svg ")).toBe(".svg");
17
+ });
18
+
19
+ test("a dotfile with no real extension reports none", () => {
20
+ expect(sanitizeAttachmentExtension(".bashrc")).toBe("");
21
+ });
22
+
23
+ test("a filename with no dot reports none", () => {
24
+ expect(sanitizeAttachmentExtension("README")).toBe("");
25
+ });
26
+ });
27
+
28
+ describe("BLOCKED_ATTACHMENT_EXTENSIONS — active-content set", () => {
29
+ test("blocks the same-origin-XSS extensions", () => {
30
+ for (const ext of [".html", ".htm", ".xhtml", ".shtml", ".xht", ".svg", ".xml", ".js", ".mjs", ".cjs", ".css"]) {
31
+ expect(BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)).toBe(true);
32
+ }
33
+ });
34
+
35
+ test("does not block ordinary document/media extensions", () => {
36
+ for (const ext of [".png", ".pdf", ".csv", ".epub", ".zip", ".mp3", ".docx"]) {
37
+ expect(BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)).toBe(false);
38
+ }
39
+ });
40
+ });
41
+
42
+ describe("mimeForAttachmentExtension", () => {
43
+ test("resolves a curated extension", () => {
44
+ expect(mimeForAttachmentExtension(".png")).toBe("image/png");
45
+ expect(mimeForAttachmentExtension(".pdf")).toBe("application/pdf");
46
+ });
47
+
48
+ test("falls back to application/octet-stream for an uncurated extension", () => {
49
+ expect(mimeForAttachmentExtension(".azw3")).toBe("application/octet-stream");
50
+ });
51
+
52
+ test("no MIME entry maps to a browser-active type (INVARIANT)", () => {
53
+ const activeTypes = new Set([
54
+ "text/html",
55
+ "image/svg+xml",
56
+ "application/xhtml+xml",
57
+ "text/javascript",
58
+ "application/wasm",
59
+ "text/css",
60
+ ]);
61
+ for (const mime of Object.values(ATTACHMENT_MIME_TYPES)) {
62
+ const bare = mime.split(";")[0]!.trim();
63
+ expect(activeTypes.has(bare)).toBe(false);
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Shared attachment upload policy — extension blocklist + MIME lookup.
3
+ *
4
+ * Canonical source for every upload path into this vault's attachment
5
+ * storage: the REST `POST /storage/upload` handler (`src/routes.ts`, which
6
+ * imports these constants rather than declaring its own) and the
7
+ * attachment-ticket mint/spend path (`core/src/mcp.ts`'s
8
+ * `request-attachment-upload` tool + `src/attachment-tickets.ts`'s spend
9
+ * route). One list, so a blocked extension can never diverge between the
10
+ * two upload doors into this vault (vault attachment-tickets design,
11
+ * "shared BLOCKED_EXTENSIONS").
12
+ *
13
+ * Deliberately dependency-free (no `node:path`) — this module is imported
14
+ * by `core/src/mcp.ts`, which a Cloudflare Worker (cloud's runtime) bundles
15
+ * directly; a hand-rolled `extname` keeps that bundle Node-compat-free.
16
+ */
17
+
18
+ // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
19
+ // arbitrary files — ebooks, office docs, datasets, archives, binaries — so we
20
+ // accept ANY upload EXCEPT the handful of types a browser can execute as
21
+ // active content in our origin when served back from /storage/. (The prior
22
+ // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
23
+ // not allowed".)
24
+ //
25
+ // BLOCKED — same-origin-XSS / active-content set:
26
+ // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
27
+ // .svg XML image — embeds <script>
28
+ // .xml can carry XSLT / be parsed as XHTML
29
+ // .js/.mjs/.cjs JavaScript
30
+ // .css style-injection / UI-redress vector
31
+ //
32
+ // Two independent guards keep every STORED file inert when served:
33
+ // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
34
+ // every other extension serves as application/octet-stream — a download,
35
+ // never rendered.
36
+ // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
37
+ // a browser can't sniff an octet-stream body into an executable type.
38
+ // The blocklist is belt-and-suspenders on top of those: even if a future MIME
39
+ // entry or an upstream proxy weakened (1) or (2), these extensions still never
40
+ // land on disk. If a future use case needs SVG, sanitize on read (strip
41
+ // <script>/<foreignObject>) and revisit.
42
+ export const BLOCKED_ATTACHMENT_EXTENSIONS = new Set([
43
+ ".html", ".htm", ".xhtml", ".shtml", ".xht",
44
+ ".svg",
45
+ ".xml",
46
+ ".js", ".mjs", ".cjs",
47
+ ".css",
48
+ ]);
49
+
50
+ // Explicit MIME types for the commonly-previewed formats. Anything accepted
51
+ // but absent here serves as application/octet-stream — a download, never
52
+ // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
53
+ // these map to an active type (text/html, image/svg+xml), so a served asset
54
+ // can't execute script; `nosniff` on the GET response makes that ironclad.
55
+ //
56
+ // INVARIANT: never add an entry that maps to a browser-active type —
57
+ // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
58
+ // application/wasm, text/css. Doing so re-enables same-origin execution for
59
+ // that extension (and would mean it must also join BLOCKED_ATTACHMENT_EXTENSIONS).
60
+ export const ATTACHMENT_MIME_TYPES: Record<string, string> = {
61
+ // Audio
62
+ ".wav": "audio/wav",
63
+ ".mp3": "audio/mpeg",
64
+ ".m4a": "audio/mp4",
65
+ ".ogg": "audio/ogg",
66
+ ".oga": "audio/ogg",
67
+ ".opus": "audio/opus",
68
+ ".aac": "audio/aac",
69
+ ".flac": "audio/flac",
70
+ ".webm": "audio/webm",
71
+ // Image
72
+ ".png": "image/png",
73
+ ".jpg": "image/jpeg",
74
+ ".jpeg": "image/jpeg",
75
+ ".gif": "image/gif",
76
+ ".webp": "image/webp",
77
+ ".bmp": "image/bmp",
78
+ ".tiff": "image/tiff",
79
+ ".tif": "image/tiff",
80
+ ".heic": "image/heic",
81
+ ".heif": "image/heif",
82
+ ".avif": "image/avif",
83
+ // Video
84
+ ".mp4": "video/mp4",
85
+ ".m4v": "video/x-m4v",
86
+ ".mov": "video/quicktime",
87
+ // Documents / ebooks / data
88
+ ".pdf": "application/pdf",
89
+ ".epub": "application/epub+zip",
90
+ ".mobi": "application/x-mobipocket-ebook",
91
+ ".txt": "text/plain; charset=utf-8",
92
+ ".md": "text/markdown; charset=utf-8",
93
+ ".markdown": "text/markdown; charset=utf-8",
94
+ ".rtf": "application/rtf",
95
+ ".csv": "text/csv; charset=utf-8",
96
+ ".tsv": "text/tab-separated-values; charset=utf-8",
97
+ ".json": "application/json; charset=utf-8",
98
+ ".doc": "application/msword",
99
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
100
+ ".ppt": "application/vnd.ms-powerpoint",
101
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
102
+ ".xls": "application/vnd.ms-excel",
103
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
104
+ ".odt": "application/vnd.oasis.opendocument.text",
105
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
106
+ ".odp": "application/vnd.oasis.opendocument.presentation",
107
+ ".zip": "application/zip",
108
+ };
109
+
110
+ /** Node-`path`-free `extname`: lowercased, leading-dot dotfiles ("`.bashrc`") report no extension — same edge case Node's `path.extname` treats as extensionless. */
111
+ function extnameOf(filename: string): string {
112
+ const idx = filename.lastIndexOf(".");
113
+ if (idx <= 0) return "";
114
+ return filename.slice(idx).toLowerCase();
115
+ }
116
+
117
+ /**
118
+ * Extract the sanitized, lowercased extension from a caller-supplied
119
+ * filename. Strips trailing dots/whitespace FIRST so `evil.html.` /
120
+ * `evil.svg ` can't slip past `BLOCKED_ATTACHMENT_EXTENSIONS`
121
+ * (`extname("evil.html.")` would otherwise be `"."`, not `.html`) — mirrors
122
+ * `src/routes.ts`'s upload-handler sanitization exactly.
123
+ */
124
+ export function sanitizeAttachmentExtension(filename: string): string {
125
+ return extnameOf(filename.replace(/[.\s]+$/, ""));
126
+ }
127
+
128
+ /** MIME type for a (already-sanitized) extension, `application/octet-stream` when uncurated — never an active/executable type (see the INVARIANT above). */
129
+ export function mimeForAttachmentExtension(ext: string): string {
130
+ return ATTACHMENT_MIME_TYPES[ext] ?? "application/octet-stream";
131
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { computeTicketTtlMs, generateTicketId, MAX_TICKET_UPLOAD_BYTES } from "./tickets.ts";
3
+
4
+ describe("computeTicketTtlMs — Aaron-ratified TTL scaling (10 min base + 10s/MiB, 30 min cap)", () => {
5
+ test("no declared size → base TTL (10 minutes)", () => {
6
+ expect(computeTicketTtlMs()).toBe(10 * 60 * 1000);
7
+ });
8
+
9
+ test("zero or negative size → base TTL (fails safe, not zero-TTL)", () => {
10
+ expect(computeTicketTtlMs(0)).toBe(10 * 60 * 1000);
11
+ expect(computeTicketTtlMs(-5)).toBe(10 * 60 * 1000);
12
+ });
13
+
14
+ test("1 MiB declared → base + 10s", () => {
15
+ expect(computeTicketTtlMs(1024 * 1024)).toBe(10 * 60 * 1000 + 10 * 1000);
16
+ });
17
+
18
+ test("10 MiB declared → base + 100s", () => {
19
+ expect(computeTicketTtlMs(10 * 1024 * 1024)).toBe(10 * 60 * 1000 + 100 * 1000);
20
+ });
21
+
22
+ test("100 MiB declared (under the cap's crossover at 120 MiB) is base + 1000s, not yet capped", () => {
23
+ expect(computeTicketTtlMs(100 * 1024 * 1024)).toBe(10 * 60 * 1000 + 1000 * 1000);
24
+ });
25
+
26
+ test("a declared size past the crossover point (200 MiB) is capped at 30 minutes flat", () => {
27
+ expect(computeTicketTtlMs(200 * 1024 * 1024)).toBe(30 * 60 * 1000);
28
+ });
29
+ });
30
+
31
+ describe("generateTicketId", () => {
32
+ test("is a 64-char hex string (256 bits)", () => {
33
+ const id = generateTicketId();
34
+ expect(id).toMatch(/^[0-9a-f]{64}$/);
35
+ });
36
+
37
+ test("is unique across many calls (no Math.random-grade collisions)", () => {
38
+ const ids = new Set(Array.from({ length: 1000 }, () => generateTicketId()));
39
+ expect(ids.size).toBe(1000);
40
+ });
41
+ });
42
+
43
+ test("MAX_TICKET_UPLOAD_BYTES mirrors REST's 100 MiB upload ceiling", () => {
44
+ expect(MAX_TICKET_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
45
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Attachment tickets — the runtime lane (Wave 1) of the two-lane
3
+ * attachments-for-agents design. Bytes never touch the MCP session:
4
+ * `request-attachment-upload` / `request-attachment-download`
5
+ * (`core/src/mcp.ts`) mint a short-lived, single-use, capability-in-URL
6
+ * ticket; a runtime with a shell (never the model) spends it directly
7
+ * against a bare HTTP endpoint outside the authed API tree.
8
+ *
9
+ * This module owns the wire-shape-defining pieces so both doors can't
10
+ * drift: the ticket record shape, TTL computation, and id generation. The
11
+ * `AttachmentTicketProvider` seam is deliberately dumb — a single-use KV,
12
+ * no policy — because ALL policy (note/attachment resolution, tag-scope,
13
+ * size cap, extension sanitize) runs at mint time in the MCP tools
14
+ * themselves, pinned here so a future cloud implementation can't drift on
15
+ * the wire shape or the error taxonomy.
16
+ */
17
+
18
+ export type AttachmentTicketKind = "upload" | "download";
19
+
20
+ /**
21
+ * A minted, not-yet-spent ticket. `sizeBytes` is the DECLARED size at mint
22
+ * — upload: caller-asserted (`request-attachment-upload`'s `size_bytes`,
23
+ * enforced as a hard cap at spend, see `MAX_TICKET_UPLOAD_BYTES`);
24
+ * download: best-effort from `attachments.metadata.size` when the row
25
+ * carries one (advisory only — never enforced, since a download ticket
26
+ * never writes bytes).
27
+ */
28
+ export interface AttachmentTicket {
29
+ id: string;
30
+ kind: AttachmentTicketKind;
31
+ vaultName: string;
32
+ createdAt: number;
33
+ expiresAt: number;
34
+ mimeType?: string;
35
+ sizeBytes?: number;
36
+ /** Upload only. */
37
+ noteId?: string;
38
+ filename?: string;
39
+ transcribe?: boolean;
40
+ /** Download only. */
41
+ attachmentId?: string;
42
+ }
43
+
44
+ /**
45
+ * Storage seam for ticket state (D10 — "tools omitted when unwired"). A
46
+ * door that hasn't implemented this yet passes no `attachmentTickets` opt
47
+ * into `generateMcpTools` — `request-attachment-upload` /
48
+ * `request-attachment-download` are then ABSENT from the tool list
49
+ * entirely (not merely erroring on call), so an agent is never shown an
50
+ * affordance the runtime can't back.
51
+ *
52
+ * "State lives where the bytes live" (design decision): bun implements
53
+ * this as an in-process `Map` (`src/attachment-tickets.ts`) — a daemon
54
+ * restart drops every outstanding ticket, which is acceptable at a
55
+ * ≤30-minute TTL and is documented at that call site. A future cloud
56
+ * implementation persists ticket rows in the vault's Durable Object
57
+ * (`ctx.storage`, atomic delete-on-spend under `transactionSync`).
58
+ */
59
+ export interface AttachmentTicketProvider {
60
+ /** Persist a freshly-minted ticket. */
61
+ put(ticket: AttachmentTicket): Promise<void>;
62
+ /**
63
+ * Atomic check-and-consume. Returns the ticket exactly once — a second
64
+ * call for the same id (already spent), an id past its `expiresAt`, or
65
+ * an id that never existed all return `null`. Collapsing all three into
66
+ * one shape is deliberate: the spend route gives an unguessable-URL
67
+ * adversary no oracle distinguishing "wrong id" from "right id, too
68
+ * late" — the caller still separately checks `expiresAt` against
69
+ * wall-clock (a `take` implementation MAY also drop expired entries
70
+ * opportunistically, but callers must not rely on that for correctness).
71
+ */
72
+ take(id: string): Promise<AttachmentTicket | null>;
73
+ }
74
+
75
+ /**
76
+ * Absolute REST upload ceiling (mirrors `MAX_UPLOAD_BYTES`,
77
+ * `src/routes.ts`) — a ticket can never promise more than REST itself
78
+ * accepts for the same bytes. Enforced at MINT (a `size_bytes` over this
79
+ * is rejected before a ticket is even created) — the ticket's own
80
+ * `sizeBytes` (always `<=` this) is the tighter, per-ticket cap the spend
81
+ * route actually enforces.
82
+ */
83
+ export const MAX_TICKET_UPLOAD_BYTES = 100 * 1024 * 1024;
84
+
85
+ const TICKET_TTL_BASE_MS = 10 * 60 * 1000; // 10 minutes
86
+ const TICKET_TTL_PER_MIB_MS = 10 * 1000; // +10s per declared MiB
87
+ const TICKET_TTL_MAX_MS = 30 * 60 * 1000; // hard cap, 30 minutes
88
+
89
+ /**
90
+ * TTL for a minted ticket, in ms. 10 minutes base + 10 seconds per
91
+ * declared MiB (a slow tether on a large upload gets proportionally more
92
+ * room), hard-capped at 30 minutes regardless of size (Aaron-ratified
93
+ * amendment over the spec's original flat 120s). A size-less mint
94
+ * (download ticket for a row with no known size) gets the base TTL.
95
+ * Single-use is enforced separately and unconditionally by
96
+ * `AttachmentTicketProvider.take` — TTL only bounds how long an UNSPENT
97
+ * ticket stays valid, never how many times a spent one can be reused.
98
+ */
99
+ export function computeTicketTtlMs(sizeBytes?: number): number {
100
+ if (!sizeBytes || sizeBytes <= 0) return TICKET_TTL_BASE_MS;
101
+ const mib = sizeBytes / (1024 * 1024);
102
+ return Math.min(TICKET_TTL_BASE_MS + mib * TICKET_TTL_PER_MIB_MS, TICKET_TTL_MAX_MS);
103
+ }
104
+
105
+ /**
106
+ * 256-bit random ticket id, hex-encoded (64 chars). Capability-in-URL — the
107
+ * ticket's entropy is the ONLY thing standing between "knows the URL" and
108
+ * "can spend it" (no auth beyond the ticket itself), so this is Web Crypto
109
+ * `getRandomValues`, never `Math.random()`. Global `crypto` — available in
110
+ * both Bun and the Cloudflare Workers runtime, so this module stays
111
+ * runtime-agnostic.
112
+ */
113
+ export function generateTicketId(): string {
114
+ const bytes = new Uint8Array(32);
115
+ crypto.getRandomValues(bytes);
116
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
117
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `request-attachment-upload` / `request-attachment-download` — the two
3
+ * MCP mint tools generated by `generateMcpTools` (see mcp.ts's
4
+ * `GenerateMcpToolsOpts.attachmentTickets`). Exercises the "tools omitted
5
+ * when unwired" contract (D10) and the mint-time policy (note/attachment
6
+ * resolution, tag-scope, size cap, extension blocklist) against a fake
7
+ * in-memory `AttachmentTicketProvider` — the spend side (actually writing
8
+ * bytes) is bun-only and covered in src/attachment-tickets.test.ts.
9
+ */
10
+
11
+ import { describe, test, expect, beforeEach } from "bun:test";
12
+ import { Database } from "bun:sqlite";
13
+ import { BunSqliteStore } from "./store.ts";
14
+ import { generateMcpTools, type McpToolDef } from "./mcp.ts";
15
+ import type { AttachmentTicket, AttachmentTicketProvider } from "./attachment/tickets.ts";
16
+ import { MAX_TICKET_UPLOAD_BYTES } from "./attachment/tickets.ts";
17
+ import type { Note } from "./types.ts";
18
+
19
+ class FakeTicketProvider implements AttachmentTicketProvider {
20
+ tickets = new Map<string, AttachmentTicket>();
21
+ async put(t: AttachmentTicket): Promise<void> {
22
+ this.tickets.set(t.id, t);
23
+ }
24
+ async take(id: string): Promise<AttachmentTicket | null> {
25
+ const t = this.tickets.get(id);
26
+ if (!t) return null;
27
+ this.tickets.delete(id);
28
+ return t;
29
+ }
30
+ }
31
+
32
+ let store: BunSqliteStore;
33
+
34
+ beforeEach(() => {
35
+ store = new BunSqliteStore(new Database(":memory:"));
36
+ });
37
+
38
+ function findTool(tools: McpToolDef[], name: string): McpToolDef {
39
+ const t = tools.find((x) => x.name === name);
40
+ if (!t) throw new Error(`tool "${name}" not found`);
41
+ return t;
42
+ }
43
+
44
+ async function callTool(tool: McpToolDef, params: Record<string, unknown>): Promise<any> {
45
+ return await tool.execute(params);
46
+ }
47
+
48
+ async function expectToolError(tool: McpToolDef, params: Record<string, unknown>): Promise<any> {
49
+ try {
50
+ await tool.execute(params);
51
+ throw new Error("expected the tool to throw");
52
+ } catch (err) {
53
+ return err as any;
54
+ }
55
+ }
56
+
57
+ describe("D10 — tools omitted when unwired", () => {
58
+ test("no attachmentTickets opt → neither ticket tool is generated", () => {
59
+ const tools = generateMcpTools(store);
60
+ expect(tools.find((t) => t.name === "request-attachment-upload")).toBeUndefined();
61
+ expect(tools.find((t) => t.name === "request-attachment-download")).toBeUndefined();
62
+ });
63
+
64
+ test("attachmentTickets opt provided → both ticket tools are generated with the right verbs", () => {
65
+ const provider = new FakeTicketProvider();
66
+ const tools = generateMcpTools(store, {
67
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
68
+ });
69
+ const upload = findTool(tools, "request-attachment-upload");
70
+ const download = findTool(tools, "request-attachment-download");
71
+ expect(upload.requiredVerb).toBe("write");
72
+ expect(download.requiredVerb).toBe("read");
73
+ });
74
+ });
75
+
76
+ describe("request-attachment-upload — mint", () => {
77
+ test("happy path: returns the ticket envelope and stores a matching provider record", async () => {
78
+ const note = await store.createNote("# Target\n", { path: "target" });
79
+ const provider = new FakeTicketProvider();
80
+ const tools = generateMcpTools(store, {
81
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
82
+ });
83
+ const tool = findTool(tools, "request-attachment-upload");
84
+
85
+ const before = Date.now();
86
+ const result = await callTool(tool, { note: note.id, filename: "photo.png", size_bytes: 1024 });
87
+ expect(result.method).toBe("PUT");
88
+ expect(result.url).toMatch(/^https:\/\/host\/vault\/v1\/tickets\/[0-9a-f]{64}$/);
89
+ expect(result.headers["content-type"]).toBe("image/png");
90
+ expect(result.max_bytes).toBe(1024);
91
+ expect(result.curl_example).toContain("curl -X PUT");
92
+ expect(result.curl_example).toContain(result.url);
93
+
94
+ const ticketId = result.url.split("/tickets/")[1] as string;
95
+ const stored = provider.tickets.get(ticketId)!;
96
+ expect(stored.kind).toBe("upload");
97
+ expect(stored.noteId).toBe(note.id);
98
+ expect(stored.filename).toBe("photo.png");
99
+ expect(stored.mimeType).toBe("image/png");
100
+ expect(stored.sizeBytes).toBe(1024);
101
+ expect(stored.vaultName).toBe("v1");
102
+ // TTL for a 1024-byte (<1 MiB) declared size is the 10-minute base.
103
+ expect(stored.expiresAt - before).toBeGreaterThanOrEqual(10 * 60 * 1000 - 1000);
104
+ expect(stored.expiresAt - before).toBeLessThanOrEqual(10 * 60 * 1000 + 5000);
105
+ });
106
+
107
+ test("mime_type is inferred from the filename extension when omitted", async () => {
108
+ const note = await store.createNote("# T\n", { path: "t2" });
109
+ const provider = new FakeTicketProvider();
110
+ const tools = generateMcpTools(store, {
111
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
112
+ });
113
+ const result = await callTool(findTool(tools, "request-attachment-upload"), {
114
+ note: note.id,
115
+ filename: "notes.csv",
116
+ size_bytes: 10,
117
+ });
118
+ expect(result.headers["content-type"]).toBe("text/csv; charset=utf-8");
119
+ });
120
+
121
+ test("transcribe: true rides through to the stored ticket", async () => {
122
+ const note = await store.createNote("# T\n", { path: "t3" });
123
+ const provider = new FakeTicketProvider();
124
+ const tools = generateMcpTools(store, {
125
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
126
+ });
127
+ const result = await callTool(findTool(tools, "request-attachment-upload"), {
128
+ note: note.id,
129
+ filename: "memo.wav",
130
+ size_bytes: 10,
131
+ transcribe: true,
132
+ });
133
+ const ticketId = result.url.split("/tickets/")[1] as string;
134
+ expect(provider.tickets.get(ticketId)!.transcribe).toBe(true);
135
+ });
136
+
137
+ test("missing note / filename / size_bytes → missing_required_field with how_to", async () => {
138
+ const provider = new FakeTicketProvider();
139
+ const tools = generateMcpTools(store, {
140
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
141
+ });
142
+ const tool = findTool(tools, "request-attachment-upload");
143
+
144
+ const e1 = await expectToolError(tool, { filename: "a.png", size_bytes: 1 });
145
+ expect(e1.error_type).toBe("missing_required_field");
146
+ expect(e1.field).toBe("note");
147
+ expect(typeof e1.how_to).toBe("string");
148
+
149
+ const e2 = await expectToolError(tool, { note: "x", size_bytes: 1 });
150
+ expect(e2.error_type).toBe("missing_required_field");
151
+ expect(e2.field).toBe("filename");
152
+
153
+ const e3 = await expectToolError(tool, { note: "x", filename: "a.png" });
154
+ expect(e3.error_type).toBe("invalid_query");
155
+ expect(e3.field).toBe("size_bytes");
156
+ });
157
+
158
+ test("size_bytes over the 100 MiB cap → file_too_large with limit/got/how_to", async () => {
159
+ const note = await store.createNote("# T\n", { path: "t4" });
160
+ const provider = new FakeTicketProvider();
161
+ const tools = generateMcpTools(store, {
162
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
163
+ });
164
+ const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
165
+ note: note.id,
166
+ filename: "huge.zip",
167
+ size_bytes: MAX_TICKET_UPLOAD_BYTES + 1,
168
+ });
169
+ expect(err.error_type).toBe("file_too_large");
170
+ expect(err.limit).toBe(MAX_TICKET_UPLOAD_BYTES);
171
+ expect(err.got).toBe(MAX_TICKET_UPLOAD_BYTES + 1);
172
+ expect(typeof err.how_to).toBe("string");
173
+ expect(provider.tickets.size).toBe(0);
174
+ });
175
+
176
+ test("blocked (active-content) extension → blocked_upload_extension, no ticket minted", async () => {
177
+ const note = await store.createNote("# T\n", { path: "t5" });
178
+ const provider = new FakeTicketProvider();
179
+ const tools = generateMcpTools(store, {
180
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
181
+ });
182
+ const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
183
+ note: note.id,
184
+ filename: "evil.html",
185
+ size_bytes: 10,
186
+ });
187
+ expect(err.error_type).toBe("blocked_upload_extension");
188
+ expect(err.extension).toBe(".html");
189
+ expect(provider.tickets.size).toBe(0);
190
+ });
191
+
192
+ test("unknown note → not_found", async () => {
193
+ const provider = new FakeTicketProvider();
194
+ const tools = generateMcpTools(store, {
195
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
196
+ });
197
+ const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
198
+ note: "does-not-exist",
199
+ filename: "a.png",
200
+ size_bytes: 10,
201
+ });
202
+ expect(err.error_type).toBe("not_found");
203
+ });
204
+
205
+ test("tag-scoped noteVisible: false → uniform not_found (no existence oracle)", async () => {
206
+ const note = await store.createNote("# T\n", { path: "t6" });
207
+ const provider = new FakeTicketProvider();
208
+ const tools = generateMcpTools(store, {
209
+ attachmentTickets: {
210
+ provider,
211
+ vaultName: "v1",
212
+ urlBase: "https://host/vault/v1",
213
+ noteVisible: async (_n: Note) => false,
214
+ },
215
+ });
216
+ const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
217
+ note: note.id,
218
+ filename: "a.png",
219
+ size_bytes: 10,
220
+ });
221
+ expect(err.error_type).toBe("not_found");
222
+ expect(provider.tickets.size).toBe(0);
223
+ });
224
+ });
225
+
226
+ describe("request-attachment-download — mint", () => {
227
+ test("happy path: returns the ticket envelope with mime_type + known size, stores a matching provider record", async () => {
228
+ const note = await store.createNote("# T\n", { path: "t7" });
229
+ const attachment = await store.addAttachment(note.id, "2026-07-17/x.png", "image/png", { size: 42 });
230
+ const provider = new FakeTicketProvider();
231
+ const tools = generateMcpTools(store, {
232
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
233
+ });
234
+ const result = await callTool(findTool(tools, "request-attachment-download"), {
235
+ attachment_id: attachment.id,
236
+ });
237
+ expect(result.method).toBe("GET");
238
+ expect(result.mime_type).toBe("image/png");
239
+ expect(result.size_bytes).toBe(42);
240
+ expect(result.curl_example).toContain(result.url);
241
+
242
+ const ticketId = result.url.split("/tickets/")[1] as string;
243
+ const stored = provider.tickets.get(ticketId)!;
244
+ expect(stored.kind).toBe("download");
245
+ expect(stored.attachmentId).toBe(attachment.id);
246
+ });
247
+
248
+ test("missing attachment_id → missing_required_field", async () => {
249
+ const provider = new FakeTicketProvider();
250
+ const tools = generateMcpTools(store, {
251
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
252
+ });
253
+ const err = await expectToolError(findTool(tools, "request-attachment-download"), {});
254
+ expect(err.error_type).toBe("missing_required_field");
255
+ });
256
+
257
+ test("unknown attachment → not_found", async () => {
258
+ const provider = new FakeTicketProvider();
259
+ const tools = generateMcpTools(store, {
260
+ attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
261
+ });
262
+ const err = await expectToolError(findTool(tools, "request-attachment-download"), {
263
+ attachment_id: "does-not-exist",
264
+ });
265
+ expect(err.error_type).toBe("not_found");
266
+ });
267
+
268
+ test("tag-scoped noteVisible: false on the owning note → not_found, same shape as unknown attachment", async () => {
269
+ const note = await store.createNote("# T\n", { path: "t8" });
270
+ const attachment = await store.addAttachment(note.id, "2026-07-17/y.png", "image/png");
271
+ const provider = new FakeTicketProvider();
272
+ const tools = generateMcpTools(store, {
273
+ attachmentTickets: {
274
+ provider,
275
+ vaultName: "v1",
276
+ urlBase: "https://host/vault/v1",
277
+ noteVisible: async (_n: Note) => false,
278
+ },
279
+ });
280
+ const err = await expectToolError(findTool(tools, "request-attachment-download"), {
281
+ attachment_id: attachment.id,
282
+ });
283
+ expect(err.error_type).toBe("not_found");
284
+ expect(provider.tickets.size).toBe(0);
285
+ });
286
+ });