@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.3
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 +3 -5
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/core.test.ts +4 -66
- package/core/src/expand.ts +3 -11
- package/core/src/mcp.ts +6 -577
- package/core/src/notes.ts +4 -201
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/types.ts +0 -6
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
|
|
108
|
-
it("boosts on the post-frontmatter title line, not the `---` delimiter (shares computeDisplayTitle's frontmatter skip)", async () => {
|
|
109
|
-
// Direct-create misuse path: raw frontmatter-bearing content. Without
|
|
110
|
-
// the frontmatter skip, the derived "title" would be the literal
|
|
111
|
-
// string "---", which never matches any query term, so this note
|
|
112
|
-
// would wrongly land in the body-only tier.
|
|
113
|
-
await store.createNote(
|
|
114
|
-
"---\ntitle: irrelevant\n---\n# Budget Review\nQ3 numbers",
|
|
115
|
-
{ path: "frontmatter-led" },
|
|
116
|
-
);
|
|
117
|
-
await store.createNote(
|
|
118
|
-
"Weekly Standup\nsomewhere we discuss the budget in passing",
|
|
119
|
-
{ path: "body-only" },
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
const hits = await store.searchNotes("budget review");
|
|
123
|
-
expect(hits[0].path).toBe("frontmatter-led");
|
|
124
|
-
});
|
|
125
|
-
});
|
package/src/attachment-bytes.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Attachment bytes — bun's filesystem implementation of the model-lane
|
|
3
|
-
* (Wave 2) `AttachmentBytesProvider` seam (`core/src/attachment/bytes-provider.ts`).
|
|
4
|
-
*
|
|
5
|
-
* Stateless by design (unlike the ticket provider, there's no in-memory
|
|
6
|
-
* state to share across requests) — a fresh instance per MCP session is
|
|
7
|
-
* cheap, so `createFsAttachmentBytesProvider` is a plain factory, not a
|
|
8
|
-
* shared singleton.
|
|
9
|
-
*
|
|
10
|
-
* `readRange` uses `Bun.file(path).slice(start, end)` — a bounded,
|
|
11
|
-
* positional read (Bun resolves the slice lazily via `pread` under the
|
|
12
|
-
* hood), never the whole-file `readFileSync` this design explicitly moves
|
|
13
|
-
* away from (see `handleStorage`'s REST `Range` support in `src/routes.ts`
|
|
14
|
-
* for the sibling fix on the byte-serve side).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { existsSync, statSync } from "fs";
|
|
18
|
-
import { join, normalize } from "path";
|
|
19
|
-
import type { Attachment } from "../core/src/types.ts";
|
|
20
|
-
import type { AttachmentBytesProvider } from "../core/src/attachment/bytes-provider.ts";
|
|
21
|
-
import { assetsDir } from "./config.ts";
|
|
22
|
-
import { transcriptPathFor } from "./transcript-note.ts";
|
|
23
|
-
import { getVaultStore } from "./vault-store.ts";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Resolve + confine an attachment's on-disk path under this vault's assets
|
|
27
|
-
* dir. Same guard as the ticket download spend route (`src/attachment-tickets.ts`)
|
|
28
|
-
* and the REST byte-serve route (`src/routes.ts`): normalize, then require
|
|
29
|
-
* the result to still start with the (normalized) assets root — a stored
|
|
30
|
-
* `path` can never resolve outside it, but a defense-in-depth check costs
|
|
31
|
-
* nothing. Returns `null` on a traversal attempt (treated identically to
|
|
32
|
-
* "file doesn't exist" by every caller here).
|
|
33
|
-
*/
|
|
34
|
-
function resolveConfinedPath(vaultName: string, attachment: Attachment): string | null {
|
|
35
|
-
const assets = assetsDir(vaultName);
|
|
36
|
-
const filePath = normalize(join(assets, attachment.path));
|
|
37
|
-
if (!filePath.startsWith(normalize(assets))) return null;
|
|
38
|
-
return filePath;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class FsAttachmentBytesProvider implements AttachmentBytesProvider {
|
|
42
|
-
constructor(private readonly vaultName: string) {}
|
|
43
|
-
|
|
44
|
-
async stat(attachment: Attachment): Promise<{ size: number } | null> {
|
|
45
|
-
const filePath = resolveConfinedPath(this.vaultName, attachment);
|
|
46
|
-
if (!filePath || !existsSync(filePath)) return null;
|
|
47
|
-
return { size: statSync(filePath).size };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array> {
|
|
51
|
-
const filePath = resolveConfinedPath(this.vaultName, attachment);
|
|
52
|
-
if (!filePath) return new Uint8Array(0);
|
|
53
|
-
const slice = Bun.file(filePath).slice(start, end);
|
|
54
|
-
return new Uint8Array(await slice.arrayBuffer());
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async resolveTranscriptNote(attachment: Attachment): Promise<{ id: string; path: string } | null> {
|
|
58
|
-
const store = getVaultStore(this.vaultName);
|
|
59
|
-
const note = await store.getNoteByPath(transcriptPathFor(attachment.path));
|
|
60
|
-
if (!note) return null;
|
|
61
|
-
return { id: note.id, path: note.path ?? transcriptPathFor(attachment.path) };
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Build a fresh `AttachmentBytesProvider` scoped to one vault. Cheap — safe to call per-request. */
|
|
66
|
-
export function createFsAttachmentBytesProvider(vaultName: string): AttachmentBytesProvider {
|
|
67
|
-
return new FsAttachmentBytesProvider(vaultName);
|
|
68
|
-
}
|
|
@@ -1,475 +0,0 @@
|
|
|
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 {
|
|
33
|
-
getSharedAttachmentTicketProvider,
|
|
34
|
-
InProcessAttachmentTicketProvider,
|
|
35
|
-
sweepExpiredAttachmentTickets,
|
|
36
|
-
startAttachmentTicketSweep,
|
|
37
|
-
stopAttachmentTicketSweep,
|
|
38
|
-
} = await import("./attachment-tickets.ts");
|
|
39
|
-
const { generateTicketId } = await import("../core/src/attachment/tickets.ts");
|
|
40
|
-
import type { AttachmentTicket } from "../core/src/attachment/tickets.ts";
|
|
41
|
-
const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
|
|
42
|
-
|
|
43
|
-
/** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
|
|
44
|
-
async function routeReq(req: Request): Promise<Response> {
|
|
45
|
-
return route(req, new URL(req.url).pathname);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function freshVault(prefix: string): string {
|
|
49
|
-
const name = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
50
|
-
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
51
|
-
return name;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Bypasses `authenticateVaultRequest` the same way vault.test.ts's scope-tier tests do — a fabricated already-resolved auth object. */
|
|
55
|
-
function mcpAuth(scopes: string[]) {
|
|
56
|
-
return {
|
|
57
|
-
permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
|
|
58
|
-
scopes,
|
|
59
|
-
legacyDerived: false,
|
|
60
|
-
scoped_tags: null,
|
|
61
|
-
} as any;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function callTool(vaultName: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
65
|
-
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
66
|
-
method: "POST",
|
|
67
|
-
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
68
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
69
|
-
});
|
|
70
|
-
const res = await handleScopedMcp(req, vaultName, mcpAuth(["vault:read", "vault:write"]));
|
|
71
|
-
const body = (await res.json()) as any;
|
|
72
|
-
if (body.error) {
|
|
73
|
-
const err = new Error(body.error.message);
|
|
74
|
-
Object.assign(err, body.error.data ?? {});
|
|
75
|
-
throw err;
|
|
76
|
-
}
|
|
77
|
-
return JSON.parse(body.result.content[0].text);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
describe("attachment tickets — upload lifecycle", () => {
|
|
81
|
-
test("mint → spend → row registered with size + original_name; second spend of the same ticket 404s", async () => {
|
|
82
|
-
const vaultName = freshVault("tickets-upload");
|
|
83
|
-
const store = getVaultStore(vaultName);
|
|
84
|
-
const note = await store.createNote("# Target\n", { path: "target" });
|
|
85
|
-
|
|
86
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
87
|
-
note: note.id,
|
|
88
|
-
filename: "photo.png",
|
|
89
|
-
size_bytes: 5,
|
|
90
|
-
mime_type: "image/png",
|
|
91
|
-
});
|
|
92
|
-
expect(mint.method).toBe("PUT");
|
|
93
|
-
expect(mint.url).toContain(`/vault/${vaultName}/tickets/`);
|
|
94
|
-
expect(mint.max_bytes).toBe(5);
|
|
95
|
-
|
|
96
|
-
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
97
|
-
const spendRes = await routeReq(
|
|
98
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: bytes }),
|
|
99
|
-
);
|
|
100
|
-
expect(spendRes.status).toBe(201);
|
|
101
|
-
const attachment = (await spendRes.json()) as any;
|
|
102
|
-
expect(attachment.noteId).toBe(note.id);
|
|
103
|
-
expect(attachment.mimeType).toBe("image/png");
|
|
104
|
-
expect(attachment.metadata.original_name).toBe("photo.png");
|
|
105
|
-
expect(attachment.metadata.size).toBe(5);
|
|
106
|
-
|
|
107
|
-
const rows = await store.getAttachments(note.id);
|
|
108
|
-
expect(rows.length).toBe(1);
|
|
109
|
-
expect(rows[0]!.id).toBe(attachment.id);
|
|
110
|
-
|
|
111
|
-
// Single-use: the ticket is gone after one spend — uniform 404, no
|
|
112
|
-
// oracle distinguishing "spent" from "never existed".
|
|
113
|
-
const secondRes = await routeReq(
|
|
114
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "image/png" }, body: new Uint8Array([9]) }),
|
|
115
|
-
);
|
|
116
|
-
expect(secondRes.status).toBe(404);
|
|
117
|
-
const secondBody = (await secondRes.json()) as any;
|
|
118
|
-
expect(secondBody.error_type).toBe("not_found");
|
|
119
|
-
expect(typeof secondBody.how_to).toBe("string");
|
|
120
|
-
|
|
121
|
-
// A second upload wasn't registered.
|
|
122
|
-
expect((await store.getAttachments(note.id)).length).toBe(1);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("POST also spends an upload ticket (accepted alongside the advertised PUT)", async () => {
|
|
126
|
-
const vaultName = freshVault("tickets-upload-post");
|
|
127
|
-
const store = getVaultStore(vaultName);
|
|
128
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
129
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
130
|
-
note: note.id,
|
|
131
|
-
filename: "a.txt",
|
|
132
|
-
size_bytes: 3,
|
|
133
|
-
});
|
|
134
|
-
const res = await routeReq(
|
|
135
|
-
new Request(mint.url, { method: "POST", headers: { "content-type": "text/plain" }, body: new Uint8Array([1, 2, 3]) }),
|
|
136
|
-
);
|
|
137
|
-
expect(res.status).toBe(201);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("size-cap: an upload exceeding the ticket's declared size_bytes is rejected 413 and no row is created", async () => {
|
|
141
|
-
const vaultName = freshVault("tickets-size-cap");
|
|
142
|
-
const store = getVaultStore(vaultName);
|
|
143
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
144
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
145
|
-
note: note.id,
|
|
146
|
-
filename: "small.bin",
|
|
147
|
-
size_bytes: 3,
|
|
148
|
-
});
|
|
149
|
-
const res = await routeReq(
|
|
150
|
-
new Request(mint.url, {
|
|
151
|
-
method: "PUT",
|
|
152
|
-
headers: { "content-type": "application/octet-stream" },
|
|
153
|
-
body: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
|
154
|
-
}),
|
|
155
|
-
);
|
|
156
|
-
expect(res.status).toBe(413);
|
|
157
|
-
const body = (await res.json()) as any;
|
|
158
|
-
expect(body.error_type).toBe("file_too_large");
|
|
159
|
-
expect(body.limit).toBe(3);
|
|
160
|
-
expect(typeof body.how_to).toBe("string");
|
|
161
|
-
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("a mismatched Content-Length header alone is rejected 413 before the body is even read", async () => {
|
|
165
|
-
const vaultName = freshVault("tickets-cl-cap");
|
|
166
|
-
const store = getVaultStore(vaultName);
|
|
167
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
168
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
169
|
-
note: note.id,
|
|
170
|
-
filename: "small.bin",
|
|
171
|
-
size_bytes: 3,
|
|
172
|
-
});
|
|
173
|
-
const res = await routeReq(
|
|
174
|
-
new Request(mint.url, {
|
|
175
|
-
method: "PUT",
|
|
176
|
-
headers: { "content-type": "application/octet-stream", "content-length": "1000000" },
|
|
177
|
-
body: new Uint8Array([1, 2, 3]),
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
expect(res.status).toBe(413);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("expiry: a ticket past its expires_at is rejected 404 even though it was never spent", async () => {
|
|
184
|
-
const vaultName = freshVault("tickets-expiry");
|
|
185
|
-
const store = getVaultStore(vaultName);
|
|
186
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
187
|
-
|
|
188
|
-
const provider = getSharedAttachmentTicketProvider();
|
|
189
|
-
const id = "e".repeat(64);
|
|
190
|
-
await provider.put({
|
|
191
|
-
id,
|
|
192
|
-
kind: "upload",
|
|
193
|
-
vaultName,
|
|
194
|
-
createdAt: Date.now() - 1000,
|
|
195
|
-
expiresAt: Date.now() - 1, // already expired
|
|
196
|
-
noteId: note.id,
|
|
197
|
-
filename: "late.bin",
|
|
198
|
-
mimeType: "application/octet-stream",
|
|
199
|
-
sizeBytes: 10,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const res = await routeReq(
|
|
203
|
-
new Request(`http://localhost:1940/vault/${vaultName}/tickets/${id}`, {
|
|
204
|
-
method: "PUT",
|
|
205
|
-
headers: { "content-type": "application/octet-stream" },
|
|
206
|
-
body: new Uint8Array([1]),
|
|
207
|
-
}),
|
|
208
|
-
);
|
|
209
|
-
expect(res.status).toBe(404);
|
|
210
|
-
expect((await store.getAttachments(note.id)).length).toBe(0);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("wrong-vault scope: a ticket minted for vault A can't be spent against vault B's URL", async () => {
|
|
214
|
-
const vaultA = freshVault("tickets-vault-a");
|
|
215
|
-
const vaultB = freshVault("tickets-vault-b");
|
|
216
|
-
const storeA = getVaultStore(vaultA);
|
|
217
|
-
const note = await storeA.createNote("# T\n", { path: "t" });
|
|
218
|
-
|
|
219
|
-
const mint = await callTool(vaultA, "request-attachment-upload", {
|
|
220
|
-
note: note.id,
|
|
221
|
-
filename: "a.bin",
|
|
222
|
-
size_bytes: 3,
|
|
223
|
-
});
|
|
224
|
-
const ticketId = mint.url.split("/tickets/")[1] as string;
|
|
225
|
-
|
|
226
|
-
const crossVaultRes = await routeReq(
|
|
227
|
-
new Request(`http://localhost:1940/vault/${vaultB}/tickets/${ticketId}`, {
|
|
228
|
-
method: "PUT",
|
|
229
|
-
headers: { "content-type": "application/octet-stream" },
|
|
230
|
-
body: new Uint8Array([1, 2, 3]),
|
|
231
|
-
}),
|
|
232
|
-
);
|
|
233
|
-
expect(crossVaultRes.status).toBe(404);
|
|
234
|
-
|
|
235
|
-
// `take()` deletes on lookup regardless of which vault asked — a
|
|
236
|
-
// wrong-vault spend attempt still burns the ticket (single-use is
|
|
237
|
-
// enforced by the delete, vault-match is a SEPARATE check layered on
|
|
238
|
-
// top). Documenting this: the ticket is gone even for the correct
|
|
239
|
-
// vault after the cross-vault probe above.
|
|
240
|
-
const correctRes = await routeReq(
|
|
241
|
-
new Request(mint.url, {
|
|
242
|
-
method: "PUT",
|
|
243
|
-
headers: { "content-type": "application/octet-stream" },
|
|
244
|
-
body: new Uint8Array([1, 2, 3]),
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
expect(correctRes.status).toBe(404);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test("transcribe: true rides through to the attachment row and stamps the note's transcribe_stub", async () => {
|
|
251
|
-
const vaultName = freshVault("tickets-transcribe");
|
|
252
|
-
const store = getVaultStore(vaultName);
|
|
253
|
-
const note = await store.createNote("# Voice memo\n", { path: "memo" });
|
|
254
|
-
|
|
255
|
-
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
256
|
-
note: note.id,
|
|
257
|
-
filename: "memo.wav",
|
|
258
|
-
size_bytes: 4,
|
|
259
|
-
transcribe: true,
|
|
260
|
-
});
|
|
261
|
-
const res = await routeReq(
|
|
262
|
-
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
263
|
-
);
|
|
264
|
-
expect(res.status).toBe(201);
|
|
265
|
-
const attachment = (await res.json()) as any;
|
|
266
|
-
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
267
|
-
expect(attachment.metadata.transcribe_origin).toBe("legacy");
|
|
268
|
-
|
|
269
|
-
const updatedNote = await store.getNote(note.id);
|
|
270
|
-
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("blocked extension is refused at MINT — no ticket is ever created", async () => {
|
|
274
|
-
const vaultName = freshVault("tickets-blocked-ext");
|
|
275
|
-
const store = getVaultStore(vaultName);
|
|
276
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
277
|
-
await expect(
|
|
278
|
-
callTool(vaultName, "request-attachment-upload", { note: note.id, filename: "evil.svg", size_bytes: 10 }),
|
|
279
|
-
).rejects.toThrow();
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
describe("attachment tickets — download lifecycle", () => {
|
|
284
|
-
test("mint → spend streams the exact bytes with the attachment's content-type", async () => {
|
|
285
|
-
const vaultName = freshVault("tickets-download");
|
|
286
|
-
const store = getVaultStore(vaultName);
|
|
287
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
288
|
-
|
|
289
|
-
const uploadMint = await callTool(vaultName, "request-attachment-upload", {
|
|
290
|
-
note: note.id,
|
|
291
|
-
filename: "data.bin",
|
|
292
|
-
size_bytes: 4,
|
|
293
|
-
mime_type: "application/octet-stream",
|
|
294
|
-
});
|
|
295
|
-
const uploadRes = await routeReq(
|
|
296
|
-
new Request(uploadMint.url, {
|
|
297
|
-
method: "PUT",
|
|
298
|
-
headers: { "content-type": "application/octet-stream" },
|
|
299
|
-
body: new Uint8Array([10, 20, 30, 40]),
|
|
300
|
-
}),
|
|
301
|
-
);
|
|
302
|
-
const attachment = (await uploadRes.json()) as any;
|
|
303
|
-
|
|
304
|
-
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
305
|
-
attachment_id: attachment.id,
|
|
306
|
-
});
|
|
307
|
-
expect(downloadMint.method).toBe("GET");
|
|
308
|
-
expect(downloadMint.mime_type).toBe("application/octet-stream");
|
|
309
|
-
|
|
310
|
-
const downloadRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
311
|
-
expect(downloadRes.status).toBe(200);
|
|
312
|
-
expect(downloadRes.headers.get("content-type")).toBe("application/octet-stream");
|
|
313
|
-
expect(downloadRes.headers.get("x-content-type-options")).toBe("nosniff");
|
|
314
|
-
const body = new Uint8Array(await downloadRes.arrayBuffer());
|
|
315
|
-
expect(Array.from(body)).toEqual([10, 20, 30, 40]);
|
|
316
|
-
|
|
317
|
-
// Single-use on the download side too.
|
|
318
|
-
const secondRes = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
319
|
-
expect(secondRes.status).toBe(404);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("path confinement: an attachment row pointing outside assetsDir can't be walked to via a download ticket", async () => {
|
|
323
|
-
const vaultName = freshVault("tickets-confinement");
|
|
324
|
-
const store = getVaultStore(vaultName);
|
|
325
|
-
const note = await store.createNote("# T\n", { path: "t" });
|
|
326
|
-
// A row with a traversal path could only arrive via a bug elsewhere
|
|
327
|
-
// (tickets themselves never accept a caller-supplied path) — this pins
|
|
328
|
-
// the download spend route's OWN confinement guard as defense-in-depth.
|
|
329
|
-
const attachment = await store.addAttachment(note.id, "../../../../etc/passwd", "text/plain");
|
|
330
|
-
|
|
331
|
-
const downloadMint = await callTool(vaultName, "request-attachment-download", {
|
|
332
|
-
attachment_id: attachment.id,
|
|
333
|
-
});
|
|
334
|
-
const res = await routeReq(new Request(downloadMint.url, { method: "GET" }));
|
|
335
|
-
expect(res.status).toBe(404);
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
describe("attachment tickets — discoverability", () => {
|
|
340
|
-
test("attachmentsInstructionBlock teaches the ticket tools when enabled, and omits them when not", () => {
|
|
341
|
-
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true });
|
|
342
|
-
expect(enabled).toContain("request-attachment-upload");
|
|
343
|
-
expect(enabled).toContain("request-attachment-download");
|
|
344
|
-
expect(enabled).toContain("curl_example");
|
|
345
|
-
|
|
346
|
-
const disabled = attachmentsInstructionBlock({ ticketsEnabled: false });
|
|
347
|
-
expect(disabled).not.toContain("request-attachment-upload");
|
|
348
|
-
// The REST fallback recipe is always present, wired or not.
|
|
349
|
-
expect(disabled).toContain("/storage/upload");
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
test("bun's connect-time getServerInstruction includes the Attachments section and teaches the ticket tools", async () => {
|
|
353
|
-
const vaultName = freshVault("tickets-instructions");
|
|
354
|
-
const md = await getServerInstruction(vaultName);
|
|
355
|
-
expect(md).toContain("## Attachments");
|
|
356
|
-
expect(md).toContain("request-attachment-upload");
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("attachmentsInstructionBlock teaches read-attachment when readEnabled, and omits it when not", () => {
|
|
360
|
-
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: true });
|
|
361
|
-
expect(enabled).toContain("read-attachment");
|
|
362
|
-
|
|
363
|
-
const disabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: false });
|
|
364
|
-
expect(disabled).not.toContain("read-attachment");
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("bun's connect-time getServerInstruction teaches read-attachment too", async () => {
|
|
368
|
-
const vaultName = freshVault("tickets-instructions-read");
|
|
369
|
-
const md = await getServerInstruction(vaultName);
|
|
370
|
-
expect(md).toContain("read-attachment");
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
describe("attachment ticket sweep (vault#612)", () => {
|
|
375
|
-
// Isolated instance — no risk of touching the one process-wide provider
|
|
376
|
-
// every OTHER test in this file (and every other test FILE, via
|
|
377
|
-
// getSharedAttachmentTicketProvider) also shares.
|
|
378
|
-
function ticketExpiringAt(id: string, expiresAt: number): AttachmentTicket {
|
|
379
|
-
return {
|
|
380
|
-
id,
|
|
381
|
-
kind: "download",
|
|
382
|
-
vaultName: "sweep-unit-test",
|
|
383
|
-
createdAt: expiresAt - 60_000,
|
|
384
|
-
expiresAt,
|
|
385
|
-
attachmentId: "att-1",
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
test("sweepExpired drops only expired-unspent tickets and returns the count dropped", async () => {
|
|
390
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
391
|
-
const now = Date.now();
|
|
392
|
-
await provider.put(ticketExpiringAt("expired-1", now - 5000));
|
|
393
|
-
await provider.put(ticketExpiringAt("expired-2", now - 1));
|
|
394
|
-
await provider.put(ticketExpiringAt("fresh-1", now + 60_000));
|
|
395
|
-
expect(provider.size()).toBe(3);
|
|
396
|
-
|
|
397
|
-
const dropped = provider.sweepExpired(now);
|
|
398
|
-
expect(dropped).toBe(2);
|
|
399
|
-
expect(provider.size()).toBe(1);
|
|
400
|
-
|
|
401
|
-
// Dropped tickets are gone — take() returns null, same as "never existed".
|
|
402
|
-
expect(await provider.take("expired-1")).toBeNull();
|
|
403
|
-
expect(await provider.take("expired-2")).toBeNull();
|
|
404
|
-
// The unexpired ticket survived the sweep and is still spendable.
|
|
405
|
-
const fresh = await provider.take("fresh-1");
|
|
406
|
-
expect(fresh?.id).toBe("fresh-1");
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("a ticket expiring exactly `now` counts as expired (< comparison, not <=)", async () => {
|
|
410
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
411
|
-
const now = Date.now();
|
|
412
|
-
await provider.put(ticketExpiringAt("boundary", now));
|
|
413
|
-
expect(provider.sweepExpired(now)).toBe(0); // expiresAt === now is NOT yet expired
|
|
414
|
-
expect(provider.sweepExpired(now + 1)).toBe(1); // one ms later, it is
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
test("a no-op sweep (nothing expired) drops nothing", () => {
|
|
418
|
-
const provider = new InProcessAttachmentTicketProvider();
|
|
419
|
-
expect(provider.sweepExpired(Date.now())).toBe(0);
|
|
420
|
-
expect(provider.size()).toBe(0);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("sweepExpiredAttachmentTickets delegates to the shared provider (unique ids — safe alongside concurrent tests)", async () => {
|
|
424
|
-
const provider = getSharedAttachmentTicketProvider();
|
|
425
|
-
const now = Date.now();
|
|
426
|
-
const expiredId = generateTicketId();
|
|
427
|
-
const freshId = generateTicketId();
|
|
428
|
-
await provider.put(ticketExpiringAt(expiredId, now - 1));
|
|
429
|
-
await provider.put(ticketExpiringAt(freshId, now + 60_000));
|
|
430
|
-
|
|
431
|
-
sweepExpiredAttachmentTickets(now);
|
|
432
|
-
|
|
433
|
-
expect(await provider.take(expiredId)).toBeNull();
|
|
434
|
-
const fresh = await provider.take(freshId);
|
|
435
|
-
expect(fresh?.id).toBe(freshId);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test("sweepExpiredAttachmentTickets always returns a number (the `?? 0` fallback path never throws)", () => {
|
|
439
|
-
// By this point in the suite the shared provider already exists (other
|
|
440
|
-
// tests above created it) — this doesn't re-prove the true
|
|
441
|
-
// never-created case in isolation, but pins the return type/no-throw
|
|
442
|
-
// contract the periodic sweep timer depends on every tick.
|
|
443
|
-
expect(typeof sweepExpiredAttachmentTickets()).toBe("number");
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
test("start/stop the periodic sweep: idempotent start, a short-interval real timer actually drops an expired ticket, clean stop", async () => {
|
|
447
|
-
// Poll via `size()`, NOT `take(id)` — `take()` deletes unconditionally
|
|
448
|
-
// on any lookup (expired or not; see its own doc comment), so polling
|
|
449
|
-
// with it would consume the ticket itself on the FIRST poll — before
|
|
450
|
-
// the timer ever fires — and the test would pass for the wrong reason.
|
|
451
|
-
const provider = getSharedAttachmentTicketProvider() as InstanceType<typeof InProcessAttachmentTicketProvider>;
|
|
452
|
-
const now = Date.now();
|
|
453
|
-
const id = generateTicketId();
|
|
454
|
-
await provider.put(ticketExpiringAt(id, now - 1)); // already expired
|
|
455
|
-
const baseline = provider.size();
|
|
456
|
-
|
|
457
|
-
startAttachmentTicketSweep(15); // 15ms — short enough to observe within the test timeout
|
|
458
|
-
startAttachmentTicketSweep(15); // idempotent — no-op, doesn't create a second timer
|
|
459
|
-
|
|
460
|
-
// Poll briefly rather than a single fixed sleep — bounded by the test
|
|
461
|
-
// runner's own timeout.
|
|
462
|
-
let sizeDropped = false;
|
|
463
|
-
for (let i = 0; i < 20 && !sizeDropped; i++) {
|
|
464
|
-
await new Promise((r) => setTimeout(r, 15));
|
|
465
|
-
if (provider.size() < baseline) sizeDropped = true;
|
|
466
|
-
}
|
|
467
|
-
stopAttachmentTicketSweep();
|
|
468
|
-
stopAttachmentTicketSweep(); // idempotent — no-op on an already-stopped sweep
|
|
469
|
-
|
|
470
|
-
expect(sizeDropped).toBe(true);
|
|
471
|
-
// Confirm it was genuinely OUR ticket the sweep dropped, not a
|
|
472
|
-
// coincidental size change from something else.
|
|
473
|
-
expect(await provider.take(id)).toBeNull();
|
|
474
|
-
});
|
|
475
|
-
});
|