@openparachute/vault 0.7.3-rc.1 → 0.7.3-rc.11
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/bytes-provider.ts +65 -0
- 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/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +521 -4
- 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 +712 -11
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +313 -6
- package/core/src/query-warnings.ts +20 -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 +55 -1
- package/core/src/vault-projection.ts +55 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +475 -0
- package/src/attachment-tickets.ts +340 -0
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- 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 +33 -4
- package/src/mcp-tools.ts +61 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +226 -96
- package/src/routing.ts +17 -0
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +28 -2
- package/src/storage.test.ts +200 -1
- 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 +48 -11
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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AttachmentBytesProvider` — the model-lane (Wave 2) byte-access seam.
|
|
3
|
+
* Mirrors `AttachmentTicketProvider` (`./tickets.ts`): a per-door
|
|
4
|
+
* implementation binds this to its own storage (bun: local fs, see
|
|
5
|
+
* `src/attachment-bytes.ts`; a future cloud implementation: ranged R2 GETs
|
|
6
|
+
* through the vault DO). Core stays storage-unaware — the `read-attachment`
|
|
7
|
+
* tool (`core/src/mcp.ts`) calls only through this interface, so bun and a
|
|
8
|
+
* future cloud implementation can never drift on the read contract.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow: stat + a bounded positional read, plus one optional
|
|
11
|
+
* hook for the audio transcript pointer. All POLICY (mime-family dispatch,
|
|
12
|
+
* size caps, range validation, tag-scope) lives in the `read-attachment`
|
|
13
|
+
* tool itself — same division as the ticket seam (`GenerateMcpToolsOpts`'s
|
|
14
|
+
* doc comment in `core/src/mcp.ts`).
|
|
15
|
+
*
|
|
16
|
+
* D10 (attachments-for-agents design): "tools omitted when unwired" — a
|
|
17
|
+
* door that hasn't wired this seam simply never passes `attachmentBytes` to
|
|
18
|
+
* `generateMcpTools`, so `read-attachment` is absent from `tools/list`
|
|
19
|
+
* entirely, not merely erroring on call.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Attachment } from "../types.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 4 MiB raw-bytes cap on the image branch of `read-attachment` (D3): the
|
|
26
|
+
* largest honest budget under Claude's ~5 MB image API limit, with room for
|
|
27
|
+
* base64 blow-up (~5.6 MiB on the wire) and DO-transient headroom on a
|
|
28
|
+
* future cloud implementation. Enforced BEFORE a read is attempted — the
|
|
29
|
+
* tool calls `stat()` first and refuses over-cap without ever calling
|
|
30
|
+
* `readRange()`.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_ATTACHMENT_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export interface AttachmentBytesProvider {
|
|
35
|
+
/**
|
|
36
|
+
* Byte size of the attachment's stored bytes, or `null` when the row
|
|
37
|
+
* exists but its bytes don't — e.g. an `audio_retention` eviction after a
|
|
38
|
+
* successful transcription (`src/transcription-worker.ts` unlinks the
|
|
39
|
+
* file on `until_transcribed` / `never` retention), or any other
|
|
40
|
+
* out-of-band loss. Drives the `attachment_binary_missing` refusal.
|
|
41
|
+
*/
|
|
42
|
+
stat(attachment: Attachment): Promise<{ size: number } | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Read the half-open byte range `[start, end)`. Bounded by construction —
|
|
45
|
+
* callers never ask for the whole file when only a window is needed (the
|
|
46
|
+
* text path); the image path DOES read start-to-end, but only after
|
|
47
|
+
* `stat()` has already confirmed the file is under
|
|
48
|
+
* {@link MAX_ATTACHMENT_IMAGE_BYTES}. `start`/`end` are always within
|
|
49
|
+
* `[0, size]` as reported by a prior `stat()` call on the same attachment
|
|
50
|
+
* — implementations don't need to re-clamp defensively, though doing so
|
|
51
|
+
* costs nothing.
|
|
52
|
+
*/
|
|
53
|
+
readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array>;
|
|
54
|
+
/**
|
|
55
|
+
* OPTIONAL: resolve the sibling transcript note for a completed
|
|
56
|
+
* audio/video transcription (bun: `<attachment-path>.transcript`, see
|
|
57
|
+
* `transcriptPathFor` in `src/transcript-note.ts`). Omitted by a door
|
|
58
|
+
* whose transcript instead lives in the owning note's body (the design's
|
|
59
|
+
* cloud path, D... — no separate sibling note to point at) — the
|
|
60
|
+
* `read-attachment` audio branch falls back to `note_id` alone (the
|
|
61
|
+
* owning note) in that case, per the design's "on cloud the transcript
|
|
62
|
+
* lives in the owning note's body, so `note_id` is the pointer."
|
|
63
|
+
*/
|
|
64
|
+
resolveTranscriptNote?(attachment: Attachment): Promise<{ id: string; path: string } | null>;
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|