@openparachute/vault 0.7.3-rc.6 → 0.7.3-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -6625,8 +6625,12 @@ describe("stateless MCP transport", async () => {
6625
6625
  expect(toolNames).not.toContain("merge-tags");
6626
6626
  // Admin tools (vault#376) are hidden too
6627
6627
  expect(toolNames).not.toContain("manage-token");
6628
- // Read tier is exactly 5 tools (doctor added by the re-tier).
6629
- 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);
6630
6634
 
6631
6635
  closeAllStores();
6632
6636
  });
@@ -6907,15 +6911,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6907
6911
  return names;
6908
6912
  }
6909
6913
 
6910
- 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 () => {
6911
6915
  const names = await listToolNames(["vault:read"]);
6912
6916
  expect(new Set(names)).toEqual(
6913
- 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"]),
6914
6918
  );
6915
- expect(names.length).toBe(5);
6919
+ expect(names.length).toBe(6);
6916
6920
  });
6917
6921
 
6918
- 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 () => {
6919
6923
  const names = await listToolNames(["vault:read", "vault:write"]);
6920
6924
  expect(new Set(names)).toEqual(
6921
6925
  new Set([
@@ -6927,9 +6931,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6927
6931
  "create-note",
6928
6932
  "update-note",
6929
6933
  "delete-note",
6934
+ "request-attachment-upload",
6935
+ "request-attachment-download",
6930
6936
  ]),
6931
6937
  );
6932
- expect(names.length).toBe(8);
6938
+ expect(names.length).toBe(10);
6933
6939
  expect(names).not.toContain("manage-token");
6934
6940
  // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6935
6941
  // admin-tier — structure/taxonomy curation, not content authorship.
@@ -6941,7 +6947,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6941
6947
  expect(names).toContain("delete-note");
6942
6948
  });
6943
6949
 
6944
- 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 () => {
6945
6951
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6946
6952
  expect(names).toContain("manage-token");
6947
6953
  expect(names).toContain("prune-schema");
@@ -6950,10 +6956,12 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6950
6956
  expect(names).toContain("delete-tag");
6951
6957
  expect(names).toContain("rename-tag");
6952
6958
  expect(names).toContain("merge-tags");
6953
- 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);
6954
6962
  });
6955
6963
 
6956
- 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 () => {
6957
6965
  const { handleScopedMcp } = await import("./mcp-http.ts");
6958
6966
  const { writeVaultConfig } = await import("./config.ts");
6959
6967
  const { closeAllStores } = await import("./vault-store.ts");
@@ -6986,7 +6994,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6986
6994
  } as any);
6987
6995
  const body = await res.json() as any;
6988
6996
  const names: string[] = body.result.tools.map((t: any) => t.name);
6989
- expect(names.length).toBe(14);
6997
+ expect(names.length).toBe(16);
6990
6998
  expect(names).toContain("manage-token");
6991
6999
  expect(names).toContain("prune-schema");
6992
7000
  expect(names).toContain("doctor");