@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.15

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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Content-range constants with ZERO runtime dependencies.
3
+ *
4
+ * Extracted from `content-range.ts` (which imports `QueryError` from
5
+ * `query-operators.ts` → `bun:sqlite`) so the pure-data MCP tool manifest
6
+ * (`mcp-manifest.ts`) can reference `MIN_CONTENT_LENGTH` in a tool
7
+ * `description` without dragging the sqlite driver into its import graph —
8
+ * the front-of-house Wave 0 workerd invariant. `content-range.ts` re-exports
9
+ * `MIN_CONTENT_LENGTH` from here, so every existing importer is unaffected.
10
+ */
11
+
12
+ /**
13
+ * Minimum accepted `content_length`. A UTF-8 codepoint is at most 4 bytes,
14
+ * so any budget >= 4 is guaranteed to make progress (the codepoint at the
15
+ * window start always fits). Budgets 1–3 could stall forever on a 4-byte
16
+ * emoji (empty slice, next_offset == offset); rejecting them up front is
17
+ * deterministic and simpler than a runtime "no progress" error.
18
+ */
19
+ export const MIN_CONTENT_LENGTH = 4;
@@ -30,14 +30,11 @@
30
30
 
31
31
  import { QueryError } from "./query-operators.js";
32
32
 
33
- /**
34
- * Minimum accepted `content_length`. A UTF-8 codepoint is at most 4 bytes,
35
- * so any budget >= 4 is guaranteed to make progress (the codepoint at the
36
- * window start always fits). Budgets 1–3 could stall forever on a 4-byte
37
- * emoji (empty slice, next_offset == offset); rejecting them up front is
38
- * deterministic and simpler than a runtime "no progress" error.
39
- */
40
- export const MIN_CONTENT_LENGTH = 4;
33
+ // `MIN_CONTENT_LENGTH` lives in the dependency-free `content-range-constants.ts`
34
+ // so the pure-data MCP tool manifest can import it without pulling `bun:sqlite`
35
+ // (front-of-house Wave 0). Re-exported here so existing importers are unchanged.
36
+ export { MIN_CONTENT_LENGTH } from "./content-range-constants.js";
37
+ import { MIN_CONTENT_LENGTH } from "./content-range-constants.js";
41
38
 
42
39
  export interface ContentRange {
43
40
  /** Byte offset (UTF-8) to start reading from. */
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Pin test for the pure-data MCP tool manifest (front-of-house Wave 0).
3
+ *
4
+ * Two invariants, plus the workerd import-graph guard:
5
+ *
6
+ * 1. `generateMcpTools` emits EXACTLY the manifest — every emitted tool's
7
+ * {name, description, inputSchema, requiredVerb} deep-equals its
8
+ * `MCP_TOOL_MANIFEST` entry, in manifest order, gated by `condition`.
9
+ * This is the drift pin: the manifest is the single source of tool
10
+ * metadata, and the refactor that split behavior (execute) from data
11
+ * (this manifest) changed NOTHING observable. It fails the moment the
12
+ * built tools and the manifest disagree.
13
+ *
14
+ * 2. The observable contract shape — the ordered tool names, their scope
15
+ * verbs, and their inclusion conditions (no-seam → 13 core; +tickets → 2;
16
+ * +bytes → 1) — matches the table captured from `main` at extraction
17
+ * time. A verb or a conditional-inclusion change is a wire-contract event
18
+ * and must update this table deliberately.
19
+ *
20
+ * 3. IMPORT-GRAPH INVARIANT: `mcp-manifest.ts`'s transitive relative-import
21
+ * closure is free of `bun:sqlite` (and every other `bun:` / `node:`
22
+ * runtime builtin), so the identity worker can import it under Cloudflare
23
+ * workerd via the same `file:../../../parachute-vault/core` dep the vault
24
+ * worker uses — without dragging in the sqlite driver.
25
+ */
26
+ import { describe, test, expect } from "bun:test";
27
+ import { readFileSync } from "node:fs";
28
+ import { dirname, resolve } from "node:path";
29
+ import { generateMcpTools, type McpToolDef } from "./mcp.js";
30
+ import { MCP_TOOL_MANIFEST, type McpToolCondition, type McpToolVerb } from "./mcp-manifest.js";
31
+
32
+ // A store stand-in: generateMcpTools only reads `store.db` to close over it in
33
+ // the (never-invoked here) execute closures. No tool is called in this file, so
34
+ // an empty object is enough to build the tool set.
35
+ const fakeStore = { db: {} } as any;
36
+
37
+ const ticketOpts = {
38
+ attachmentTickets: {
39
+ provider: {} as any,
40
+ vaultName: "pin",
41
+ urlBase: "https://host/vault/pin",
42
+ },
43
+ };
44
+ const bytesOpts = { attachmentBytes: { provider: {} as any } };
45
+
46
+ /**
47
+ * The observable contract, as emitted by `main` the day this manifest was
48
+ * extracted (verified byte-identical against a snapshot of main's
49
+ * `generateMcpTools`). Order is emission order.
50
+ */
51
+ const EXPECTED: ReadonlyArray<{
52
+ name: string;
53
+ requiredVerb: McpToolVerb;
54
+ condition: McpToolCondition;
55
+ hasResultContent: boolean;
56
+ }> = [
57
+ { name: "query-notes", requiredVerb: "read", condition: "core", hasResultContent: false },
58
+ { name: "create-note", requiredVerb: "write", condition: "core", hasResultContent: false },
59
+ { name: "update-note", requiredVerb: "write", condition: "core", hasResultContent: false },
60
+ { name: "delete-note", requiredVerb: "write", condition: "core", hasResultContent: false },
61
+ { name: "list-tags", requiredVerb: "read", condition: "core", hasResultContent: false },
62
+ { name: "update-tag", requiredVerb: "admin", condition: "core", hasResultContent: false },
63
+ { name: "delete-tag", requiredVerb: "admin", condition: "core", hasResultContent: false },
64
+ { name: "rename-tag", requiredVerb: "admin", condition: "core", hasResultContent: false },
65
+ { name: "merge-tags", requiredVerb: "admin", condition: "core", hasResultContent: false },
66
+ { name: "find-path", requiredVerb: "read", condition: "core", hasResultContent: false },
67
+ { name: "vault-info", requiredVerb: "read", condition: "core", hasResultContent: false },
68
+ { name: "prune-schema", requiredVerb: "admin", condition: "core", hasResultContent: false },
69
+ { name: "doctor", requiredVerb: "read", condition: "core", hasResultContent: false },
70
+ { name: "request-attachment-upload", requiredVerb: "write", condition: "attachment-tickets", hasResultContent: false },
71
+ { name: "request-attachment-download", requiredVerb: "read", condition: "attachment-tickets", hasResultContent: false },
72
+ { name: "read-attachment", requiredVerb: "read", condition: "attachment-bytes", hasResultContent: true },
73
+ ];
74
+
75
+ function names(tools: McpToolDef[]): string[] {
76
+ return tools.map((t) => t.name);
77
+ }
78
+
79
+ describe("MCP_TOOL_MANIFEST — shape", () => {
80
+ test("manifest order + verbs + conditions match the captured contract", () => {
81
+ expect(
82
+ MCP_TOOL_MANIFEST.map((e) => ({ name: e.name, requiredVerb: e.requiredVerb, condition: e.condition })),
83
+ ).toEqual(EXPECTED.map((e) => ({ name: e.name, requiredVerb: e.requiredVerb, condition: e.condition })));
84
+ });
85
+
86
+ test("every manifest entry carries a non-empty description + object inputSchema", () => {
87
+ for (const e of MCP_TOOL_MANIFEST) {
88
+ expect(typeof e.description).toBe("string");
89
+ expect(e.description.length).toBeGreaterThan(0);
90
+ expect(e.inputSchema).toBeInstanceOf(Object);
91
+ expect((e.inputSchema as any).type).toBe("object");
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("generateMcpTools — conditional inclusion (byte-for-byte with main)", () => {
97
+ test("no seams → exactly the 13 core-condition tools, in manifest order", () => {
98
+ const tools = generateMcpTools(fakeStore);
99
+ expect(names(tools)).toEqual(EXPECTED.filter((e) => e.condition === "core").map((e) => e.name));
100
+ expect(tools.length).toBe(13);
101
+ });
102
+
103
+ test("attachmentTickets seam appends the 2 ticket tools (upload=write, download=read)", () => {
104
+ const tools = generateMcpTools(fakeStore, ticketOpts);
105
+ expect(names(tools)).toEqual(
106
+ EXPECTED.filter((e) => e.condition === "core" || e.condition === "attachment-tickets").map((e) => e.name),
107
+ );
108
+ expect(tools.length).toBe(15);
109
+ });
110
+
111
+ test("attachmentBytes seam appends read-attachment only", () => {
112
+ const tools = generateMcpTools(fakeStore, bytesOpts);
113
+ expect(names(tools)).toEqual(
114
+ EXPECTED.filter((e) => e.condition === "core" || e.condition === "attachment-bytes").map((e) => e.name),
115
+ );
116
+ expect(tools.length).toBe(14);
117
+ });
118
+
119
+ test("both seams → all 16 tools in emission order", () => {
120
+ const tools = generateMcpTools(fakeStore, { ...ticketOpts, ...bytesOpts });
121
+ expect(names(tools)).toEqual(EXPECTED.map((e) => e.name));
122
+ expect(tools.length).toBe(16);
123
+ });
124
+ });
125
+
126
+ describe("generateMcpTools — the emitted set IS the manifest (drift pin)", () => {
127
+ const tools = generateMcpTools(fakeStore, { ...ticketOpts, ...bytesOpts });
128
+ const includedEntries = MCP_TOOL_MANIFEST; // all conditions satisfied by both seams
129
+
130
+ test("emitted {name,description,inputSchema,requiredVerb} deep-equals the manifest, in order", () => {
131
+ expect(
132
+ tools.map((t) => ({
133
+ name: t.name,
134
+ description: t.description,
135
+ inputSchema: t.inputSchema,
136
+ requiredVerb: t.requiredVerb,
137
+ })),
138
+ ).toEqual(
139
+ includedEntries.map((e) => ({
140
+ name: e.name,
141
+ description: e.description,
142
+ inputSchema: e.inputSchema,
143
+ requiredVerb: e.requiredVerb,
144
+ })),
145
+ );
146
+ });
147
+
148
+ test("only read-attachment carries a resultContent wrapper", () => {
149
+ for (let i = 0; i < tools.length; i++) {
150
+ expect(typeof tools[i]!.resultContent === "function").toBe(EXPECTED[i]!.hasResultContent);
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("mcp-manifest.ts — workerd import-graph invariant (bun:sqlite-free)", () => {
156
+ // Walk the transitive RELATIVE-import closure of mcp-manifest.ts on disk and
157
+ // collect every bare (non-relative) specifier it reaches. A bare `bun:*` /
158
+ // `node:*` anywhere in that closure would break the identity worker's
159
+ // workerd build. Uses Bun's real import parser (not a regex) so prose in
160
+ // descriptions/comments can't produce phantom specifiers, and so type-only
161
+ // imports — erased at runtime, irrelevant to the workerd graph — are elided.
162
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
163
+ function transitiveBareSpecifiers(entryTs: string): Set<string> {
164
+ const bare = new Set<string>();
165
+ const seen = new Set<string>();
166
+ const stack = [entryTs];
167
+ while (stack.length) {
168
+ const file = stack.pop()!;
169
+ if (seen.has(file)) continue;
170
+ seen.add(file);
171
+ const specs = transpiler.scanImports(readFileSync(file, "utf8")).map((i) => i.path);
172
+ for (const spec of specs) {
173
+ if (spec.startsWith(".")) {
174
+ // Resolve `./x.js` (or extensionless) to the on-disk `.ts` source.
175
+ const base = resolve(dirname(file), spec);
176
+ const candidate = base.endsWith(".js") ? base.slice(0, -3) + ".ts" : base + ".ts";
177
+ stack.push(candidate);
178
+ } else {
179
+ bare.add(spec);
180
+ }
181
+ }
182
+ }
183
+ return bare;
184
+ }
185
+
186
+ test("transitive closure reaches no bun:/node: runtime builtin (esp. bun:sqlite)", () => {
187
+ const entry = resolve(import.meta.dir, "mcp-manifest.ts");
188
+ const bare = transitiveBareSpecifiers(entry);
189
+ expect([...bare]).not.toContain("bun:sqlite");
190
+ const runtimeBuiltins = [...bare].filter((s) => s.startsWith("bun:") || s.startsWith("node:"));
191
+ expect(runtimeBuiltins).toEqual([]);
192
+ });
193
+
194
+ test("as a positive control, the SAME scan over mcp.ts DOES reach bun:sqlite", () => {
195
+ // Proves the scanner actually follows imports (a vacuous scan would pass
196
+ // the invariant above for the wrong reason).
197
+ const bare = transitiveBareSpecifiers(resolve(import.meta.dir, "mcp.ts"));
198
+ expect([...bare]).toContain("bun:sqlite");
199
+ });
200
+ });