@openparachute/vault 0.3.3 → 0.4.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.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Vault projection — computes a comprehensive description of the vault
3
+ * (tags-with-schemas + effective inheritance + indexed-field catalog +
4
+ * query hints) shared by two consumers:
5
+ *
6
+ * - `vault-info` MCP tool — returns the full projection as a JSON object
7
+ * so an agent can request a refresh mid-session.
8
+ * - `getServerInstruction` (MCP `initialize` response) — renders the
9
+ * same projection as a terse markdown brief sent once at connect.
10
+ *
11
+ * The projection lives outside the per-note path: it describes what kinds
12
+ * of notes and queries are available, not the contents. Nothing here
13
+ * depends on auth/scopes — both consumers compose this with vault config
14
+ * (name/description) and any policy-driven framing on top.
15
+ *
16
+ * See vault#271 for design notes.
17
+ */
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import {
21
+ loadSchemaConfig,
22
+ resolveNoteSchemas,
23
+ walkAncestors,
24
+ type ResolvedSchemas,
25
+ type SchemaField,
26
+ } from "./schema-defaults.ts";
27
+ import { listIndexedFields } from "./indexed-fields.ts";
28
+ import {
29
+ listTagRecords,
30
+ type TagFieldSchema,
31
+ type TagRecord,
32
+ } from "./tag-schemas.ts";
33
+ import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
34
+ import * as noteOps from "./notes.ts";
35
+ import type { VaultStats } from "./types.ts";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface ProjectionTag {
42
+ name: string;
43
+ description: string | null;
44
+ /** Direct parents declared in `tags.parent_names` (verbatim, no walk). */
45
+ parents: string[];
46
+ /**
47
+ * Walk-order ancestor closure (parents → grandparents → …) including the
48
+ * implicit `_default` universal parent when present, with the tag itself
49
+ * excluded. Empty when the tag has no parents and no `_default` exists.
50
+ */
51
+ effective_parents: string[];
52
+ /**
53
+ * Own field declarations (verbatim from `tags.fields`). Carries the full
54
+ * `TagFieldSchema` shape — `type` (string), optional `description`,
55
+ * `enum`, `indexed`. Width matches the on-disk row.
56
+ */
57
+ fields: Record<string, TagFieldSchema> | null;
58
+ /**
59
+ * Merged field map = own ∪ inherited (first-in-walk wins, matching
60
+ * `resolveNoteSchemas`). Uses the `SchemaField` view returned by the
61
+ * resolver (narrower `type` enum). Empty when no ancestor — including
62
+ * the tag itself — declared anything.
63
+ */
64
+ effective_fields: Record<string, SchemaField>;
65
+ relationships: TagRecord["relationships"] | null;
66
+ }
67
+
68
+ export interface ProjectionIndexedField {
69
+ name: string;
70
+ /** User-facing field type ("string" | "integer" | "boolean") drawn from the first declarer. */
71
+ type: string;
72
+ tags: string[];
73
+ }
74
+
75
+ export interface VaultProjection {
76
+ tags: ProjectionTag[];
77
+ indexed_fields: ProjectionIndexedField[];
78
+ query_hints: string[];
79
+ /** Included when the caller requests stats; omitted otherwise. */
80
+ stats?: VaultStats;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Inheritance helpers (built on the #272 resolver)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Resolve a single tag's effective inheritance.
89
+ *
90
+ * Built on top of `resolveNoteSchemas({ tags: [tag] })` so the walk order
91
+ * and conflict precedence match the runtime validator exactly. Returns:
92
+ *
93
+ * - `effective_parents`: walk-order ancestor list with the tag itself
94
+ * excluded. Includes `_default` when a `_default` row exists, regardless
95
+ * of whether the tag declares it (universal-parent semantics).
96
+ * - `effective_fields`: merged field map (first-in-walk wins). When no
97
+ * ancestor contributes, this equals the tag's own `fields`.
98
+ */
99
+ export function resolveTagInheritance(
100
+ resolved: ResolvedSchemas,
101
+ tag: string,
102
+ ): { effective_parents: string[]; effective_fields: Record<string, SchemaField> } {
103
+ const resolution = resolveNoteSchemas(resolved, { tags: [tag] });
104
+
105
+ // resolveNoteSchemas returns effectiveTags (only fields-contributing tags).
106
+ // We need the full walk for effective_parents — replay using the same
107
+ // resolver helper so walk-order semantics stay in lockstep with #270.
108
+ const visited = new Set<string>();
109
+ const order: string[] = [];
110
+ walkAncestors(tag, resolved, visited, order);
111
+ if (resolved.allTags.has(DEFAULT_TAG_NAME) && !visited.has(DEFAULT_TAG_NAME)) {
112
+ walkAncestors(DEFAULT_TAG_NAME, resolved, visited, order);
113
+ }
114
+ const effective_parents = order.filter((t) => t !== tag);
115
+
116
+ const effective_fields: Record<string, SchemaField> = {};
117
+ for (const [field, { spec }] of resolution.mergedFields) {
118
+ effective_fields[field] = spec;
119
+ }
120
+
121
+ return { effective_parents, effective_fields };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Projection
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Static query-hint catalog. Sent verbatim in both vault-info JSON and the
130
+ * connect-time markdown projection so an agent can self-orient without
131
+ * reading source. Edit here when query semantics change.
132
+ */
133
+ export const QUERY_HINTS: readonly string[] = [
134
+ "query-notes { tag: \"X\" } — all notes with tag X (includes descendants per inheritance)",
135
+ "query-notes { tag: \"X\", metadata: { field: { op: value } } } — operator queries on indexed fields (eq/ne/gt/gte/lt/lte/in/not_in/exists)",
136
+ "query-notes { search: \"...\" } — full-text search across content",
137
+ "query-notes { near: { id: \"...\" }, depth: 2 } — graph neighborhood within N hops",
138
+ "query-notes { id: \"<note-id-or-path>\" } — fetch a single note by ID or path",
139
+ ] as const;
140
+
141
+ /**
142
+ * Build the comprehensive vault projection. Pure read; no caches mutated.
143
+ *
144
+ * Shape rules:
145
+ *
146
+ * - `tags`: only tags carrying their own `description` or `fields`. A
147
+ * hierarchy-only tag (parent_names but no own schema) is omitted from
148
+ * the catalog — its semantics live in whichever ancestor contributes
149
+ * fields. `effective_fields` still surfaces the merged spec when the
150
+ * tag *does* appear (because it has its own description/fields).
151
+ *
152
+ * - `indexed_fields`: one entry per row in the `indexed_fields` table.
153
+ * The user-facing `type` is drawn from the first declarer's spec —
154
+ * declarers must agree on type (enforced at write time) so picking the
155
+ * first is unambiguous. `tags` lists every declarer, sorted.
156
+ *
157
+ * - `stats`: included when `opts.includeStats === true`. Uses the
158
+ * existing `getVaultStats` shape unchanged — camelCase keys, full
159
+ * monthly distribution.
160
+ */
161
+ export function buildVaultProjection(
162
+ db: Database,
163
+ opts?: { includeStats?: boolean },
164
+ ): VaultProjection {
165
+ const resolved = loadSchemaConfig(db);
166
+ const records = listTagRecords(db);
167
+
168
+ const tags: ProjectionTag[] = [];
169
+ for (const r of records) {
170
+ const hasOwnSchema =
171
+ (r.description !== undefined && r.description !== null) ||
172
+ (r.fields !== undefined && r.fields !== null && Object.keys(r.fields).length > 0);
173
+ if (!hasOwnSchema) continue;
174
+
175
+ const { effective_parents, effective_fields } = resolveTagInheritance(resolved, r.tag);
176
+
177
+ tags.push({
178
+ name: r.tag,
179
+ description: r.description ?? null,
180
+ parents: r.parent_names ?? [],
181
+ effective_parents,
182
+ fields: r.fields ?? null,
183
+ effective_fields,
184
+ relationships: r.relationships ?? null,
185
+ });
186
+ }
187
+
188
+ const indexedRows = listIndexedFields(db);
189
+ const indexed_fields: ProjectionIndexedField[] = indexedRows.map((row) => {
190
+ const declarers = [...row.declarerTags].sort();
191
+ // Recover the user-facing type from the first declarer's spec. Falls
192
+ // back to a sqlite-derived label if the declarer's row is gone (race;
193
+ // shouldn't happen because release drops the indexed_fields row, but
194
+ // robust against drift).
195
+ let userType = sqliteToUserType(row.sqliteType);
196
+ for (const t of declarers) {
197
+ const fields = resolved.tagToFields.get(t);
198
+ const declared = fields?.[row.field]?.type;
199
+ if (typeof declared === "string" && declared.length > 0) {
200
+ userType = declared;
201
+ break;
202
+ }
203
+ }
204
+ return { name: row.field, type: userType, tags: declarers };
205
+ });
206
+
207
+ const projection: VaultProjection = {
208
+ tags,
209
+ indexed_fields,
210
+ query_hints: [...QUERY_HINTS],
211
+ };
212
+
213
+ if (opts?.includeStats) {
214
+ projection.stats = noteOps.getVaultStats(db);
215
+ }
216
+
217
+ return projection;
218
+ }
219
+
220
+ function sqliteToUserType(t: string): string {
221
+ if (t === "TEXT") return "string";
222
+ if (t === "INTEGER") return "integer";
223
+ return t.toLowerCase();
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Markdown rendering — for getServerInstruction
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Render a vault projection as a terse markdown brief for the MCP
232
+ * `initialize` response. Keep dense — agents see this once at connect, and
233
+ * the goal is "everything an agent needs to start using the vault sensibly,
234
+ * with explicit pointers for refresh."
235
+ *
236
+ * Token budget guideline: ~600 tokens for a small vault (Aaron's, ~4 tags-
237
+ * with-schemas) and under ~5K tokens at 50 tags-with-schemas. Listing all
238
+ * tags-with-schemas inline is the default; cap heuristics can be added if
239
+ * a real test shape demands it.
240
+ */
241
+ export function projectionToMarkdown(args: {
242
+ vaultName: string;
243
+ description?: string | null;
244
+ projection: VaultProjection;
245
+ }): string {
246
+ const { vaultName, description, projection } = args;
247
+ const stats = projection.stats;
248
+
249
+ const lines: string[] = [];
250
+ lines.push(`You are connected to Parachute Vault "${vaultName}".`);
251
+ if (description && description.trim().length > 0) {
252
+ lines.push("");
253
+ lines.push(description.trim());
254
+ }
255
+
256
+ lines.push("");
257
+ lines.push("## Quick orientation (call `vault-info` for full schema)");
258
+ lines.push("");
259
+
260
+ if (stats) {
261
+ // Two distinct counts surface here so an agent doesn't conflate
262
+ // them (vault#274): `tagCount` is "tags ANY note uses" — driven by
263
+ // note_tags rows. `projection.tags.length` is "tags carrying a
264
+ // schema declaration" — strictly smaller and the relevant denominator
265
+ // for the schema-bearing list a few lines down. Showing only one
266
+ // hid the gap (e.g., 100 tags but only 5 with schemas read as
267
+ // "100 tags with schemas").
268
+ const noteCount = stats.totalNotes;
269
+ const tagCount = stats.tagCount;
270
+ const withSchemas = projection.tags.length;
271
+ const noteLabel = noteCount === 1 ? "note" : "notes";
272
+ const tagLabel = tagCount === 1 ? "tag" : "tags";
273
+ const tagSuffix = withSchemas > 0 ? `, ${withSchemas} with schemas` : "";
274
+ lines.push(`- ${noteCount} ${noteLabel}, ${tagCount} ${tagLabel} total${tagSuffix}`);
275
+ } else {
276
+ lines.push(`- (call \`vault-info { include_stats: true }\` for note/tag counts)`);
277
+ }
278
+
279
+ if (projection.tags.length === 0) {
280
+ lines.push(`- No tag schemas declared yet — every note is freeform.`);
281
+ } else {
282
+ const names = projection.tags.map((t) => t.name).join(", ");
283
+ lines.push(`- ${projection.tags.length} tag${projection.tags.length === 1 ? "" : "s"} with schemas: ${names}`);
284
+ }
285
+
286
+ if (projection.indexed_fields.length > 0) {
287
+ lines.push(`- Indexed metadata fields (queryable with operators):`);
288
+ for (const f of projection.indexed_fields) {
289
+ const declarers = f.tags.map((t) => `#${t}`).join(", ");
290
+ lines.push(` - ${f.name} (${f.type}; from ${declarers})`);
291
+ }
292
+ } else {
293
+ lines.push(`- No indexed metadata fields.`);
294
+ }
295
+
296
+ lines.push("");
297
+ lines.push("## Querying");
298
+ lines.push("");
299
+ for (const hint of projection.query_hints) {
300
+ lines.push(`- \`${hint}\``);
301
+ }
302
+
303
+ lines.push("");
304
+ lines.push("## Refreshing context");
305
+ lines.push("");
306
+ 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.");
307
+
308
+ return lines.join("\n");
309
+ }
@@ -44,7 +44,7 @@ export function parseWikilinks(content: string): ParsedWikilink[] {
44
44
 
45
45
  while ((match = regex.exec(stripped)) !== null) {
46
46
  const embed = match[1] === "!";
47
- const inner = match[2];
47
+ const inner = match[2]!;
48
48
 
49
49
  // Split on | for display text: [[target|display]]
50
50
  const pipeIdx = inner.indexOf("|");
@@ -133,7 +133,7 @@ export function resolveWikilink(db: Database, target: string): string | null {
133
133
  )
134
134
  `).all(target, `%/${target}`) as { id: string }[];
135
135
 
136
- if (basename.length === 1) return basename[0].id;
136
+ if (basename.length === 1) return basename[0]!.id;
137
137
 
138
138
  // Ambiguous or no match
139
139
  return null;
@@ -171,7 +171,7 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
171
171
  `).all(target, `%/${target}`) as { id: string; path: string }[];
172
172
 
173
173
  if (basename.length === 1) {
174
- return { resolved: true, note_id: basename[0].id, path: basename[0].path, candidates: [] };
174
+ return { resolved: true, note_id: basename[0]!.id, path: basename[0]!.path, candidates: [] };
175
175
  }
176
176
 
177
177
  if (basename.length > 1) {
package/package.json CHANGED
@@ -1,20 +1,30 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.3.3",
3
+ "version": "0.4.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "parachute-vault": "src/cli.ts"
9
9
  },
10
+ "files": [
11
+ "src",
12
+ "core/src",
13
+ "core/package.json",
14
+ ".parachute",
15
+ "tsconfig.json"
16
+ ],
10
17
  "scripts": {
11
18
  "start": "bun src/server.ts",
12
19
  "cli": "bun src/cli.ts",
13
- "test": "bun test src/",
14
- "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run"
20
+ "test": "bun test ./src/",
21
+ "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
22
+ "typecheck": "tsc --noEmit"
15
23
  },
16
24
  "dependencies": {
17
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
+ "@openparachute/scope-guard": "^0.2.0",
27
+ "jose": "^6.2.2",
18
28
  "otpauth": "^9.5.0",
19
29
  "qrcode-terminal": "^0.12.0"
20
30
  },
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for the admin SPA static-file mount (`src/admin-spa.ts`).
3
+ *
4
+ * The routing layer's responsibility is just "dispatch /admin/* to the SPA";
5
+ * this file tests the SPA-serving behavior itself with a tmp dist dir so
6
+ * the assertions don't depend on `bun run build` having been run in
7
+ * `web/ui/`. The integration check (admin path → SPA dispatch) lives in
8
+ * `routing.test.ts`.
9
+ */
10
+
11
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
12
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
13
+ import { join } from "path";
14
+ import { tmpdir } from "os";
15
+ import { isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
16
+
17
+ const fixtureDir = join(tmpdir(), `vault-admin-spa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
+
19
+ beforeAll(() => {
20
+ mkdirSync(join(fixtureDir, "assets"), { recursive: true });
21
+ writeFileSync(join(fixtureDir, "index.html"), "<!doctype html><html><body>shell</body></html>");
22
+ writeFileSync(join(fixtureDir, "assets", "index-abc.js"), "console.log('bundle');");
23
+ writeFileSync(join(fixtureDir, "assets", "index-abc.css"), "body { color: red; }");
24
+ });
25
+
26
+ afterAll(() => {
27
+ rmSync(fixtureDir, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("isAdminSpaPath", () => {
31
+ test("matches /vault/<name>/admin and /vault/<name>/admin/...", () => {
32
+ expect(isAdminSpaPath("/vault/work/admin")).toBe(true);
33
+ expect(isAdminSpaPath("/vault/work/admin/")).toBe(true);
34
+ expect(isAdminSpaPath("/vault/work/admin/tokens")).toBe(true);
35
+ expect(isAdminSpaPath("/vault/boulder/admin/assets/index.js")).toBe(true);
36
+ // Vault names with URL-safe punctuation should still match — the regex
37
+ // captures up to the next "/" so dashes / dots / digits all pass.
38
+ expect(isAdminSpaPath("/vault/my-vault.2/admin")).toBe(true);
39
+ });
40
+
41
+ test("does not match adjacent paths under the same vault", () => {
42
+ expect(isAdminSpaPath("/vault/work")).toBe(false);
43
+ expect(isAdminSpaPath("/vault/work/")).toBe(false);
44
+ expect(isAdminSpaPath("/vault/work/api/notes")).toBe(false);
45
+ expect(isAdminSpaPath("/vault/work/tokens")).toBe(false);
46
+ // Bare `admin-foo` suffix must not trigger — only the SPA mount itself.
47
+ expect(isAdminSpaPath("/vault/work/admin-foo")).toBe(false);
48
+ expect(isAdminSpaPath("/vault/work/administrative")).toBe(false);
49
+ });
50
+
51
+ test("does not match origin-rooted /admin (legacy mount retired)", () => {
52
+ expect(isAdminSpaPath("/admin")).toBe(false);
53
+ expect(isAdminSpaPath("/admin/")).toBe(false);
54
+ expect(isAdminSpaPath("/admin/tokens")).toBe(false);
55
+ });
56
+
57
+ test("does not match unrelated paths", () => {
58
+ expect(isAdminSpaPath("/")).toBe(false);
59
+ expect(isAdminSpaPath("/vaults")).toBe(false);
60
+ expect(isAdminSpaPath("/auth/status")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("serveAdminSpa", () => {
65
+ test("503 when the dist dir is absent (unbuilt)", async () => {
66
+ const res = await serveAdminSpa("/nonexistent/dist/dir", "/vault/work/admin/");
67
+ expect(res.status).toBe(503);
68
+ const body = await res.text();
69
+ expect(body).toContain("not found");
70
+ expect(body).toContain("bun run build");
71
+ });
72
+
73
+ test("bare /vault/<name>/admin redirects to trailing-slash form (301)", async () => {
74
+ // Vite's relative asset URLs (./assets/...) resolve against the
75
+ // *directory* of the current document — without a trailing slash,
76
+ // /vault/foo/admin's directory is /vault/foo/ and assets 404 against
77
+ // the per-vault auth wall. Hub's resolveManagementUrl generates the
78
+ // bare form, so this redirect is the load-bearing canonicalization.
79
+ const res = await serveAdminSpa(fixtureDir, "/vault/work/admin");
80
+ expect(res.status).toBe(301);
81
+ expect(res.headers.get("Location")).toBe("/vault/work/admin/");
82
+ });
83
+
84
+ test("/vault/<name>/admin/ returns the SPA index", async () => {
85
+ const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/");
86
+ expect(res.status).toBe(200);
87
+ expect(res.headers.get("content-type")).toContain("text/html");
88
+ expect(await res.text()).toContain("shell");
89
+ });
90
+
91
+ test("client-routed path (no extension) falls through to index.html", async () => {
92
+ const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/tokens");
93
+ expect(res.status).toBe(200);
94
+ expect(res.headers.get("content-type")).toContain("text/html");
95
+ expect(await res.text()).toContain("shell");
96
+ });
97
+
98
+ test("real asset path returns the asset with the right content-type", async () => {
99
+ const jsRes = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/index-abc.js");
100
+ expect(jsRes.status).toBe(200);
101
+ expect(jsRes.headers.get("content-type")).toContain("application/javascript");
102
+ expect(await jsRes.text()).toContain("console.log");
103
+
104
+ const cssRes = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/index-abc.css");
105
+ expect(cssRes.status).toBe(200);
106
+ expect(cssRes.headers.get("content-type")).toContain("text/css");
107
+ });
108
+
109
+ test("vault names with URL-safe punctuation strip cleanly", async () => {
110
+ const res = await serveAdminSpa(fixtureDir, "/vault/my-vault.2/admin/assets/index-abc.js");
111
+ expect(res.status).toBe(200);
112
+ expect(res.headers.get("content-type")).toContain("application/javascript");
113
+ });
114
+
115
+ test("typo'd asset path falls through to index.html (not a 404)", async () => {
116
+ const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/assets/missing-xyz.js");
117
+ expect(res.status).toBe(200);
118
+ expect(res.headers.get("content-type")).toContain("text/html");
119
+ });
120
+
121
+ test("path traversal (..) cannot escape dist dir", async () => {
122
+ // Triggers the asset-shape filter (.. is rejected) so this falls through
123
+ // to the SPA shell rather than reading something outside dist/.
124
+ const res = await serveAdminSpa(fixtureDir, "/vault/work/admin/../../etc/passwd");
125
+ expect(res.status).toBe(200);
126
+ expect(res.headers.get("content-type")).toContain("text/html");
127
+ const body = await res.text();
128
+ expect(body).toContain("shell");
129
+ expect(body).not.toContain("root:");
130
+ });
131
+ });
132
+
133
+ describe("hub <-> vault managementUrl contract", () => {
134
+ // Browsers drop the URL fragment when following a 301 (RFC 7231 SHOULD
135
+ // preserve, but Chrome/Firefox/Safari are inconsistent in practice). The
136
+ // hub-issued JWT travels in `#token=...`, so a redirected click loses the
137
+ // token and the SPA boots unauthenticated. Hub's resolveManagementUrl joins
138
+ // the per-vault module URL with module.json's `managementUrl` verbatim — if
139
+ // it ends with "/" the canonical click target is `/vault/<name>/admin/`
140
+ // (no redirect, fragment preserved). Without the trailing slash hub emits
141
+ // `/vault/<name>/admin`, the server 301s, and the fragment is gone.
142
+ test("module.json managementUrl ends with '/' so hub emits the no-redirect form", () => {
143
+ const moduleJson = JSON.parse(
144
+ readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
145
+ );
146
+ expect(moduleJson.managementUrl).toMatch(/\/$/);
147
+ });
148
+
149
+ test("the canonical hub-emitted URL serves the SPA shell directly (no 301)", async () => {
150
+ // Mirror hub's resolveManagementUrl shape: per-vault module URL +
151
+ // managementUrl. With managementUrl="/admin/" the result is
152
+ // /vault/<name>/admin/ — which serveAdminSpa returns as 200, not 301.
153
+ const moduleJson = JSON.parse(
154
+ readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
155
+ );
156
+ const canonical = `/vault/work${moduleJson.managementUrl}`;
157
+ const res = await serveAdminSpa(fixtureDir, canonical);
158
+ expect(res.status).toBe(200);
159
+ expect(res.headers.get("Location")).toBeNull();
160
+ });
161
+ });