@openparachute/vault 0.7.3-rc.5 → 0.7.3-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/display-title.test.ts +149 -0
- package/core/src/mcp.ts +245 -4
- package/core/src/notes.ts +111 -4
- 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 +107 -0
- package/core/src/types.ts +6 -0
- 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 +35 -11
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
escapeFtsToken,
|
|
5
5
|
buildLiteralSearchQuery,
|
|
6
6
|
isValidSearchMode,
|
|
7
|
+
extractLiteralBoostTerms,
|
|
7
8
|
} from "./search-query.js";
|
|
8
9
|
|
|
9
10
|
describe("escapeFtsToken", () => {
|
|
@@ -147,3 +148,44 @@ describe("isValidSearchMode / SEARCH_MODES", () => {
|
|
|
147
148
|
expect(isValidSearchMode(["literal"])).toBe(false);
|
|
148
149
|
});
|
|
149
150
|
});
|
|
151
|
+
|
|
152
|
+
describe("extractLiteralBoostTerms (search title-boost input, title axis)", () => {
|
|
153
|
+
it("lowercases and whitespace-splits", () => {
|
|
154
|
+
expect(extractLiteralBoostTerms("Budget Review")).toEqual(["budget", "review"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("collapses whitespace runs", () => {
|
|
158
|
+
expect(extractLiteralBoostTerms("widgets gadgets")).toEqual(["widgets", "gadgets"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("trims leading/trailing whitespace", () => {
|
|
162
|
+
expect(extractLiteralBoostTerms(" widgets ")).toEqual(["widgets"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("strips literal quote characters a caller typed, unlike buildLiteralSearchQuery", () => {
|
|
166
|
+
// buildLiteralSearchQuery treats a manually-typed `"` as ordinary
|
|
167
|
+
// content (escaped into the FTS phrase); the boost-term extractor
|
|
168
|
+
// strips it instead, since a title-string comparison shouldn't
|
|
169
|
+
// require the title to contain a literal quote mark.
|
|
170
|
+
expect(extractLiteralBoostTerms(`"eleven-day capping delay"`)).toEqual([
|
|
171
|
+
"eleven-day",
|
|
172
|
+
"capping",
|
|
173
|
+
"delay",
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
|
|
178
|
+
expect(extractLiteralBoostTerms("eleven-day")).toEqual(["eleven-day"]);
|
|
179
|
+
expect(extractLiteralBoostTerms("18.6")).toEqual(["18.6"]);
|
|
180
|
+
expect(extractLiteralBoostTerms("didn't")).toEqual(["didn't"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns an empty array for blank/whitespace-only input", () => {
|
|
184
|
+
expect(extractLiteralBoostTerms("")).toEqual([]);
|
|
185
|
+
expect(extractLiteralBoostTerms(" ")).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("sanitizes control characters as separators, matching buildLiteralSearchQuery", () => {
|
|
189
|
+
expect(extractLiteralBoostTerms("hello\x00world")).toEqual(["hello", "world"]);
|
|
190
|
+
});
|
|
191
|
+
});
|
package/core/src/search-query.ts
CHANGED
|
@@ -153,3 +153,30 @@ export function buildLiteralSearchQuery(raw: string): LiteralSearchQuery {
|
|
|
153
153
|
const tokens = cleaned.trim().split(/\s+/).filter(Boolean);
|
|
154
154
|
return { query: tokens.map(escapeFtsToken).join(" "), isEmpty: false };
|
|
155
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract lowercase whitespace-delimited terms from raw literal-mode search
|
|
159
|
+
* text, for the search title-boost post-rank (title axis, ratified
|
|
160
|
+
* 2026-07-17) — NOT used to build the FTS5 MATCH query itself (that's
|
|
161
|
+
* `buildLiteralSearchQuery`, above). Sanitizes control characters the same
|
|
162
|
+
* way, plus strips any literal `"` a caller typed (kept as ordinary content
|
|
163
|
+
* by `buildLiteralSearchQuery`, but a display-title substring check
|
|
164
|
+
* shouldn't require the title to contain a literal quote mark).
|
|
165
|
+
*
|
|
166
|
+
* This is a heuristic re-rank signal, not a second index — it deliberately
|
|
167
|
+
* does not attempt FTS5-tokenizer parity (stemming, unicode normalization).
|
|
168
|
+
* It only needs to answer "does the note's first line plausibly contain the
|
|
169
|
+
* query," not reproduce FTS5's match semantics exactly. Restricted to
|
|
170
|
+
* literal-mode callers by convention (see `core/src/notes.ts` `searchNotes`)
|
|
171
|
+
* — `search_mode: "advanced"` text carries FTS5 boolean/column/prefix
|
|
172
|
+
* operators (`AND`, `path:`, `foo*`) that would leak into the term list as
|
|
173
|
+
* false "content" if tokenized the same naive way.
|
|
174
|
+
*/
|
|
175
|
+
export function extractLiteralBoostTerms(raw: string): string[] {
|
|
176
|
+
const cleaned = raw.replace(CONTROL_CHARS, " ").toLowerCase();
|
|
177
|
+
return cleaned
|
|
178
|
+
.trim()
|
|
179
|
+
.split(/\s+/)
|
|
180
|
+
.map((t) => t.replace(/^"+|"+$/g, ""))
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search title-boost (title axis, ratified 2026-07-17): literal-mode
|
|
3
|
+
* search results whose `displayTitle` (first non-empty content line, NOT
|
|
4
|
+
* `path`) contains every query term are post-ranked ahead of body-only
|
|
5
|
+
* matches. No-migration — an in-memory re-rank of the already-fetched
|
|
6
|
+
* page, not a `notes_fts` schema change (see `applySearchTitleBoost` /
|
|
7
|
+
* `boostTitleMatches` in `notes.ts`).
|
|
8
|
+
*
|
|
9
|
+
* This is a DIFFERENT axis from the existing `path`-weighted bm25 ranking
|
|
10
|
+
* covered in `search-fts-v25.test.ts` — a note's title here is its first
|
|
11
|
+
* CONTENT line, not its `path`.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { SqliteStore } from "./store.js";
|
|
16
|
+
|
|
17
|
+
let store: SqliteStore;
|
|
18
|
+
let db: Database;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
db = new Database(":memory:");
|
|
22
|
+
store = new SqliteStore(db);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("search title-boost", () => {
|
|
26
|
+
it("ranks a first-line (title) match above a body-only match", async () => {
|
|
27
|
+
// Body-only match: "budget" appears only deep in the body, first line
|
|
28
|
+
// is unrelated.
|
|
29
|
+
await store.createNote(
|
|
30
|
+
"Weekly Standup\nlots of unrelated notes here, but somewhere we discuss the budget in passing",
|
|
31
|
+
{ path: "standup-notes" },
|
|
32
|
+
);
|
|
33
|
+
// First-line (title) match: "Budget" is the first line itself.
|
|
34
|
+
await store.createNote("Budget Review\nQ3 numbers", { path: "budget-review" });
|
|
35
|
+
|
|
36
|
+
const hits = await store.searchNotes("budget");
|
|
37
|
+
expect(hits.length).toBeGreaterThanOrEqual(2);
|
|
38
|
+
expect(hits[0].path).toBe("budget-review");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("requires ALL query terms to be present in the first line to boost (a partial title match doesn't qualify)", async () => {
|
|
42
|
+
// "budget" is in the title but "review" is only in the body — a
|
|
43
|
+
// PARTIAL title match, which stays in the body-only tier.
|
|
44
|
+
await store.createNote("Budget notes\nsee the quarterly review for details", { path: "partial" });
|
|
45
|
+
// Both "budget" and "review" are in the title — a FULL title match.
|
|
46
|
+
await store.createNote("Budget Review\nQ3 numbers", { path: "full-match" });
|
|
47
|
+
|
|
48
|
+
const hits = await store.searchNotes("budget review");
|
|
49
|
+
expect(hits.map((n) => n.path)).toEqual(["full-match", "partial"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ties (both notes in the same tier) keep the existing relevance order", async () => {
|
|
53
|
+
// Two title-matching notes plus one body-only note. Comparing literal
|
|
54
|
+
// mode (boosted) against advanced mode with the same plain-word query
|
|
55
|
+
// (identical FTS5 syntax, so identical underlying bm25 order, but no
|
|
56
|
+
// boost pass) isolates the boost's effect: within the title-match
|
|
57
|
+
// tier, the boost must preserve whatever relative order the
|
|
58
|
+
// underlying relevance ranking already gave — a stable sort, not a
|
|
59
|
+
// fresh comparator that could reshuffle ties.
|
|
60
|
+
await store.createNote("Project Widget\nfirst, mentions widget again for relevance", { path: "w1" });
|
|
61
|
+
await store.createNote("Project Widget\nsecond", { path: "w2" });
|
|
62
|
+
await store.createNote("Unrelated\nbody-only mention of widget project", { path: "body-only" });
|
|
63
|
+
|
|
64
|
+
const boosted = await store.searchNotes("widget project");
|
|
65
|
+
const rawOrder = await store.searchNotes("widget project", { mode: "advanced" });
|
|
66
|
+
|
|
67
|
+
const titleTierBoosted = boosted.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
|
|
68
|
+
const titleTierRaw = rawOrder.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
|
|
69
|
+
expect(titleTierBoosted).toEqual(titleTierRaw);
|
|
70
|
+
// The title-match tier precedes the body-only note in the boosted result.
|
|
71
|
+
expect(boosted.map((n) => n.path).indexOf("body-only")).toBeGreaterThan(
|
|
72
|
+
Math.max(...["w1", "w2"].map((p) => boosted.map((n) => n.path).indexOf(p))),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not disturb ordering when an explicit sort is requested", async () => {
|
|
77
|
+
await store.createNote("Zebra body-only mention of gadget", { path: "z" });
|
|
78
|
+
await store.createNote("Gadget Launch\nfirst line title match", { path: "a" });
|
|
79
|
+
|
|
80
|
+
const asc = await store.searchNotes("gadget", { sort: "asc" });
|
|
81
|
+
// created_at ASC means "z" (created first) comes before "a" — the
|
|
82
|
+
// title-boost must NOT override an explicit sort.
|
|
83
|
+
expect(asc.map((n) => n.path)).toEqual(["z", "a"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not apply the boost under search_mode: advanced (raw FTS5 syntax, not naively tokenizable)", async () => {
|
|
87
|
+
await store.createNote("Body only mentions gadget in passing text here", { path: "body-only" });
|
|
88
|
+
await store.createNote("Gadget\nfirst line title match", { path: "title-match" });
|
|
89
|
+
|
|
90
|
+
const advanced = await store.searchNotes("gadget", { mode: "advanced" });
|
|
91
|
+
const literal = await store.searchNotes("gadget");
|
|
92
|
+
|
|
93
|
+
// Literal mode boosts the title match to the front.
|
|
94
|
+
expect(literal[0].path).toBe("title-match");
|
|
95
|
+
// Advanced mode is unaffected by the boost — order comes from bm25
|
|
96
|
+
// relevance alone (title-match still likely ranks first via the
|
|
97
|
+
// EXISTING path-weighted bm25, but that's a different mechanism; the
|
|
98
|
+
// test here just confirms advanced mode doesn't throw and returns
|
|
99
|
+
// both notes, not that ordering matches/mismatches literal mode).
|
|
100
|
+
expect(advanced.map((n) => n.path).sort()).toEqual(["body-only", "title-match"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a query with no results is unaffected (empty array in, empty array out)", async () => {
|
|
104
|
+
const hits = await store.searchNotes("nonexistent_term_xyz");
|
|
105
|
+
expect(hits).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
package/core/src/types.ts
CHANGED
|
@@ -400,6 +400,12 @@ export interface NoteIndex {
|
|
|
400
400
|
metadata?: Record<string, unknown>;
|
|
401
401
|
byteSize: number;
|
|
402
402
|
preview: string;
|
|
403
|
+
/** Derived from the first non-empty line of content (title axis, ratified
|
|
404
|
+
* 2026-07-17) — NEVER stored, computed fresh at read time by
|
|
405
|
+
* `computeDisplayTitle`. `null` when content has no non-empty line.
|
|
406
|
+
* Surfaces decide how to render a `null` title (e.g. a timestamp/path
|
|
407
|
+
* fallback); core just reports the honest content-derived value. */
|
|
408
|
+
displayTitle: string | null;
|
|
403
409
|
/** Opt-in link degree (see `Note.linkCount`). */
|
|
404
410
|
linkCount?: number;
|
|
405
411
|
/** Full-text search relevance score (see `Note.score`). Carried onto the
|
|
@@ -272,6 +272,42 @@ function sqliteToUserType(t: string): string {
|
|
|
272
272
|
return t.toLowerCase();
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Attachments orientation block (attachment-tickets design, Wave 0+1)
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The Attachments orientation block, rendered into the connect-time
|
|
281
|
+
* markdown brief (`projectionToMarkdown`) so every agent learns, every
|
|
282
|
+
* session, how to move file bytes into/out of this vault without spending
|
|
283
|
+
* its own context on them (bytes never ride MCP either way).
|
|
284
|
+
*
|
|
285
|
+
* `ticketsEnabled` reflects whether THIS door has wired an
|
|
286
|
+
* `AttachmentTicketProvider` (bun: always, as of this PR; cloud: not yet
|
|
287
|
+
* — its mirror is a separate PR). An unwired door omits BOTH the ticket
|
|
288
|
+
* tools from `tools/list` (see `generateMcpTools`'s `attachmentTickets`
|
|
289
|
+
* opt) AND the ticket-tool sentences here — the brief never dangles a
|
|
290
|
+
* pointer at a tool the agent can't actually call.
|
|
291
|
+
*
|
|
292
|
+
* Kept as a single dense paragraph (not its own multi-line list) to stay
|
|
293
|
+
* inside the connect-time brief's token budget — see
|
|
294
|
+
* `projectionToMarkdown`'s doc comment.
|
|
295
|
+
*/
|
|
296
|
+
export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
|
|
297
|
+
const sentences: string[] = [
|
|
298
|
+
"Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
|
|
299
|
+
];
|
|
300
|
+
if (opts.ticketsEnabled) {
|
|
301
|
+
sentences.push(
|
|
302
|
+
"To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
sentences.push(
|
|
306
|
+
"If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer`. Audio attached with `transcribe: true` is transcribed automatically.",
|
|
307
|
+
);
|
|
308
|
+
return sentences.join(" ");
|
|
309
|
+
}
|
|
310
|
+
|
|
275
311
|
// ---------------------------------------------------------------------------
|
|
276
312
|
// Markdown rendering — for getServerInstruction
|
|
277
313
|
// ---------------------------------------------------------------------------
|
|
@@ -302,6 +338,13 @@ export function projectionToMarkdown(args: {
|
|
|
302
338
|
* known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
|
|
303
339
|
*/
|
|
304
340
|
coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
|
|
341
|
+
/**
|
|
342
|
+
* Attachments orientation (see `attachmentsInstructionBlock`). Omitted
|
|
343
|
+
* defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
|
|
344
|
+
* that hasn't wired ticket support yet (or a test fixture) never
|
|
345
|
+
* accidentally advertises tools it can't back.
|
|
346
|
+
*/
|
|
347
|
+
attachments?: { ticketsEnabled: boolean };
|
|
305
348
|
}): string {
|
|
306
349
|
const { vaultName, description, projection, coordinates } = args;
|
|
307
350
|
const stats = projection.stats;
|
|
@@ -418,6 +461,11 @@ export function projectionToMarkdown(args: {
|
|
|
418
461
|
lines.push("");
|
|
419
462
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
420
463
|
|
|
464
|
+
lines.push("");
|
|
465
|
+
lines.push("## Attachments");
|
|
466
|
+
lines.push("");
|
|
467
|
+
lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
|
|
468
|
+
|
|
421
469
|
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
422
470
|
// querying — an agent had no path to "how do I script/automate against this
|
|
423
471
|
// vault." Point at the guide rather than inlining it, to keep this brief
|
package/package.json
CHANGED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment tickets — end-to-end lifecycle (mint via the real MCP tool
|
|
3
|
+
* call path, spend via the real unauthenticated `/vault/<name>/tickets/<id>`
|
|
4
|
+
* route dispatched through `routing.ts`'s `route()`). Covers the Wave 1
|
|
5
|
+
* security posture: single-use, TTL/expiry, size-cap, wrong-vault scoping,
|
|
6
|
+
* uniform 404, upload→row+transcribe, download streaming + path
|
|
7
|
+
* confinement, and the connect-time instructions block.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect } from "bun:test";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
|
|
14
|
+
const testDir = join(
|
|
15
|
+
tmpdir(),
|
|
16
|
+
`vault-attachment-tickets-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
17
|
+
);
|
|
18
|
+
process.env.PARACHUTE_HOME = testDir;
|
|
19
|
+
// Deliberately NOT setting ASSETS_DIR (unlike storage.test.ts, which wants a
|
|
20
|
+
// single shared assets root for its own reasons): that env var is global to
|
|
21
|
+
// the whole `bun test` process (all files share one Node process), so
|
|
22
|
+
// setting it here would leak into every OTHER test file's assetsDir() calls
|
|
23
|
+
// too. Each vault below gets a unique, unset-ASSETS_DIR-default assets dir
|
|
24
|
+
// (`vaultDir(name)/assets`) scoped under this file's own unique
|
|
25
|
+
// PARACHUTE_HOME — fully isolated without needing the override.
|
|
26
|
+
|
|
27
|
+
const { route } = await import("./routing.ts");
|
|
28
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
29
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
30
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
31
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
32
|
+
const { getSharedAttachmentTicketProvider } = await import("./attachment-tickets.ts");
|
|
33
|
+
const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
|
|
34
|
+
|
|
35
|
+
/** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
|
|
36
|
+
async function routeReq(req: Request): Promise<Response> {
|
|
37
|
+
return route(req, new URL(req.url).pathname);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function freshVault(prefix: string): string {
|
|
41
|
+
const name = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Bypasses `authenticateVaultRequest` the same way vault.test.ts's scope-tier tests do — a fabricated already-resolved auth object. */
|
|
47
|
+
function mcpAuth(scopes: string[]) {
|
|
48
|
+
return {
|
|
49
|
+
permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
|
|
50
|
+
scopes,
|
|
51
|
+
legacyDerived: false,
|
|
52
|
+
scoped_tags: null,
|
|
53
|
+
} as any;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function callTool(vaultName: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
57
|
+
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
60
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
61
|
+
});
|
|
62
|
+
const res = await handleScopedMcp(req, vaultName, mcpAuth(["vault:read", "vault:write"]));
|
|
63
|
+
const body = (await res.json()) as any;
|
|
64
|
+
if (body.error) {
|
|
65
|
+
const err = new Error(body.error.message);
|
|
66
|
+
Object.assign(err, body.error.data ?? {});
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
return JSON.parse(body.result.content[0].text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("attachment tickets — upload lifecycle", () => {
|
|
73
|
+
test("mint → spend → row registered with size + original_name; second spend of the same ticket 404s", async () => {
|
|
74
|
+
const vaultName = freshVault("tickets-upload");
|
|
75
|
+
const store = getVaultStore(vaultName);
|
|
76
|
+
const note = await store.createNote("# Target\n", { path: "target" });
|
|
77
|
+
|
|
78
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
79
|
+
note: note.id,
|
|
80
|
+
filename: "photo.png",
|
|
81
|
+
size_bytes: 5,
|
|
82
|
+
mime_type: "image/png",
|
|
83
|
+
});
|
|
84
|
+
expect(mint.method).toBe("PUT");
|
|
85
|
+
expect(mint.url).toContain(`/vault/${vaultName}/tickets/`);
|
|
86
|
+
expect(mint.max_bytes).toBe(5);
|
|
87
|
+
|
|
88
|
+
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
89
|
+
const spendRes = await routeReq(
|
|
90
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: bytes }),
|
|
91
|
+
);
|
|
92
|
+
expect(spendRes.status).toBe(201);
|
|
93
|
+
const attachment = (await spendRes.json()) as any;
|
|
94
|
+
expect(attachment.noteId).toBe(note.id);
|
|
95
|
+
expect(attachment.mimeType).toBe("image/png");
|
|
96
|
+
expect(attachment.metadata.original_name).toBe("photo.png");
|
|
97
|
+
expect(attachment.metadata.size).toBe(5);
|
|
98
|
+
|
|
99
|
+
const rows = await store.getAttachments(note.id);
|
|
100
|
+
expect(rows.length).toBe(1);
|
|
101
|
+
expect(rows[0]!.id).toBe(attachment.id);
|
|
102
|
+
|
|
103
|
+
// Single-use: the ticket is gone after one spend — uniform 404, no
|
|
104
|
+
// oracle distinguishing "spent" from "never existed".
|
|
105
|
+
const secondRes = await routeReq(
|
|
106
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: new Uint8Array([9]) }),
|
|
107
|
+
);
|
|
108
|
+
expect(secondRes.status).toBe(404);
|
|
109
|
+
const secondBody = (await secondRes.json()) as any;
|
|
110
|
+
expect(secondBody.error_type).toBe("not_found");
|
|
111
|
+
expect(typeof secondBody.how_to).toBe("string");
|
|
112
|
+
|
|
113
|
+
// A second upload wasn't registered.
|
|
114
|
+
expect((await store.getAttachments(note.id)).length).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("POST also spends an upload ticket (accepted alongside the advertised PUT)", async () => {
|
|
118
|
+
const vaultName = freshVault("tickets-upload-post");
|
|
119
|
+
const store = getVaultStore(vaultName);
|
|
120
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
121
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
122
|
+
note: note.id,
|
|
123
|
+
filename: "a.txt",
|
|
124
|
+
size_bytes: 3,
|
|
125
|
+
});
|
|
126
|
+
const res = await routeReq(
|
|
127
|
+
new Request(mint.url, { method: "POST", headers: { "content-type": "text/plain" }, body: new Uint8Array([1, 2, 3]) }),
|
|
128
|
+
);
|
|
129
|
+
expect(res.status).toBe(201);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("size-cap: an upload exceeding the ticket's declared size_bytes is rejected 413 and no row is created", async () => {
|
|
133
|
+
const vaultName = freshVault("tickets-size-cap");
|
|
134
|
+
const store = getVaultStore(vaultName);
|
|
135
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
136
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
137
|
+
note: note.id,
|
|
138
|
+
filename: "small.bin",
|
|
139
|
+
size_bytes: 3,
|
|
140
|
+
});
|
|
141
|
+
const res = await routeReq(
|
|
142
|
+
new Request(mint.url, {
|
|
143
|
+
method: "PUT",
|
|
144
|
+
headers: { "content-type": "application/octet-stream" },
|
|
145
|
+
body: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
expect(res.status).toBe(413);
|
|
149
|
+
const body = (await res.json()) as any;
|
|
150
|
+
expect(body.error_type).toBe("file_too_large");
|
|
151
|
+
expect(body.limit).toBe(3);
|
|
152
|
+
expect(typeof body.how_to).toBe("string");
|
|
153
|
+
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a mismatched Content-Length header alone is rejected 413 before the body is even read", async () => {
|
|
157
|
+
const vaultName = freshVault("tickets-cl-cap");
|
|
158
|
+
const store = getVaultStore(vaultName);
|
|
159
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
160
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
161
|
+
note: note.id,
|
|
162
|
+
filename: "small.bin",
|
|
163
|
+
size_bytes: 3,
|
|
164
|
+
});
|
|
165
|
+
const res = await routeReq(
|
|
166
|
+
new Request(mint.url, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
headers: { "content-type": "application/octet-stream", "content-length": "1000000" },
|
|
169
|
+
body: new Uint8Array([1, 2, 3]),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
expect(res.status).toBe(413);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("expiry: a ticket past its expires_at is rejected 404 even though it was never spent", async () => {
|
|
176
|
+
const vaultName = freshVault("tickets-expiry");
|
|
177
|
+
const store = getVaultStore(vaultName);
|
|
178
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
179
|
+
|
|
180
|
+
const provider = getSharedAttachmentTicketProvider();
|
|
181
|
+
const id = "e".repeat(64);
|
|
182
|
+
await provider.put({
|
|
183
|
+
id,
|
|
184
|
+
kind: "upload",
|
|
185
|
+
vaultName,
|
|
186
|
+
createdAt: Date.now() - 1000,
|
|
187
|
+
expiresAt: Date.now() - 1, // already expired
|
|
188
|
+
noteId: note.id,
|
|
189
|
+
filename: "late.bin",
|
|
190
|
+
mimeType: "application/octet-stream",
|
|
191
|
+
sizeBytes: 10,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const res = await routeReq(
|
|
195
|
+
new Request(`http://localhost:1940/vault/${vaultName}/tickets/${id}`, {
|
|
196
|
+
method: "PUT",
|
|
197
|
+
headers: { "content-type": "application/octet-stream" },
|
|
198
|
+
body: new Uint8Array([1]),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
expect(res.status).toBe(404);
|
|
202
|
+
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("wrong-vault scope: a ticket minted for vault A can't be spent against vault B's URL", async () => {
|
|
206
|
+
const vaultA = freshVault("tickets-vault-a");
|
|
207
|
+
const vaultB = freshVault("tickets-vault-b");
|
|
208
|
+
const storeA = getVaultStore(vaultA);
|
|
209
|
+
const note = await storeA.createNote("# T\n", { path: "t" });
|
|
210
|
+
|
|
211
|
+
const mint = await callTool(vaultA, "request-attachment-upload", {
|
|
212
|
+
note: note.id,
|
|
213
|
+
filename: "a.bin",
|
|
214
|
+
size_bytes: 3,
|
|
215
|
+
});
|
|
216
|
+
const ticketId = mint.url.split("/tickets/")[1] as string;
|
|
217
|
+
|
|
218
|
+
const crossVaultRes = await routeReq(
|
|
219
|
+
new Request(`http://localhost:1940/vault/${vaultB}/tickets/${ticketId}`, {
|
|
220
|
+
method: "PUT",
|
|
221
|
+
headers: { "content-type": "application/octet-stream" },
|
|
222
|
+
body: new Uint8Array([1, 2, 3]),
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
expect(crossVaultRes.status).toBe(404);
|
|
226
|
+
|
|
227
|
+
// `take()` deletes on lookup regardless of which vault asked — a
|
|
228
|
+
// wrong-vault spend attempt still burns the ticket (single-use is
|
|
229
|
+
// enforced by the delete, vault-match is a SEPARATE check layered on
|
|
230
|
+
// top). Documenting this: the ticket is gone even for the correct
|
|
231
|
+
// vault after the cross-vault probe above.
|
|
232
|
+
const correctRes = await routeReq(
|
|
233
|
+
new Request(mint.url, {
|
|
234
|
+
method: "PUT",
|
|
235
|
+
headers: { "content-type": "application/octet-stream" },
|
|
236
|
+
body: new Uint8Array([1, 2, 3]),
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
expect(correctRes.status).toBe(404);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("transcribe: true rides through to the attachment row and stamps the note's transcribe_stub", async () => {
|
|
243
|
+
const vaultName = freshVault("tickets-transcribe");
|
|
244
|
+
const store = getVaultStore(vaultName);
|
|
245
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo" });
|
|
246
|
+
|
|
247
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
248
|
+
note: note.id,
|
|
249
|
+
filename: "memo.wav",
|
|
250
|
+
size_bytes: 4,
|
|
251
|
+
transcribe: true,
|
|
252
|
+
});
|
|
253
|
+
const res = await routeReq(
|
|
254
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
255
|
+
);
|
|
256
|
+
expect(res.status).toBe(201);
|
|
257
|
+
const attachment = (await res.json()) as any;
|
|
258
|
+
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
259
|
+
expect(attachment.metadata.transcribe_origin).toBe("legacy");
|
|
260
|
+
|
|
261
|
+
const updatedNote = await store.getNote(note.id);
|
|
262
|
+
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("blocked extension is refused at MINT — no ticket is ever created", async () => {
|
|
266
|
+
const vaultName = freshVault("tickets-blocked-ext");
|
|
267
|
+
const store = getVaultStore(vaultName);
|
|
268
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
269
|
+
await expect(
|
|
270
|
+
callTool(vaultName, "request-attachment-upload", { note: note.id, filename: "evil.svg", size_bytes: 10 }),
|
|
271
|
+
).rejects.toThrow();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("attachment tickets — download lifecycle", () => {
|
|
276
|
+
test("mint → spend streams the exact bytes with the attachment's content-type", async () => {
|
|
277
|
+
const vaultName = freshVault("tickets-download");
|
|
278
|
+
const store = getVaultStore(vaultName);
|
|
279
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
280
|
+
|
|
281
|
+
const uploadMint = await callTool(vaultName, "request-attachment-upload", {
|
|
282
|
+
note: note.id,
|
|
283
|
+
filename: "data.bin",
|
|
284
|
+
size_bytes: 4,
|
|
285
|
+
mime_type: "application/octet-stream",
|
|
286
|
+
});
|
|
287
|
+
const uploadRes = await routeReq(
|
|
288
|
+
new Request(uploadMint.url, {
|
|
289
|
+
method: "PUT",
|
|
290
|
+
headers: { "content-type": "application/octet-stream" },
|
|
291
|
+
body: new Uint8Array([10, 20, 30, 40]),
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
const attachment = (await uploadRes.json()) as any;
|
|
295
|
+
|
|
296
|
+
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
297
|
+
attachment_id: attachment.id,
|
|
298
|
+
});
|
|
299
|
+
expect(downloadMint.method).toBe("GET");
|
|
300
|
+
expect(downloadMint.mime_type).toBe("application/octet-stream");
|
|
301
|
+
|
|
302
|
+
const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
303
|
+
expect(downloadRes.status).toBe(200);
|
|
304
|
+
expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
|
|
305
|
+
expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
|
|
306
|
+
const body = new Uint8Array(await downloadRes.arrayBuffer());
|
|
307
|
+
expect(Array.from(body)).toEqual([10, 20, 30, 40]);
|
|
308
|
+
|
|
309
|
+
// Single-use on the download side too.
|
|
310
|
+
const secondRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
311
|
+
expect(secondRes.status).toBe(404);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
|
|
315
|
+
const vaultName = freshVault("tickets-confinement");
|
|
316
|
+
const store = getVaultStore(vaultName);
|
|
317
|
+
const note = await store.createNote("# T\n", { path: "t" });
|
|
318
|
+
// A row with a traversal path could only arrive via a bug elsewhere
|
|
319
|
+
// (tickets themselves never accept a caller-supplied path) — this pins
|
|
320
|
+
// the download spend route's OWN confinement guard as defense-in-depth.
|
|
321
|
+
const attachment = await store.addAttachment(note.id, "../../../../etc/passwd", "text/plain");
|
|
322
|
+
|
|
323
|
+
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
324
|
+
attachment_id: attachment.id,
|
|
325
|
+
});
|
|
326
|
+
const res = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
327
|
+
expect(res.status).toBe(404);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("attachment tickets — discoverability", () => {
|
|
332
|
+
test("attachmentsInstructionBlock teaches the ticket tools when enabled, and omits them when not", () => {
|
|
333
|
+
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true });
|
|
334
|
+
expect(enabled).toContain("request-attachment-upload");
|
|
335
|
+
expect(enabled).toContain("request-attachment-download");
|
|
336
|
+
expect(enabled).toContain("curl_example");
|
|
337
|
+
|
|
338
|
+
const disabled = attachmentsInstructionBlock({ ticketsEnabled: false });
|
|
339
|
+
expect(disabled).not.toContain("request-attachment-upload");
|
|
340
|
+
// The REST fallback recipe is always present, wired or not.
|
|
341
|
+
expect(disabled).toContain("/storage/upload");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("bun's connect-time getServerInstruction includes the Attachments section and teaches the ticket tools", async () => {
|
|
345
|
+
const vaultName = freshVault("tickets-instructions");
|
|
346
|
+
const md = await getServerInstruction(vaultName);
|
|
347
|
+
expect(md).toContain("## Attachments");
|
|
348
|
+
expect(md).toContain("request-attachment-upload");
|
|
349
|
+
});
|
|
350
|
+
});
|