@openparachute/vault 0.5.3-rc.3 → 0.6.0

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 (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
@@ -143,6 +143,86 @@ export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
143
143
  return result;
144
144
  }
145
145
 
146
+ /**
147
+ * Tag-expansion axis (vault tag `expand` axis — design
148
+ * `design/2026-06-09-tag-expand-axis.md`). A tag relates to others along two
149
+ * orthogonal axes; this selects which one (or both, or neither) a query expands
150
+ * along:
151
+ *
152
+ * - `"subtypes"` (DEFAULT): tag ∪ `parent_names` descendants. The semantic
153
+ * is-a axis — today's always-on behavior, unchanged. `_default` universal
154
+ * parent magic fires here.
155
+ * - `"namespace"`: tag ∪ {names lexically prefixed `tag/`}. The organizational
156
+ * filing axis — purely lexical over the known tag set, no `parent_names`, no
157
+ * `_default` magic.
158
+ * - `"both"`: union of subtypes and namespace.
159
+ * - `"exact"`: the literal tag only, no expansion.
160
+ */
161
+ export type TagExpandMode = "subtypes" | "namespace" | "both" | "exact";
162
+
163
+ export const TAG_EXPAND_MODES: readonly TagExpandMode[] = [
164
+ "subtypes",
165
+ "namespace",
166
+ "both",
167
+ "exact",
168
+ ] as const;
169
+
170
+ export const DEFAULT_TAG_EXPAND_MODE: TagExpandMode = "subtypes";
171
+
172
+ /**
173
+ * Lexical namespace expansion for a single tag: the tag itself plus every
174
+ * known tag name filed under it (`name === tag` OR `name` starts with
175
+ * `tag + "/"`). Purely a string-prefix match over `h.allTags` — namespacing is
176
+ * free-form (the slash in the tag *name*), declared nowhere, which is the whole
177
+ * point: subtyping is declared via `parent_names`, filing is not. No `_default`
178
+ * magic here — that's a subtypes-axis concept.
179
+ */
180
+ export function getTagNamespace(h: TagHierarchy, tag: string): Set<string> {
181
+ // Pre-seeded with `tag` itself, so the loop only needs the strict `tag/*`
182
+ // prefix test (the `name === tag` case is already covered by the seed).
183
+ const result = new Set<string>([tag]);
184
+ const prefix = tag + "/";
185
+ for (const name of h.allTags) {
186
+ if (name.startsWith(prefix)) result.add(name);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * Mode-aware single-tag expansion (vault tag `expand` axis). Returns the set of
193
+ * tag names a query for `tag` should match under `mode`. Always includes `tag`
194
+ * itself. Set semantics: a tag that is BOTH a declared subtype-child AND
195
+ * name-prefixed appears once.
196
+ *
197
+ * - `"subtypes"` → `getTagDescendants` (parent_names + `_default` magic).
198
+ * - `"namespace"` → `getTagNamespace` (lexical `tag/*`).
199
+ * - `"both"` → union of the two.
200
+ * - `"exact"` → `{tag}`.
201
+ */
202
+ export function getTagExpansion(
203
+ h: TagHierarchy,
204
+ tag: string,
205
+ mode: TagExpandMode,
206
+ ): Set<string> {
207
+ switch (mode) {
208
+ case "exact":
209
+ return new Set<string>([tag]);
210
+ case "namespace":
211
+ return getTagNamespace(h, tag);
212
+ case "both": {
213
+ const union = new Set<string>(getTagDescendants(h, tag));
214
+ for (const name of getTagNamespace(h, tag)) union.add(name);
215
+ return union;
216
+ }
217
+ case "subtypes":
218
+ default:
219
+ // `default` is a defensive fallback — `TagExpandMode` already constrains
220
+ // the value to the four cases above; it can only be reached if an
221
+ // untyped caller passes a bad string.
222
+ return getTagDescendants(h, tag);
223
+ }
224
+ }
225
+
146
226
  /**
147
227
  * Detect cycles in the declared hierarchy. Returns the list of tags
148
228
  * reachable from themselves via parent declarations. Used by
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Unit tests for the persisted-triggers store (core/src/triggers-store.ts) —
3
+ * JSON encode/decode round-trip + upsert/list/get/delete semantics over an
4
+ * in-memory SQLite DB (schema v21).
5
+ */
6
+
7
+ import { describe, test, expect, beforeEach } from "bun:test";
8
+ import { Database } from "bun:sqlite";
9
+ import { initSchema } from "./schema.js";
10
+ import {
11
+ upsertTrigger,
12
+ listTriggers,
13
+ getTrigger,
14
+ deleteTrigger,
15
+ loadAllTriggers,
16
+ } from "./triggers-store.js";
17
+
18
+ let db: Database;
19
+
20
+ beforeEach(() => {
21
+ db = new Database(":memory:");
22
+ initSchema(db);
23
+ });
24
+
25
+ function sample(name: string) {
26
+ return {
27
+ name,
28
+ events: ["created", "updated"] as Array<"created" | "updated">,
29
+ when: { tags: ["channel-message"], has_content: true },
30
+ action: {
31
+ webhook: "https://example.test/hook",
32
+ send: "json" as const,
33
+ timeout: 30000,
34
+ auth: { bearer: "jwt-token" },
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("triggers-store", () => {
40
+ test("the triggers table exists after initSchema (v21)", () => {
41
+ const row = db
42
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='triggers'")
43
+ .get();
44
+ expect(row).toBeTruthy();
45
+ });
46
+
47
+ test("upsert → get round-trips the structured JSON columns", () => {
48
+ upsertTrigger(db, sample("inbound"));
49
+ const got = getTrigger(db, "inbound");
50
+ expect(got).not.toBeNull();
51
+ expect(got!.when).toEqual({ tags: ["channel-message"], has_content: true });
52
+ expect(got!.action.webhook).toBe("https://example.test/hook");
53
+ expect(got!.action.auth).toEqual({ bearer: "jwt-token" });
54
+ expect(got!.events).toEqual(["created", "updated"]);
55
+ expect(got!.created_at).toBeTruthy();
56
+ expect(got!.updated_at).toBeTruthy();
57
+ });
58
+
59
+ test("upsert by name replaces + preserves created_at, bumps updated_at", async () => {
60
+ const first = upsertTrigger(db, sample("inbound"));
61
+ // Ensure clock advances so updated_at differs.
62
+ await new Promise((r) => setTimeout(r, 5));
63
+ const second = upsertTrigger(db, {
64
+ ...sample("inbound"),
65
+ action: { webhook: "https://example.test/v2" },
66
+ });
67
+
68
+ expect(listTriggers(db)).toHaveLength(1);
69
+ expect(second.created_at).toBe(first.created_at);
70
+ expect(second.updated_at >= first.updated_at).toBe(true);
71
+ expect(getTrigger(db, "inbound")!.action.webhook).toBe("https://example.test/v2");
72
+ });
73
+
74
+ test("events defaults to [created, updated] when omitted", () => {
75
+ upsertTrigger(db, {
76
+ name: "no-events",
77
+ when: { tags: ["x"] },
78
+ action: { webhook: "https://example.test/hook" },
79
+ });
80
+ expect(getTrigger(db, "no-events")!.events).toEqual(["created", "updated"]);
81
+ });
82
+
83
+ test("list / loadAllTriggers return all rows ordered by name", () => {
84
+ upsertTrigger(db, sample("zebra"));
85
+ upsertTrigger(db, sample("alpha"));
86
+ expect(listTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
87
+ expect(loadAllTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
88
+ });
89
+
90
+ test("delete removes the row; returns false on a missing name", () => {
91
+ upsertTrigger(db, sample("inbound"));
92
+ expect(deleteTrigger(db, "inbound")).toBe(true);
93
+ expect(getTrigger(db, "inbound")).toBeNull();
94
+ expect(deleteTrigger(db, "inbound")).toBe(false);
95
+ });
96
+
97
+ test("getTrigger returns null for an unknown name", () => {
98
+ expect(getTrigger(db, "nope")).toBeNull();
99
+ });
100
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Persisted runtime triggers — per-vault CRUD over the `triggers` table
3
+ * (schema v21).
4
+ *
5
+ * Complements the static `config.yaml` trigger system: config.yaml triggers
6
+ * are loaded at boot and fire globally (all vaults); rows in this table live
7
+ * in a single vault's SQLite DB and are re-registered at boot scoped to that
8
+ * vault (they fire ONLY for events on the vault they were stored under).
9
+ *
10
+ * The structured columns (`events`, `when`, `action`) are JSON-encoded on
11
+ * write and parsed on read. `name` is the primary key, so `upsertTrigger`
12
+ * is a true upsert (insert-or-replace by name). The shape here is kept
13
+ * structurally compatible with `src/config.ts`'s `TriggerConfig` /
14
+ * `TriggerWhen` / `TriggerAction` without importing from `src/` — core stays
15
+ * dependency-free of the server layer.
16
+ *
17
+ * `action.auth.bearer`, when present, becomes an `Authorization: Bearer`
18
+ * header on the webhook POST (the JWT webhook-auth path that retires the
19
+ * old `?secret=` query param). It is stored verbatim in the JSON column.
20
+ */
21
+
22
+ import type { Database } from "bun:sqlite";
23
+
24
+ /** Predicate shape — mirrors src/config.ts:TriggerWhen. */
25
+ export interface StoredTriggerWhen {
26
+ tags?: string[];
27
+ has_content?: boolean;
28
+ missing_metadata?: string[];
29
+ has_metadata?: string[];
30
+ }
31
+
32
+ /** Webhook auth — only the bearer-JWT path for now. */
33
+ export interface StoredTriggerAuth {
34
+ bearer?: string;
35
+ }
36
+
37
+ /** Action shape — mirrors src/config.ts:TriggerAction plus `auth`. */
38
+ export interface StoredTriggerAction {
39
+ webhook: string;
40
+ timeout?: number;
41
+ send?: "json" | "attachment" | "content";
42
+ auth?: StoredTriggerAuth;
43
+ // Forward-compat: include_context and any other action fields round-trip
44
+ // through the JSON column verbatim even though core doesn't interpret them.
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ /** A persisted trigger row, decoded. */
49
+ export interface StoredTrigger {
50
+ name: string;
51
+ events: Array<"created" | "updated">;
52
+ when: StoredTriggerWhen;
53
+ action: StoredTriggerAction;
54
+ created_at: string;
55
+ updated_at: string;
56
+ }
57
+
58
+ /** The writable subset callers pass to upsert. */
59
+ export interface TriggerInput {
60
+ name: string;
61
+ events?: Array<"created" | "updated">;
62
+ when: StoredTriggerWhen;
63
+ action: StoredTriggerAction;
64
+ }
65
+
66
+ interface TriggerRow {
67
+ name: string;
68
+ events: string;
69
+ when: string;
70
+ action: string;
71
+ created_at: string;
72
+ updated_at: string;
73
+ }
74
+
75
+ function decodeRow(row: TriggerRow): StoredTrigger {
76
+ return {
77
+ name: row.name,
78
+ events: safeParse(row.events, ["created", "updated"]) as Array<"created" | "updated">,
79
+ when: safeParse(row.when, {}) as StoredTriggerWhen,
80
+ action: safeParse(row.action, { webhook: "" }) as StoredTriggerAction,
81
+ created_at: row.created_at,
82
+ updated_at: row.updated_at,
83
+ };
84
+ }
85
+
86
+ function safeParse(json: string, fallback: unknown): unknown {
87
+ try {
88
+ return JSON.parse(json);
89
+ } catch {
90
+ return fallback;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Insert or replace a trigger by name. Preserves `created_at` on update
96
+ * (re-fetches the existing row's timestamp) and stamps a fresh `updated_at`.
97
+ * Returns the stored shape.
98
+ */
99
+ export function upsertTrigger(db: Database, input: TriggerInput): StoredTrigger {
100
+ const now = new Date().toISOString();
101
+ const existing = db
102
+ .prepare("SELECT created_at FROM triggers WHERE name = ?")
103
+ .get(input.name) as { created_at: string } | null;
104
+ const createdAt = existing?.created_at ?? now;
105
+ const events = input.events ?? ["created", "updated"];
106
+
107
+ db.prepare(
108
+ `INSERT INTO triggers (name, events, "when", action, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(name) DO UPDATE SET
111
+ events = excluded.events,
112
+ "when" = excluded."when",
113
+ action = excluded.action,
114
+ updated_at = excluded.updated_at`,
115
+ ).run(
116
+ input.name,
117
+ JSON.stringify(events),
118
+ JSON.stringify(input.when),
119
+ JSON.stringify(input.action),
120
+ createdAt,
121
+ now,
122
+ );
123
+
124
+ return {
125
+ name: input.name,
126
+ events,
127
+ when: input.when,
128
+ action: input.action,
129
+ created_at: createdAt,
130
+ updated_at: now,
131
+ };
132
+ }
133
+
134
+ /** List all persisted triggers for this vault, ordered by name. */
135
+ export function listTriggers(db: Database): StoredTrigger[] {
136
+ const rows = db
137
+ .prepare(
138
+ `SELECT name, events, "when", action, created_at, updated_at
139
+ FROM triggers ORDER BY name`,
140
+ )
141
+ .all() as TriggerRow[];
142
+ return rows.map(decodeRow);
143
+ }
144
+
145
+ /** Fetch a single trigger by name, or null. */
146
+ export function getTrigger(db: Database, name: string): StoredTrigger | null {
147
+ const row = db
148
+ .prepare(
149
+ `SELECT name, events, "when", action, created_at, updated_at
150
+ FROM triggers WHERE name = ?`,
151
+ )
152
+ .get(name) as TriggerRow | null;
153
+ return row ? decodeRow(row) : null;
154
+ }
155
+
156
+ /** Delete a trigger by name. Returns true if a row was removed. */
157
+ export function deleteTrigger(db: Database, name: string): boolean {
158
+ const res = db.prepare("DELETE FROM triggers WHERE name = ?").run(name);
159
+ return res.changes > 0;
160
+ }
161
+
162
+ /** Load all persisted triggers (boot path). Alias of listTriggers for clarity. */
163
+ export function loadAllTriggers(db: Database): StoredTrigger[] {
164
+ return listTriggers(db);
165
+ }
package/core/src/types.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
+ import type { TagExpandMode } from "./tag-hierarchy.js";
4
5
 
5
6
  // ---- Re-exports ----
6
7
 
7
8
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
8
9
  export type { PrunedField } from "./indexed-fields.js";
10
+ export type { TagExpandMode } from "./tag-hierarchy.js";
9
11
 
10
12
  // ---- Note ----
11
13
 
@@ -85,6 +87,18 @@ export interface VaultStats {
85
87
  export interface QueryOpts {
86
88
  tags?: string[];
87
89
  tagMatch?: "all" | "any"; // "all" = must have ALL tags (default), "any" = must have ANY tag
90
+ /**
91
+ * Tag-expansion axis (vault tag `expand` axis — design
92
+ * `design/2026-06-09-tag-expand-axis.md`). Selects how each `tags` entry
93
+ * expands:
94
+ * - `"subtypes"` (DEFAULT): tag ∪ `parent_names` descendants. Today's
95
+ * semantic is-a behavior, unchanged. `_default` universal magic fires here.
96
+ * - `"namespace"`: tag ∪ lexically name-prefixed `tag/*` (the filing axis).
97
+ * - `"both"`: union of subtypes + namespace.
98
+ * - `"exact"`: the literal tag only, no expansion.
99
+ * Absent → `"subtypes"` → byte-identical to pre-axis behavior.
100
+ */
101
+ expand?: TagExpandMode;
88
102
  excludeTags?: string[];
89
103
  // Presence filters. `true` → has at least one; `false` → has none.
90
104
  // When `tags` is also set, `hasTags` is ignored (the tag filter already constrains the set).
@@ -256,7 +270,7 @@ export interface Store {
256
270
  * agent loop can persist a single watermark and keep polling.
257
271
  */
258
272
  queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
259
- searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
273
+ searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]>;
260
274
 
261
275
  // Tags
262
276
  tagNote(noteId: string, tags: string[]): Promise<void>;
@@ -268,6 +282,18 @@ export interface Store {
268
282
  * compute the effective allowlisted tag-set at auth time.
269
283
  */
270
284
  expandTagsWithDescendants(tags: string[]): Promise<Set<string>>;
285
+ /**
286
+ * Mode-aware tag expansion (vault tag `expand` axis). Expands each input tag
287
+ * along the selected axis and returns the union:
288
+ * - `"subtypes"` (default): `{tag} ∪ parent_names-descendants` — identical to
289
+ * `expandTagsWithDescendants` (which is a thin shim over this).
290
+ * - `"namespace"`: `{tag} ∪ lexically name-prefixed tag/*`.
291
+ * - `"both"`: union of the two.
292
+ * - `"exact"`: `{tag}` only.
293
+ * Always includes each input tag. Used by the live-query matcher to lower the
294
+ * IDENTICAL expansion the snapshot query engine uses for the same `expand`.
295
+ */
296
+ expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
271
297
  listTags(): Promise<{ name: string; count: number }[]>;
272
298
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
273
299
  renameTag(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.3-rc.3",
3
+ "version": "0.6.0",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -12,7 +12,12 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
12
12
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
13
13
  import { join } from "path";
14
14
  import { tmpdir } from "os";
15
- import { isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
15
+ import {
16
+ isAdminSpaPath,
17
+ isDaemonAdminSpaPath,
18
+ serveAdminSpa,
19
+ serveDaemonAdminSpa,
20
+ } from "./admin-spa.ts";
16
21
 
17
22
  const fixtureDir = join(tmpdir(), `vault-admin-spa-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
23
 
@@ -130,15 +135,76 @@ describe("serveAdminSpa", () => {
130
135
  });
131
136
  });
132
137
 
138
+ describe("isDaemonAdminSpaPath", () => {
139
+ test("matches /vault/admin and true subpaths", () => {
140
+ expect(isDaemonAdminSpaPath("/vault/admin")).toBe(true);
141
+ expect(isDaemonAdminSpaPath("/vault/admin/")).toBe(true);
142
+ expect(isDaemonAdminSpaPath("/vault/admin/assets/index.js")).toBe(true);
143
+ // The doubled path the per-vault regex would mis-read as vault "admin".
144
+ expect(isDaemonAdminSpaPath("/vault/admin/admin")).toBe(true);
145
+ });
146
+
147
+ test("does not match vaults whose name merely starts with 'admin'", () => {
148
+ expect(isDaemonAdminSpaPath("/vault/adminx")).toBe(false);
149
+ expect(isDaemonAdminSpaPath("/vault/admin2/admin")).toBe(false);
150
+ expect(isDaemonAdminSpaPath("/vault/admin-foo")).toBe(false);
151
+ });
152
+
153
+ test("does not match per-vault mounts or unrelated paths", () => {
154
+ expect(isDaemonAdminSpaPath("/vault/work/admin")).toBe(false);
155
+ expect(isDaemonAdminSpaPath("/admin")).toBe(false);
156
+ expect(isDaemonAdminSpaPath("/vaults")).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("serveDaemonAdminSpa (the /vault/admin multi-vault mount)", () => {
161
+ test("bare /vault/admin redirects to trailing-slash form (301)", async () => {
162
+ // Same load-bearing canonicalization as the per-vault mount: Vite's
163
+ // relative asset URLs (./assets/...) resolve against the document's
164
+ // DIRECTORY, so /vault/admin (bare) would resolve assets to
165
+ // /vault/assets/... and 404 them.
166
+ const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin");
167
+ expect(res.status).toBe(301);
168
+ expect(res.headers.get("Location")).toBe("/vault/admin/");
169
+ });
170
+
171
+ test("/vault/admin/ returns the SPA index", async () => {
172
+ const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/");
173
+ expect(res.status).toBe(200);
174
+ expect(res.headers.get("content-type")).toContain("text/html");
175
+ expect(await res.text()).toContain("shell");
176
+ });
177
+
178
+ test("daemon-mount asset path strips cleanly", async () => {
179
+ const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/assets/index-abc.js");
180
+ expect(res.status).toBe(200);
181
+ expect(res.headers.get("content-type")).toContain("application/javascript");
182
+ });
183
+
184
+ test("/vault/admin/admin serves the shell (client route, not a per-vault boot)", async () => {
185
+ const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/admin");
186
+ expect(res.status).toBe(200);
187
+ expect(res.headers.get("content-type")).toContain("text/html");
188
+ expect(await res.text()).toContain("shell");
189
+ });
190
+
191
+ test("path traversal (..) cannot escape dist dir on the daemon mount", async () => {
192
+ const res = await serveDaemonAdminSpa(fixtureDir, "/vault/admin/../../etc/passwd");
193
+ expect(res.status).toBe(200);
194
+ expect(await res.text()).toContain("shell");
195
+ });
196
+ });
197
+
133
198
  describe("hub <-> vault managementUrl contract", () => {
134
199
  // Browsers drop the URL fragment when following a 301 (RFC 7231 SHOULD
135
200
  // preserve, but Chrome/Firefox/Safari are inconsistent in practice). The
136
201
  // 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.
202
+ // token and the SPA boots unauthenticated. Under the B4 URL-resolution
203
+ // semantics (hub#637) a RELATIVE managementUrl is mount-joined per
204
+ // instance (`/vault/<name>` + "/" + "admin/") if it ends with "/" the
205
+ // canonical click target is `/vault/<name>/admin/` (no redirect, fragment
206
+ // preserved). Without the trailing slash hub emits `/vault/<name>/admin`,
207
+ // the server 301s, and the fragment is gone.
142
208
  test("module.json managementUrl ends with '/' so hub emits the no-redirect form", () => {
143
209
  const moduleJson = JSON.parse(
144
210
  readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
@@ -146,16 +212,40 @@ describe("hub <-> vault managementUrl contract", () => {
146
212
  expect(moduleJson.managementUrl).toMatch(/\/$/);
147
213
  });
148
214
 
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
215
+ test("managementUrl + uiUrl are RELATIVE (per-instance); configUiUrl is origin-absolute (daemon-level)", () => {
216
+ // B4 semantics (2026-06-09 hub-module-boundary): relative = mount-joined
217
+ // per instance; leading "/" = origin-absolute verbatim. The per-instance
218
+ // surfaces (manage tile, instance UI) stay per-vault; the module-level
219
+ // config UI points at the daemon-level multi-vault home. A leading "/"
220
+ // on managementUrl/uiUrl here would flip every instance tile to the
221
+ // module home; a relative configUiUrl would wrongly mount-join.
222
+ const moduleJson = JSON.parse(
223
+ readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
224
+ );
225
+ expect(moduleJson.managementUrl).toBe("admin/");
226
+ expect(moduleJson.uiUrl).toBe("admin/");
227
+ expect(moduleJson.configUiUrl).toBe("/vault/admin/");
228
+ });
229
+
230
+ test("the canonical hub-emitted per-instance URL serves the SPA shell directly (no 301)", async () => {
231
+ // Mirror hub's per-instance join under B4: mount + "/" + relative
232
+ // managementUrl. With managementUrl="admin/" the result is
152
233
  // /vault/<name>/admin/ — which serveAdminSpa returns as 200, not 301.
153
234
  const moduleJson = JSON.parse(
154
235
  readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
155
236
  );
156
- const canonical = `/vault/work${moduleJson.managementUrl}`;
237
+ const canonical = `/vault/work/${moduleJson.managementUrl}`;
157
238
  const res = await serveAdminSpa(fixtureDir, canonical);
158
239
  expect(res.status).toBe(200);
159
240
  expect(res.headers.get("Location")).toBeNull();
160
241
  });
242
+
243
+ test("the canonical configUiUrl serves the daemon-level shell directly (no 301)", async () => {
244
+ const moduleJson = JSON.parse(
245
+ readFileSync(join(import.meta.dir, "..", ".parachute", "module.json"), "utf8"),
246
+ );
247
+ const res = await serveDaemonAdminSpa(fixtureDir, moduleJson.configUiUrl);
248
+ expect(res.status).toBe(200);
249
+ expect(res.headers.get("Location")).toBeNull();
250
+ });
161
251
  });
package/src/admin-spa.ts CHANGED
@@ -28,6 +28,18 @@ import { fileURLToPath } from "node:url";
28
28
  */
29
29
  const ADMIN_SPA_MOUNT_RE = /^\/vault\/([^/]+)\/admin(?=\/|$)/;
30
30
 
31
+ /**
32
+ * Regex anchoring the DAEMON-LEVEL multi-vault SPA mount at `/vault/admin`
33
+ * (B3 of the 2026-06-09 hub-module-boundary migration). Deliberately a
34
+ * SEPARATE regex from the per-vault one — merging them would let
35
+ * `/vault/admin/admin` boot per-vault mode with name="admin". `admin` is a
36
+ * reserved vault name (see `vault-name.ts:RESERVED_VAULT_NAMES`), so this
37
+ * mount can never collide with a real instance; routing dispatches it
38
+ * BEFORE the per-vault branch so a pre-reservation squatter is shadowed
39
+ * (and warned about at boot) rather than capturing the mount.
40
+ */
41
+ const DAEMON_ADMIN_SPA_MOUNT_RE = /^\/vault\/admin(?=\/|$)/;
42
+
31
43
  /**
32
44
  * Resolve the default SPA bundle dir. Anchored to this file's location so
33
45
  * a `bun src/server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
@@ -92,18 +104,24 @@ function spaContentType(pathname: string): string {
92
104
  * even before a token has been minted (so the operator can actually see
93
105
  * the empty / auth-required state we render in `VaultDetail.tsx`).
94
106
  */
95
- export async function serveAdminSpa(spaDistDir: string, pathname: string): Promise<Response> {
107
+ export async function serveAdminSpa(
108
+ spaDistDir: string,
109
+ pathname: string,
110
+ mountRe: RegExp = ADMIN_SPA_MOUNT_RE,
111
+ ): Promise<Response> {
96
112
  if (!existsSync(spaDistDir)) {
97
113
  return new Response(
98
114
  "vault admin SPA bundle not found — run `bun run build` in web/ui/ to produce dist/",
99
115
  { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
100
116
  );
101
117
  }
102
- // Strip the mount prefix:
118
+ // Strip the mount prefix (per-vault by default; the daemon-level mount
119
+ // passes its own regex via `serveDaemonAdminSpa`):
103
120
  // /vault/foo/admin → ""
104
121
  // /vault/foo/admin/ → "/"
105
122
  // /vault/foo/admin/x.js → "/x.js"
106
- const sub = pathname.replace(ADMIN_SPA_MOUNT_RE, "");
123
+ // /vault/admin/x.js → "/x.js" (daemon mount)
124
+ const sub = pathname.replace(mountRe, "");
107
125
 
108
126
  // Canonicalize the bare mount → trailing-slash form. Vite emits
109
127
  // *relative* asset URLs (`./assets/index-abc.js`) since `<name>` isn't
@@ -155,7 +173,34 @@ export async function serveAdminSpa(spaDistDir: string, pathname: string): Promi
155
173
  * Match `/vault/<name>/admin` or `/vault/<name>/admin/...`. Bare
156
174
  * `/vault/<name>/admin-foo` and `/vault/<name>` (the metadata endpoint)
157
175
  * must NOT trigger this — only the SPA mount root and its true subpaths.
176
+ *
177
+ * NOTE: `/vault/admin/admin` also matches this regex (name="admin") — the
178
+ * router dispatches `isDaemonAdminSpaPath` FIRST so that path never
179
+ * reaches per-vault mode. Keep that dispatch order; it's pinned in
180
+ * routing.test.ts.
158
181
  */
159
182
  export function isAdminSpaPath(pathname: string): boolean {
160
183
  return ADMIN_SPA_MOUNT_RE.test(pathname);
161
184
  }
185
+
186
+ /**
187
+ * Match the daemon-level multi-vault mount: `/vault/admin` or
188
+ * `/vault/admin/...`. `/vault/adminx` (a real vault that begins with
189
+ * "admin") must NOT trigger this — only the exact segment.
190
+ */
191
+ export function isDaemonAdminSpaPath(pathname: string): boolean {
192
+ return DAEMON_ADMIN_SPA_MOUNT_RE.test(pathname);
193
+ }
194
+
195
+ /**
196
+ * Serve the SPA bundle under the daemon-level `/vault/admin` mount. Same
197
+ * bundle as the per-vault mount — `web/ui/src/lib/mount.ts` detects which
198
+ * basename it booted under at runtime — with the daemon mount's own
199
+ * prefix-strip. The bare-mount 301 inside `serveAdminSpa` fires for
200
+ * `/vault/admin` too: Vite's relative asset URLs resolve against the
201
+ * document's DIRECTORY, so without the trailing-slash canonicalization
202
+ * assets would resolve to `/vault/assets/...` and 404.
203
+ */
204
+ export function serveDaemonAdminSpa(spaDistDir: string, pathname: string): Promise<Response> {
205
+ return serveAdminSpa(spaDistDir, pathname, DAEMON_ADMIN_SPA_MOUNT_RE);
206
+ }