@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/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/mcp.ts +243 -2
- package/core/src/vault-projection.ts +48 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/routes.ts +12 -91
- package/src/routing.ts +17 -0
- package/src/vault.test.ts +19 -11
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
4444
|
-
//
|
|
4445
|
-
//
|
|
4446
|
-
//
|
|
4447
|
-
//
|
|
4448
|
-
//
|
|
4449
|
-
|
|
4450
|
-
|
|
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.ts — the 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
|
-
//
|
|
6629
|
-
expect(toolNames
|
|
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
|
|
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(
|
|
6919
|
+
expect(names.length).toBe(6);
|
|
6916
6920
|
});
|
|
6917
6921
|
|
|
6918
|
-
test("vault:read + vault:write sees the
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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");
|