@openparachute/vault 0.7.2 → 0.7.3-rc.10
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/README.md +5 -3
- 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/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +62 -13
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ For remote access from Claude Desktop or mobile apps, see [Deployment](#deployme
|
|
|
30
30
|
|
|
31
31
|
A server on port 1940 with:
|
|
32
32
|
|
|
33
|
-
- **MCP** —
|
|
33
|
+
- **MCP** — tools for AI agents (notes, tags, graph, vault info, attachments)
|
|
34
34
|
- **REST API** — Full CRUD for notes, tags, links, full-text search
|
|
35
35
|
- **Wikilink auto-linking** — `[[wikilinks]]` in note content automatically create links in the graph
|
|
36
36
|
- **Obsidian import/export** — Bidirectional interop with Obsidian vaults
|
|
@@ -252,12 +252,14 @@ parachute-vault backup --schedule daily # hourly | daily | weekly | manua
|
|
|
252
252
|
parachute-vault backup status # schedule, last run, destinations, next run
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
## MCP tools (
|
|
255
|
+
## MCP tools (13 core, + attachments + admin)
|
|
256
256
|
|
|
257
257
|
**Notes**: `query-notes` (universal read — single by ID/path, filter, search, graph neighborhood), `create-note` (single or batch), `update-note` (single or batch — content, tags, links, metadata), `delete-note`
|
|
258
|
-
**Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`
|
|
258
|
+
**Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`, `rename-tag`, `merge-tags`
|
|
259
259
|
**Graph**: `find-path` (BFS between two notes)
|
|
260
260
|
**Vault**: `vault-info` (get/update description + stats)
|
|
261
|
+
**Admin**: `prune-schema`, `doctor` (taxonomy/metadata integrity scan), `manage-token`
|
|
262
|
+
**Attachments** (2, present when the door wires an attachment-ticket provider — always true on this server): `request-attachment-upload`, `request-attachment-download` — mint a short-lived, single-use ticket a runtime's shell spends directly at `/vault/{name}/tickets/{id}`; bytes never pass through the tool call. See [`docs/HTTP_API.md`](./docs/HTTP_API.md#attachment-tickets-vault611--wave-1-bun-only).
|
|
261
263
|
|
|
262
264
|
### Vault descriptions
|
|
263
265
|
|
|
@@ -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
|
+
});
|