@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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Attachment tickets — bun's runtime-lane implementation (Wave 1).
3
+ *
4
+ * `InProcessAttachmentTicketProvider` is the process-wide (not per-vault)
5
+ * ticket store — a plain `Map` keyed by the 256-bit ticket id, which is
6
+ * already globally unguessable, so there's no need to shard by vault. A
7
+ * daemon restart drops every outstanding ticket: acceptable given the
8
+ * ≤30-minute TTL (a caller mid-upload across a restart just re-mints), and
9
+ * simpler than persisting single-use, short-lived state to disk. Cloud's
10
+ * future mirror persists to the vault's Durable Object instead — see
11
+ * `AttachmentTicketProvider`'s doc comment (`core/src/attachment/tickets.ts`).
12
+ *
13
+ * `handleTicketSpend` is the bare (no auth beyond the ticket itself) HTTP
14
+ * handler `src/routing.ts` dispatches to for `/vault/<name>/tickets/<id>`
15
+ * — BEFORE `authenticateVaultRequest` runs, deliberately: the ticket IS
16
+ * the credential, and a bearer-less shell (curl) must be able to spend it.
17
+ */
18
+
19
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "fs";
20
+ import { join, normalize } from "path";
21
+ import type { Store } from "../core/src/types.ts";
22
+ import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
+ import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
24
+ import { assetsDir, readVaultConfig } from "./config.ts";
25
+ import { shouldAutoTranscribe } from "./auto-transcribe.ts";
26
+ import { invalidateUsageCache } from "./usage.ts";
27
+
28
+ function json(data: unknown, status = 200): Response {
29
+ return Response.json(data, { status });
30
+ }
31
+
32
+ /**
33
+ * Process-wide, in-memory ticket store (see module doc comment for the
34
+ * restart-drops-tickets tradeoff). `take` deletes unconditionally on
35
+ * lookup — single-use is enforced by the delete itself, not by a separate
36
+ * expiry check; `takeValidTicket` below still checks `expiresAt` after
37
+ * `take` returns, collapsing "expired" into the same null as "spent" /
38
+ * "unknown" so the HTTP layer can give a uniform 404 (no oracle).
39
+ */
40
+ class InProcessAttachmentTicketProvider implements AttachmentTicketProvider {
41
+ private readonly tickets = new Map<string, AttachmentTicket>();
42
+
43
+ async put(ticket: AttachmentTicket): Promise<void> {
44
+ this.tickets.set(ticket.id, ticket);
45
+ }
46
+
47
+ async take(id: string): Promise<AttachmentTicket | null> {
48
+ const ticket = this.tickets.get(id);
49
+ if (!ticket) return null;
50
+ this.tickets.delete(id);
51
+ return ticket;
52
+ }
53
+ }
54
+
55
+ let sharedProvider: InProcessAttachmentTicketProvider | undefined;
56
+
57
+ /** The one shared ticket provider for this process (see class doc comment above). */
58
+ export function getSharedAttachmentTicketProvider(): AttachmentTicketProvider {
59
+ if (!sharedProvider) sharedProvider = new InProcessAttachmentTicketProvider();
60
+ return sharedProvider;
61
+ }
62
+
63
+ /** Test-only: force a fresh provider so ticket state doesn't leak between test files. */
64
+ export function resetSharedAttachmentTicketProviderForTests(): void {
65
+ sharedProvider = undefined;
66
+ }
67
+
68
+ /**
69
+ * Take + validate a ticket against the URL's vault name and the spend
70
+ * route's expected kind (a GET must not spend an upload ticket, etc.).
71
+ * Collapses spent / expired / unknown / wrong-vault / wrong-kind into the
72
+ * SAME `null` — the uniform-404 no-oracle posture the design calls for
73
+ * (an adversary probing an unguessable URL learns nothing about WHY it
74
+ * failed).
75
+ */
76
+ async function takeValidTicket(
77
+ provider: AttachmentTicketProvider,
78
+ id: string,
79
+ vaultName: string,
80
+ kind: AttachmentTicket["kind"],
81
+ ): Promise<AttachmentTicket | null> {
82
+ const ticket = await provider.take(id);
83
+ if (!ticket) return null;
84
+ if (ticket.kind !== kind || ticket.vaultName !== vaultName || ticket.expiresAt < Date.now()) {
85
+ return null;
86
+ }
87
+ return ticket;
88
+ }
89
+
90
+ function ticketNotFound(): Response {
91
+ return json(
92
+ {
93
+ error: "Not found",
94
+ error_type: "not_found",
95
+ how_to:
96
+ "tickets are single-use and short-lived — mint a new one with request-attachment-upload / request-attachment-download",
97
+ },
98
+ 404,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Dispatch a ticket spend request for `/vault/<name>/tickets/<id>`. PUT or
104
+ * POST spends an upload ticket (the mint tool advertises PUT; POST is
105
+ * accepted too for runtimes/proxies that rewrite methods); GET spends a
106
+ * download ticket. Any other method, or a method that doesn't match the
107
+ * ticket's own kind, collapses to the same 404 `takeValidTicket` uses —
108
+ * no oracle on which kind a given id was minted for.
109
+ */
110
+ export async function handleTicketSpend(
111
+ req: Request,
112
+ ticketId: string,
113
+ vaultName: string,
114
+ store: Store,
115
+ ): Promise<Response> {
116
+ const provider = getSharedAttachmentTicketProvider();
117
+
118
+ if (req.method === "PUT" || req.method === "POST") {
119
+ return handleUploadSpend(req, ticketId, vaultName, store, provider);
120
+ }
121
+ if (req.method === "GET") {
122
+ return handleDownloadSpend(ticketId, vaultName, store, provider);
123
+ }
124
+ return ticketNotFound();
125
+ }
126
+
127
+ async function handleUploadSpend(
128
+ req: Request,
129
+ ticketId: string,
130
+ vaultName: string,
131
+ store: Store,
132
+ provider: AttachmentTicketProvider,
133
+ ): Promise<Response> {
134
+ const ticket = await takeValidTicket(provider, ticketId, vaultName, "upload");
135
+ if (!ticket || !ticket.noteId || !ticket.filename || !ticket.mimeType) return ticketNotFound();
136
+
137
+ // Declared size at mint IS the enforced cap here — tighter than the flat
138
+ // REST ceiling, per the ticket's own promise (`max_bytes` in the mint
139
+ // response). A ticket somehow minted with no declared size fails closed
140
+ // (0 bytes allowed) rather than falling back to the flat 100 MiB cap.
141
+ const declaredMax = ticket.sizeBytes ?? 0;
142
+
143
+ const contentLengthHeader = req.headers.get("content-length");
144
+ if (contentLengthHeader) {
145
+ const declared = Number(contentLengthHeader);
146
+ if (Number.isFinite(declared) && declared > declaredMax) {
147
+ return json(
148
+ {
149
+ error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
150
+ error_type: "file_too_large",
151
+ limit: declaredMax,
152
+ got: declared,
153
+ how_to: "mint a new ticket with an accurate size_bytes",
154
+ },
155
+ 413,
156
+ );
157
+ }
158
+ }
159
+
160
+ const buffer = Buffer.from(await req.arrayBuffer());
161
+ if (buffer.length > declaredMax) {
162
+ return json(
163
+ {
164
+ error: `Upload exceeds the ticket's declared size (${declaredMax} bytes)`,
165
+ error_type: "file_too_large",
166
+ limit: declaredMax,
167
+ got: buffer.length,
168
+ how_to: "mint a new ticket with an accurate size_bytes",
169
+ },
170
+ 413,
171
+ );
172
+ }
173
+
174
+ // Same on-disk write discipline as REST's POST /storage/upload
175
+ // (src/routes.ts) — server-generated path, never caller-controlled.
176
+ const ext = sanitizeAttachmentExtension(ticket.filename);
177
+ const date = new Date().toISOString().split("T")[0]!;
178
+ const dir = join(assetsDir(vaultName), date);
179
+ mkdirSync(dir, { recursive: true });
180
+ const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
181
+ writeFileSync(join(dir, filename), buffer);
182
+ const relativePath = `${date}/${filename}`;
183
+
184
+ invalidateUsageCache(vaultName);
185
+
186
+ // Mirrors the REST POST /notes/:id/attachments transcribe decision
187
+ // exactly (src/routes.ts): explicit opt-in (here, `transcribe: true` at
188
+ // mint) wins; otherwise infer from mime-type + the owning vault's
189
+ // auto-transcribe toggle.
190
+ const explicitOptIn = ticket.transcribe === true;
191
+ const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
192
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
193
+ const attMeta: Record<string, unknown> = {
194
+ original_name: ticket.filename,
195
+ size: buffer.length,
196
+ };
197
+ if (explicitOptIn || autoOptIn) {
198
+ attMeta.transcribe_status = "pending";
199
+ attMeta.transcribe_requested_at = new Date().toISOString();
200
+ attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
201
+ }
202
+
203
+ const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
204
+
205
+ if (explicitOptIn) {
206
+ const note = await store.getNote(ticket.noteId);
207
+ if (note) {
208
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
209
+ if (noteMeta.transcribe_stub !== true) {
210
+ await store.updateNote(ticket.noteId, {
211
+ metadata: { ...noteMeta, transcribe_stub: true },
212
+ skipUpdatedAt: true,
213
+ });
214
+ }
215
+ }
216
+ }
217
+
218
+ // Ticket mint + register in one shot (vs REST's two-step upload-then-
219
+ // attach) — the spend response is the attachment row itself, so the
220
+ // runtime can hand the id straight back to the model.
221
+ return json(attachment, 201);
222
+ }
223
+
224
+ async function handleDownloadSpend(
225
+ ticketId: string,
226
+ vaultName: string,
227
+ store: Store,
228
+ provider: AttachmentTicketProvider,
229
+ ): Promise<Response> {
230
+ const ticket = await takeValidTicket(provider, ticketId, vaultName, "download");
231
+ if (!ticket || !ticket.attachmentId) return ticketNotFound();
232
+
233
+ // The row can vanish between mint and spend (rare — a delete racing a
234
+ // slow curl); fold that into the same uniform 404 rather than a 500.
235
+ const attachment = await store.getAttachment(ticket.attachmentId);
236
+ if (!attachment) return ticketNotFound();
237
+
238
+ const assets = assetsDir(vaultName);
239
+ const filePath = normalize(join(assets, attachment.path));
240
+ if (!filePath.startsWith(normalize(assets)) || !existsSync(filePath)) {
241
+ return json(
242
+ {
243
+ error: "Not found",
244
+ error_type: "attachment_binary_missing",
245
+ how_to: "the attachment row exists but its bytes are gone — this ticket can't be re-spent",
246
+ },
247
+ 404,
248
+ );
249
+ }
250
+
251
+ const stat = statSync(filePath);
252
+ const fileBuffer = readFileSync(filePath);
253
+ return new Response(fileBuffer, {
254
+ status: 200,
255
+ headers: {
256
+ "Content-Type": attachment.mimeType || "application/octet-stream",
257
+ "Content-Length": String(stat.size),
258
+ // Same defense-in-depth as the existing GET /storage/<path> byte-serve
259
+ // (src/routes.ts) — never let a browser MIME-sniff a stored asset into
260
+ // an active type.
261
+ "X-Content-Type-Options": "nosniff",
262
+ },
263
+ });
264
+ }
package/src/mcp-http.ts CHANGED
@@ -109,7 +109,7 @@ export async function handleScopedMcp(
109
109
  const instruction = await getServerInstruction(vaultName, auth);
110
110
  return handleMcp(
111
111
  req,
112
- () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null),
112
+ () => generateScopedMcpTools(vaultName, auth, callerBearer ?? null, req),
113
113
  `parachute-vault/${vaultName}`,
114
114
  vaultName,
115
115
  auth,
@@ -229,6 +229,8 @@ export async function handleMcp(
229
229
  tag?: string;
230
230
  cycle?: unknown;
231
231
  referencing_tags?: unknown;
232
+ /** Attachment-tickets design (§2c "errors as JIT docs") — a short, imperative next-step, distinct from the older free-form `hint`. */
233
+ how_to?: string;
232
234
  };
233
235
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
234
236
  // values that are structurally invalid rather than merely "no
@@ -399,6 +401,16 @@ export async function handleMcp(
399
401
  error_type: e.error_type,
400
402
  field: e.field,
401
403
  hint: e.hint,
404
+ // Forwarded when present (undefined keys are fine — JSON-RPC
405
+ // `data` just omits them) so a `structuredError()` call site that
406
+ // stamps extra fields — `limit`/`got`/`extension` (size/type
407
+ // refusals) or `how_to` (attachment-tickets' JIT-docs field, §2c)
408
+ // — isn't silently truncated down to error_type/field/hint by
409
+ // this backstop.
410
+ ...(e.limit !== undefined ? { limit: e.limit } : {}),
411
+ ...(e.got !== undefined ? { got: e.got } : {}),
412
+ ...(e.extension !== undefined ? { extension: e.extension } : {}),
413
+ ...(e.how_to !== undefined ? { how_to: e.how_to } : {}),
402
414
  });
403
415
  }
404
416
  return {
package/src/mcp-tools.ts CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  import { chooseHubOrigin, mintHubJwt, revokeHubJwt } from "./mcp-install.ts";
44
44
  import { looksLikeJwt } from "./hub-jwt.ts";
45
45
  import { readGlobalConfig, DEFAULT_PORT } from "./config.ts";
46
+ import { getBaseUrl } from "./oauth-discovery.ts";
47
+ import { getSharedAttachmentTicketProvider } from "./attachment-tickets.ts";
46
48
 
47
49
  /**
48
50
  * Filter a vault projection to entries an in-scope tag contributes to.
@@ -110,6 +112,10 @@ export async function getServerInstruction(
110
112
  description: config?.description ?? null,
111
113
  projection,
112
114
  coordinates: resolveVaultCoordinates(),
115
+ // Bun always wires an in-process AttachmentTicketProvider (see
116
+ // `generateScopedMcpTools` below) — the connect-time brief can
117
+ // unconditionally teach the ticket tools on this door.
118
+ attachments: { ticketsEnabled: true },
113
119
  });
114
120
  }
115
121
 
@@ -143,6 +149,16 @@ export function generateScopedMcpTools(
143
149
  vaultName: string,
144
150
  auth?: AuthResult,
145
151
  callerBearer?: string | null,
152
+ /**
153
+ * The incoming MCP request, when available (omitted by the many
154
+ * test-only call sites that construct tools without a live request).
155
+ * Threaded through ONLY so the attachment-ticket tools can resolve a
156
+ * request-accurate `urlBase` (honoring `X-Forwarded-Host`/proto, same as
157
+ * `getBaseUrl`) — falls back to `resolveVaultCoordinates()`'s configured/
158
+ * expose-state origin when omitted, so ticket tools are still present
159
+ * (just less precisely origined) in that case.
160
+ */
161
+ req?: Request,
146
162
  ): McpToolDef[] {
147
163
  const store = getVaultStore(vaultName);
148
164
 
@@ -225,20 +241,39 @@ export function generateScopedMcpTools(
225
241
  ? (info) => logStrictBypass(info)
226
242
  : undefined;
227
243
 
228
- const tools = generateMcpTools(
229
- store,
230
- expandVisibility || nearTraversable || ifExistsVisible || aggregateVisibility || writeContext || strictBypass
231
- ? {
232
- ...(expandVisibility ? { expandVisibility } : {}),
233
- ...(nearTraversable ? { nearTraversable } : {}),
234
- ...(ifExistsVisible ? { ifExistsVisible } : {}),
235
- ...(aggregateVisibility ? { aggregateVisibility } : {}),
236
- ...(writeContext ? { writeContext } : {}),
237
- ...(strictBypass ? { strictBypass } : {}),
238
- ...(onStrictBypass ? { onStrictBypass } : {}),
239
- }
240
- : undefined,
241
- );
244
+ // Attachment tickets (Wave 1): bun always wires the in-process provider
245
+ // (see src/attachment-tickets.ts) — unlike the tag-scope predicates
246
+ // above, this isn't conditional on `scoped` because the tools should be
247
+ // listed for every session, scoped or not. Tag-scope confidentiality is
248
+ // still enforced (`noteVisible`, awaited inline inside the ticket tools'
249
+ // own async execute see its doc comment in generateMcpTools for why
250
+ // this doesn't need the shared allowedHolder machinery the OTHER
251
+ // predicates above do).
252
+ const ticketNoteVisible = scoped
253
+ ? async (note: Note) => {
254
+ const allowed = await expandTokenTagScope(store, rawTags);
255
+ return noteWithinTagScope(note, allowed, rawTags);
256
+ }
257
+ : undefined;
258
+ const ticketUrlBase = req
259
+ ? `${getBaseUrl(req).replace(/\/$/, "")}/vault/${vaultName}`
260
+ : `${resolveVaultCoordinates().hubOrigin.replace(/\/$/, "")}/vault/${vaultName}`;
261
+
262
+ const tools = generateMcpTools(store, {
263
+ ...(expandVisibility ? { expandVisibility } : {}),
264
+ ...(nearTraversable ? { nearTraversable } : {}),
265
+ ...(ifExistsVisible ? { ifExistsVisible } : {}),
266
+ ...(aggregateVisibility ? { aggregateVisibility } : {}),
267
+ ...(writeContext ? { writeContext } : {}),
268
+ ...(strictBypass ? { strictBypass } : {}),
269
+ ...(onStrictBypass ? { onStrictBypass } : {}),
270
+ attachmentTickets: {
271
+ provider: getSharedAttachmentTicketProvider(),
272
+ vaultName,
273
+ urlBase: ticketUrlBase,
274
+ ...(ticketNoteVisible ? { noteVisible: ticketNoteVisible } : {}),
275
+ },
276
+ });
242
277
 
243
278
  overrideVaultInfo(tools, vaultName, auth);
244
279
  applyTagDependencyGuards(tools, vaultName);
package/src/routes.ts CHANGED
@@ -42,6 +42,10 @@ import {
42
42
  type ContentRange,
43
43
  } from "../core/src/content-range.ts";
44
44
  import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
45
+ import {
46
+ BLOCKED_ATTACHMENT_EXTENSIONS,
47
+ ATTACHMENT_MIME_TYPES,
48
+ } from "../core/src/attachment/policy.ts";
45
49
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
46
50
  import { logStrictBypass } from "./scopes.ts";
47
51
  import * as linkOps from "../core/src/links.ts";
@@ -4440,97 +4444,14 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4440
4444
  */
4441
4445
  export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
4442
4446
 
4443
- // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
4444
- // arbitrary files ebooks, office docs, datasets, archives, binaries — so we
4445
- // accept ANY upload EXCEPT the handful of types a browser can execute as
4446
- // active content in our origin when served back from /storage/. (The prior
4447
- // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
4448
- // not allowed".)
4449
- //
4450
- // BLOCKED same-origin-XSS / active-content set:
4451
- // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
4452
- // .svg XML image — embeds <script>
4453
- // .xml can carry XSLT / be parsed as XHTML
4454
- // .js/.mjs/.cjs JavaScript
4455
- // .css style-injection / UI-redress vector
4456
- //
4457
- // Two independent guards keep every STORED file inert when served:
4458
- // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
4459
- // every other extension serves as application/octet-stream — a download,
4460
- // never rendered.
4461
- // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
4462
- // a browser can't sniff an octet-stream body into an executable type.
4463
- // The blocklist is belt-and-suspenders on top of those: even if a future MIME
4464
- // entry or an upstream proxy weakened (1) or (2), these extensions still never
4465
- // land on disk. If a future use case needs SVG, sanitize on read (strip
4466
- // <script>/<foreignObject>) and revisit.
4467
- const BLOCKED_EXTENSIONS = new Set([
4468
- ".html", ".htm", ".xhtml", ".shtml", ".xht",
4469
- ".svg",
4470
- ".xml",
4471
- ".js", ".mjs", ".cjs",
4472
- ".css",
4473
- ]);
4474
-
4475
- // Explicit MIME types for the commonly-previewed formats. Anything accepted
4476
- // but absent here serves as application/octet-stream — a download, never
4477
- // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
4478
- // these map to an active type (text/html, image/svg+xml), so a served asset
4479
- // can't execute script; `nosniff` on the GET response makes that ironclad.
4480
- //
4481
- // INVARIANT: never add an entry that maps to a browser-active type —
4482
- // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
4483
- // application/wasm, text/css. Doing so re-enables same-origin execution for
4484
- // that extension (and would mean it must also join BLOCKED_EXTENSIONS).
4485
- const MIME_TYPES: Record<string, string> = {
4486
- // Audio
4487
- ".wav": "audio/wav",
4488
- ".mp3": "audio/mpeg",
4489
- ".m4a": "audio/mp4",
4490
- ".ogg": "audio/ogg",
4491
- ".oga": "audio/ogg",
4492
- ".opus": "audio/opus",
4493
- ".aac": "audio/aac",
4494
- ".flac": "audio/flac",
4495
- ".webm": "audio/webm",
4496
- // Image
4497
- ".png": "image/png",
4498
- ".jpg": "image/jpeg",
4499
- ".jpeg": "image/jpeg",
4500
- ".gif": "image/gif",
4501
- ".webp": "image/webp",
4502
- ".bmp": "image/bmp",
4503
- ".tiff": "image/tiff",
4504
- ".tif": "image/tiff",
4505
- ".heic": "image/heic",
4506
- ".heif": "image/heif",
4507
- ".avif": "image/avif",
4508
- // Video
4509
- ".mp4": "video/mp4",
4510
- ".m4v": "video/x-m4v",
4511
- ".mov": "video/quicktime",
4512
- // Documents / ebooks / data
4513
- ".pdf": "application/pdf",
4514
- ".epub": "application/epub+zip",
4515
- ".mobi": "application/x-mobipocket-ebook",
4516
- ".txt": "text/plain; charset=utf-8",
4517
- ".md": "text/markdown; charset=utf-8",
4518
- ".markdown": "text/markdown; charset=utf-8",
4519
- ".rtf": "application/rtf",
4520
- ".csv": "text/csv; charset=utf-8",
4521
- ".tsv": "text/tab-separated-values; charset=utf-8",
4522
- ".json": "application/json; charset=utf-8",
4523
- ".doc": "application/msword",
4524
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4525
- ".ppt": "application/vnd.ms-powerpoint",
4526
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4527
- ".xls": "application/vnd.ms-excel",
4528
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4529
- ".odt": "application/vnd.oasis.opendocument.text",
4530
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
4531
- ".odp": "application/vnd.oasis.opendocument.presentation",
4532
- ".zip": "application/zip",
4533
- };
4447
+ // Storage upload policy (extension blocklist + MIME lookup) now lives in
4448
+ // core/src/attachment/policy.tsthe canonical source shared with the
4449
+ // attachment-ticket mint/spend path (vault attachment-tickets design,
4450
+ // "shared BLOCKED_EXTENSIONS"), so a blocked extension can never diverge
4451
+ // between REST and tickets. See that module's doc comment for the full
4452
+ // same-origin-XSS rationale and the MIME curation invariant.
4453
+ const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
4454
+ const MIME_TYPES = ATTACHMENT_MIME_TYPES;
4534
4455
 
4535
4456
  export async function handleStorage(
4536
4457
  req: Request,
package/src/routing.ts CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  } from "./mirror-routes.ts";
104
104
  import { getMirrorManager } from "./mirror-registry.ts";
105
105
  import { buildUsageReport } from "./usage.ts";
106
+ import { handleTicketSpend } from "./attachment-tickets.ts";
106
107
 
107
108
  /**
108
109
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -490,6 +491,22 @@ export async function route(
490
491
  return handleAuthorizationServer(req, vaultName);
491
492
  }
492
493
 
494
+ // Attachment ticket spend (attachment-tickets design, Wave 1) — no auth
495
+ // beyond the ticket itself; the ticket IS the credential (single-use,
496
+ // short TTL, scoped to exactly one upload slot or one attachment's
497
+ // bytes). Deliberately placed BEFORE `authenticateVaultRequest` below —
498
+ // a bearer-less runtime (a bare `curl`) must be able to spend a ticket
499
+ // without ever holding this vault's API key. The path segment
500
+ // (`/tickets/<id>`) is outside the authed `/api` tree and outside
501
+ // `/mcp` on purpose — see `request-attachment-upload`/`-download`
502
+ // (core/src/mcp.ts) for where tickets are minted.
503
+ const vaultTicketMatch = subpath.match(/^\/tickets\/([^/]+)$/);
504
+ if (vaultTicketMatch) {
505
+ const ticketId = decodeURIComponent(vaultTicketMatch[1]!);
506
+ const store = getVaultStore(vaultName);
507
+ return handleTicketSpend(req, ticketId, vaultName, store);
508
+ }
509
+
493
510
  // ---------------------------------------------------------------------
494
511
  // Authenticated surface
495
512
  // ---------------------------------------------------------------------
package/src/vault.test.ts CHANGED
@@ -2139,6 +2139,22 @@ describe("HTTP /notes", async () => {
2139
2139
  expect(body[0]).not.toHaveProperty("content");
2140
2140
  expect(body[0]).toHaveProperty("byteSize");
2141
2141
  expect(body[0]).toHaveProperty("preview");
2142
+ expect(body[0]).toHaveProperty("displayTitle");
2143
+ });
2144
+
2145
+ // Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
2146
+ test("GET /notes lean shape carries the computed displayTitle", async () => {
2147
+ await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
2148
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2149
+ const body = await res.json() as any[];
2150
+ expect(body[0].displayTitle).toBe("Meeting Notes");
2151
+ });
2152
+
2153
+ test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
2154
+ await store.createNote("", { path: "empty" });
2155
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2156
+ const body = await res.json() as any[];
2157
+ expect(body[0].displayTitle).toBeNull();
2142
2158
  });
2143
2159
 
2144
2160
  test("GET /notes?include_content=true returns full notes", async () => {
@@ -6609,8 +6625,12 @@ describe("stateless MCP transport", async () => {
6609
6625
  expect(toolNames).not.toContain("merge-tags");
6610
6626
  // Admin tools (vault#376) are hidden too
6611
6627
  expect(toolNames).not.toContain("manage-token");
6612
- // Read tier is exactly 5 tools (doctor added by the re-tier).
6613
- expect(toolNames.length).toBe(5);
6628
+ // request-attachment-download is read-tier (upload is write-tier).
6629
+ expect(toolNames).toContain("request-attachment-download");
6630
+ expect(toolNames).not.toContain("request-attachment-upload");
6631
+ // Read tier is exactly 6 tools (doctor added by the re-tier;
6632
+ // request-attachment-download added by the attachment-tickets design).
6633
+ expect(toolNames.length).toBe(6);
6614
6634
 
6615
6635
  closeAllStores();
6616
6636
  });
@@ -6891,15 +6911,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6891
6911
  return names;
6892
6912
  }
6893
6913
 
6894
- test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
6914
+ test("vault:read sees exactly the 6 read tools (doctor moved admin → read; request-attachment-download is read-tier)", async () => {
6895
6915
  const names = await listToolNames(["vault:read"]);
6896
6916
  expect(new Set(names)).toEqual(
6897
- new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
6917
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor", "request-attachment-download"]),
6898
6918
  );
6899
- expect(names.length).toBe(5);
6919
+ expect(names.length).toBe(6);
6900
6920
  });
6901
6921
 
6902
- test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
6922
+ test("vault:read + vault:write sees the 10 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
6903
6923
  const names = await listToolNames(["vault:read", "vault:write"]);
6904
6924
  expect(new Set(names)).toEqual(
6905
6925
  new Set([
@@ -6911,9 +6931,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6911
6931
  "create-note",
6912
6932
  "update-note",
6913
6933
  "delete-note",
6934
+ "request-attachment-upload",
6935
+ "request-attachment-download",
6914
6936
  ]),
6915
6937
  );
6916
- expect(names.length).toBe(8);
6938
+ expect(names.length).toBe(10);
6917
6939
  expect(names).not.toContain("manage-token");
6918
6940
  // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6919
6941
  // admin-tier — structure/taxonomy curation, not content authorship.
@@ -6925,7 +6947,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6925
6947
  expect(names).toContain("delete-note");
6926
6948
  });
6927
6949
 
6928
- test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
6950
+ test("vault:admin sees all 16 tools including manage-token + prune-schema + the tag-schema tools + both attachment-ticket tools", async () => {
6929
6951
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6930
6952
  expect(names).toContain("manage-token");
6931
6953
  expect(names).toContain("prune-schema");
@@ -6934,10 +6956,12 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6934
6956
  expect(names).toContain("delete-tag");
6935
6957
  expect(names).toContain("rename-tag");
6936
6958
  expect(names).toContain("merge-tags");
6937
- expect(names.length).toBe(14);
6959
+ expect(names).toContain("request-attachment-upload");
6960
+ expect(names).toContain("request-attachment-download");
6961
+ expect(names.length).toBe(16);
6938
6962
  });
6939
6963
 
6940
- test("legacy-derived full token sees all 14 tools (back-compat)", async () => {
6964
+ test("legacy-derived full token sees all 16 tools (back-compat)", async () => {
6941
6965
  const { handleScopedMcp } = await import("./mcp-http.ts");
6942
6966
  const { writeVaultConfig } = await import("./config.ts");
6943
6967
  const { closeAllStores } = await import("./vault-store.ts");
@@ -6970,7 +6994,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6970
6994
  } as any);
6971
6995
  const body = await res.json() as any;
6972
6996
  const names: string[] = body.result.tools.map((t: any) => t.name);
6973
- expect(names.length).toBe(14);
6997
+ expect(names.length).toBe(16);
6974
6998
  expect(names).toContain("manage-token");
6975
6999
  expect(names).toContain("prune-schema");
6976
7000
  expect(names).toContain("doctor");