@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
@@ -431,6 +431,70 @@ describe("/vault/<name>/admin/* SPA mount", () => {
431
431
  expect(res.status).toBe(401);
432
432
  });
433
433
 
434
+ // ---------------------------------------------------------------------
435
+ // /vault/admin[/*] — DAEMON-LEVEL multi-vault SPA mount (B3, 2026-06-09
436
+ // hub-module-boundary migration). `admin` is a reserved vault name (B2),
437
+ // and this branch dispatches BEFORE both the per-vault SPA mount and the
438
+ // per-vault dispatcher. Detailed serving behavior (the bare-mount 301,
439
+ // asset strip) is pinned in admin-spa.test.ts with a tmp dist dir.
440
+ // ---------------------------------------------------------------------
441
+
442
+ test("/vault/admin/ fires the SPA layer, never the per-vault 'Vault not found' JSON", async () => {
443
+ // No vault named "admin" exists (it can't — reserved). Without the
444
+ // daemon-level branch this path would fall to the per-vault dispatcher
445
+ // and 404 as JSON.
446
+ const req = new Request("http://localhost:1940/vault/admin/");
447
+ const res = await route(req, "/vault/admin/");
448
+ expect(res.status === 200 || res.status === 503).toBe(true);
449
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
450
+ });
451
+
452
+ test("/vault/admin/admin does NOT boot per-vault mode for a vault named 'admin'", async () => {
453
+ // The per-vault regex would capture name="admin" here. The daemon-level
454
+ // branch must win — the regexes are deliberately not merged.
455
+ const req = new Request("http://localhost:1940/vault/admin/admin");
456
+ const res = await route(req, "/vault/admin/admin");
457
+ expect(res.status === 200 || res.status === 503).toBe(true);
458
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
459
+ });
460
+
461
+ test("a squatted vault named 'admin' is shadowed — its data plane serves the SPA layer", async () => {
462
+ // A vault created before the reservation landed. The daemon mount wins
463
+ // over its entire /vault/admin/* surface (server boot warns with the
464
+ // recovery procedure — see vault-name.ts:reservedNameSquatWarnings).
465
+ createVault("admin");
466
+ const req = new Request("http://localhost:1940/vault/admin/api/notes");
467
+ const res = await route(req, "/vault/admin/api/notes");
468
+ // The per-vault API would 401 (auth wall); the SPA layer serves the
469
+ // static shell (200) or the unbuilt-dist 503 — never the API's JSON.
470
+ expect(res.status === 200 || res.status === 503).toBe(true);
471
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
472
+ });
473
+
474
+ test("POST /vault/admin/ returns 405 (daemon mount is GET-only)", async () => {
475
+ const req = new Request("http://localhost:1940/vault/admin/", { method: "POST" });
476
+ const res = await route(req, "/vault/admin/");
477
+ expect(res.status).toBe(405);
478
+ });
479
+
480
+ test("/vault/adminx/* does NOT match the daemon mount — routes per-vault", async () => {
481
+ // Exact-segment match only: a real vault whose name merely starts with
482
+ // "admin" keeps its normal per-vault surface (the API auth wall 401s,
483
+ // proving the per-vault dispatcher handled it).
484
+ createVault("adminx");
485
+ const req = new Request("http://localhost:1940/vault/adminx/api/notes");
486
+ const res = await route(req, "/vault/adminx/api/notes");
487
+ expect(res.status).toBe(401);
488
+ });
489
+
490
+ test("per-vault /vault/<real>/admin/ is unaffected by the daemon mount", async () => {
491
+ createVault("work");
492
+ const req = new Request("http://localhost:1940/vault/work/admin/");
493
+ const res = await route(req, "/vault/work/admin/");
494
+ expect(res.status === 200 || res.status === 503).toBe(true);
495
+ expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
496
+ });
497
+
434
498
  test("origin-rooted /admin (legacy mount retired) returns 404", async () => {
435
499
  // Pre-vault#252 the SPA was at /admin/*. Routing now lets that fall
436
500
  // through to the catch-all — hub's directory page should link to
package/src/routing.ts CHANGED
@@ -15,6 +15,9 @@
15
15
  * /vaults/list — public vault-name discovery (can be
16
16
  * disabled globally via config)
17
17
  * /vaults — authenticated vault metadata list
18
+ * /vault/admin[/*] — daemon-level multi-vault admin SPA
19
+ * (B3; `admin` is a reserved vault
20
+ * name, dispatched BEFORE per-vault)
18
21
  * /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
19
22
  * the hub as the authorization server
20
23
  * (vault is resource-server only)
@@ -52,7 +55,13 @@ import {
52
55
  import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
53
56
  import { getVaultStore } from "./vault-store.ts";
54
57
  import { handleScopedMcp } from "./mcp-http.ts";
55
- import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
58
+ import {
59
+ defaultAdminSpaDistDir,
60
+ isAdminSpaPath,
61
+ isDaemonAdminSpaPath,
62
+ serveAdminSpa,
63
+ serveDaemonAdminSpa,
64
+ } from "./admin-spa.ts";
56
65
  import {
57
66
  handleNotes,
58
67
  handleTags,
@@ -63,6 +72,8 @@ import {
63
72
  handleViewNote,
64
73
  type TagScopeCtx,
65
74
  } from "./routes.ts";
75
+ import { handleSubscribe } from "./subscribe.ts";
76
+ import { handleTriggers } from "./triggers-api.ts";
66
77
  import { expandTokenTagScope } from "./tag-scope.ts";
67
78
  import {
68
79
  handleProtectedResource,
@@ -287,6 +298,26 @@ export async function route(
287
298
  });
288
299
  }
289
300
 
301
+ // Daemon-level multi-vault admin SPA — `/vault/admin[/*]` (B3 of the
302
+ // 2026-06-09 hub-module-boundary migration). The vault MODULE's own home
303
+ // surface: list / create / delete vaults, deep-link into each instance's
304
+ // per-vault admin. Same static bundle as the per-vault mount below —
305
+ // `web/ui/src/lib/mount.ts` detects the basename at runtime.
306
+ //
307
+ // MUST dispatch BEFORE `isAdminSpaPath`: the per-vault regex also matches
308
+ // `/vault/admin/admin` (capturing name="admin"), which must NOT boot
309
+ // per-vault mode — `admin` is a reserved vault name (B2) and this mount is
310
+ // daemon-owned. Hub#637's `/vault/admin` route forwards the FULL path here
311
+ // (no stripPrefix on vault's services row). A pre-reservation squatter
312
+ // vault named "admin" is fully shadowed by this branch — server boot
313
+ // warns with the recovery procedure (see vault-name.ts).
314
+ if (isDaemonAdminSpaPath(path)) {
315
+ if (req.method !== "GET") {
316
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
317
+ }
318
+ return serveDaemonAdminSpa(defaultAdminSpaDistDir(), path);
319
+ }
320
+
290
321
  // Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
291
322
  // file serving only (index.html + Vite asset bundle); no auth at this
292
323
  // seam since the bundle reveals nothing privileged. The SPA's data
@@ -728,6 +759,18 @@ export async function route(
728
759
  return Response.json({ error: "Not found" }, { status: 404 });
729
760
  }
730
761
 
762
+ // /api/triggers — runtime trigger-registration API. Dispatched BEFORE the
763
+ // generic method→verb scope gate below because it's ADMIN-scoped on EVERY
764
+ // method (a webhook trigger exfiltrates note data; even GET is admin). The
765
+ // gate lives inside `handleTriggers`. Subpath is everything after
766
+ // `/api/triggers`. See src/triggers-api.ts.
767
+ {
768
+ const triggersPath = apiMatch[1] ?? "";
769
+ if (triggersPath === "/triggers" || triggersPath.startsWith("/triggers/")) {
770
+ return handleTriggers(req, store, triggersPath.slice("/triggers".length), vaultName, auth);
771
+ }
772
+ }
773
+
731
774
  // REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
732
775
  // POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
733
776
  // and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
@@ -762,6 +805,10 @@ export async function route(
762
805
  };
763
806
 
764
807
  if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
808
+ // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
809
+ // upsert/remove events over text/event-stream. Auth + tag-scope already
810
+ // resolved above and threaded through, mirroring the /notes branch.
811
+ if (apiPath === "/subscribe") return handleSubscribe(req, store, vaultName, tagScope);
765
812
  if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
766
813
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
767
814
  if (apiPath === "/vault") {
package/src/server.ts CHANGED
@@ -15,13 +15,14 @@
15
15
  * The request pipeline lives in ./routing.ts (exported for unit testing).
16
16
  */
17
17
 
18
- import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
18
+ import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath, bootAutoCreateAllowed } from "./config.ts";
19
19
  import { existsSync, rmSync } from "fs";
20
20
  import { migrateVaultKeys } from "./token-store.ts";
21
- import { resolveFirstBootVaultName } from "./vault-name.ts";
21
+ import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
22
22
  import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
23
23
  import { defaultHookRegistry } from "../core/src/hooks.ts";
24
24
  import { registerTriggers } from "./triggers.ts";
25
+ import { loadVaultTriggers } from "./triggers-api.ts";
25
26
  import { route } from "./routing.ts";
26
27
  import { startTranscriptionWorker, registerTranscriptionHook, type TranscriptionWorker } from "./transcription-worker.ts";
27
28
  import { setTranscriptionWorker } from "./transcription-registry.ts";
@@ -155,9 +156,20 @@ warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
155
156
  // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
156
157
  // falls back to "default". Hub's first-boot wizard (hub#267) passes through
157
158
  // an operator-chosen name via this env var.
159
+ //
160
+ // Gated on the `auto_create: false` marker (2026-06-09 hub-module-boundary
161
+ // migration): `parachute-vault remove` writes it when the operator deletes
162
+ // their LAST vault, so an explicit empty-the-server action isn't silently
163
+ // undone by a resurrection with fresh credentials on the next boot. Fresh
164
+ // installs have no config.yaml — no marker — so Docker first-run still
165
+ // auto-creates.
158
166
  if (listVaults().length === 0) {
159
167
  const globalConfig = readGlobalConfig();
160
- if (!globalConfig.default_vault) {
168
+ if (!bootAutoCreateAllowed(globalConfig)) {
169
+ console.log(
170
+ '[vault first-boot] auto-create disabled (auto_create: false in config.yaml — the last vault was removed deliberately). Create one with: parachute-vault create <name>',
171
+ );
172
+ } else if (!globalConfig.default_vault) {
161
173
  const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
162
174
  if (firstBoot.source === "env") {
163
175
  console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
@@ -204,6 +216,15 @@ if (listVaults().length === 0) {
204
216
  // survive — see self-register.ts header for the v0.6 vs v0.7 design note.
205
217
  selfRegister({ version: pkg.version });
206
218
 
219
+ // Loud boot warning for vaults squatting a shadowed reserved name (B2 of the
220
+ // 2026-06-09 hub-module-boundary migration). A vault named `admin`/`new`/
221
+ // `assets` (created before the names were reserved) has its entire
222
+ // `/vault/<name>/*` data plane shadowed by reserved hub/daemon routes — warn
223
+ // with the recovery procedure rather than silently serving nothing.
224
+ for (const warning of reservedNameSquatWarnings(listVaults())) {
225
+ console.warn(warning);
226
+ }
227
+
207
228
  // Migrate tag schemas from vault.yaml → DB for each vault.
208
229
  // Only inserts schemas that don't already exist in the DB (safe across restarts).
209
230
  for (const vaultName of listVaults()) {
@@ -244,6 +265,31 @@ for (const vaultName of listVaults()) {
244
265
  }
245
266
  }
246
267
 
268
+ // Load persisted runtime triggers for each vault and register them
269
+ // vault-scoped on the shared hook registry (frictionless-channel-setup PR 1).
270
+ // Runs alongside the config.yaml trigger registration above; config.yaml
271
+ // triggers stay global, these fire only for their own vault. Survives
272
+ // restart — the `triggers` table is the source of truth. Per-vault failure
273
+ // is isolated so one bad vault can't block the others.
274
+ {
275
+ let totalTriggers = 0;
276
+ for (const vaultName of listVaults()) {
277
+ try {
278
+ const store = getVaultStore(vaultName);
279
+ const n = loadVaultTriggers(vaultName, store);
280
+ totalTriggers += n;
281
+ if (n > 0) {
282
+ console.log(`[triggers] loaded ${n} runtime trigger(s) for vault "${vaultName}"`);
283
+ }
284
+ } catch (err) {
285
+ console.error(`[triggers] failed to load runtime triggers for vault "${vaultName}":`, err);
286
+ }
287
+ }
288
+ if (totalTriggers === 0) {
289
+ console.log("[triggers] no persisted runtime triggers");
290
+ }
291
+ }
292
+
247
293
  const globalConfig = readGlobalConfig();
248
294
  const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PORT;
249
295
  const hostname = resolveBindHostname();