@openparachute/hub 0.6.5-rc.7 → 0.7.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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/hub-server.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * # Pre-rename 301 back-compat (hub#231 — first so they preempt any
17
17
  * # remaining handlers under /vault or /hub).
18
- * /vault, /vault/, /vault/new → 301 → /admin/vaults[/new]
18
+ * /vault, /vault/, /vault/new → 301 → /vault/admin/ (B5:
19
+ * vault's daemon-level admin)
19
20
  * /hub/vaults* → 301 → /admin/vaults*
20
21
  * /hub/permissions → 301 → /admin/permissions
21
22
  * /hub/tokens → 301 → /admin/tokens
@@ -43,8 +44,22 @@
43
44
  *
44
45
  * # Admin API + bearer-mint surfaces (must precede /admin/* SPA mount).
45
46
  * /vaults (POST) → create vault
47
+ * /vaults/<name> (DELETE) → destroy vault + identity cascade
48
+ * (B1: confirm body, host:admin,
49
+ * tokens/grants/user_vaults/invites/
50
+ * connections sweep, CLI remove,
51
+ * supervisor restart)
46
52
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
47
53
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
54
+ * /admin/channel-token (GET) → channel UI bearer mint (cookie-gated)
55
+ * /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
56
+ * /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
57
+ * /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
58
+ * /admin/connections/<id> (DELETE) → connection teardown (cookie-gated; CSRF-belted)
59
+ *
60
+ * # "CSRF-belted" = strict same-origin Origin check on cookie-authed
61
+ * # mutations (hub#632, boundary C1) — origin-check.ts
62
+ * # `assertSameOriginForCookieMutation` carries the canonical enumeration.
48
63
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
49
64
  * /api/hub (GET) → hub version + uptime + install-source (host:admin)
50
65
  * /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
@@ -94,6 +109,11 @@
94
109
  * /admin/config* → 301 → /admin/vaults (legacy
95
110
  * portal retired post-SPA-rework)
96
111
  *
112
+ * # Vault MODULE daemon-level admin surface (B-route, 2026-06-09
113
+ * # hub-module-boundary). Runs BEFORE the per-vault proxy; "admin" is a
114
+ * # reserved vault name so no instance can claim the mount.
115
+ * /vault/admin, /vault/admin/* → proxy to the vault module's daemon
116
+ *
97
117
  * # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
98
118
  * /vault/<name>/* → proxy to the vault backend
99
119
  *
@@ -130,7 +150,13 @@ import pkg from "../package.json" with { type: "json" };
130
150
  import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
131
151
  import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
132
152
  import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
153
+ import { handleChannelToken } from "./admin-channel-token.ts";
133
154
  import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
155
+ import {
156
+ type ConnectionsDeps,
157
+ handleConnections,
158
+ handleConnectionsCatalog,
159
+ } from "./admin-connections.ts";
134
160
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
135
161
  import {
136
162
  handleAdminLoginGet,
@@ -139,8 +165,9 @@ import {
139
165
  handleAdminLogoutPost,
140
166
  } from "./admin-handlers.ts";
141
167
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
168
+ import { handleModuleToken } from "./admin-module-token.ts";
142
169
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
143
- import { handleCreateVault } from "./admin-vaults.ts";
170
+ import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
144
171
  import {
145
172
  handleAccountChangePasswordGet,
146
173
  handleAccountChangePasswordPost,
@@ -151,7 +178,6 @@ import { handleApiHub } from "./api-hub.ts";
151
178
  import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
152
179
  import { handleApiMe } from "./api-me.ts";
153
180
  import { handleApiMintToken } from "./api-mint-token.ts";
154
- import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
155
181
  import {
156
182
  getDefaultOperationsRegistry,
157
183
  handleInstall,
@@ -211,7 +237,7 @@ import {
211
237
  protectedResourceMetadata,
212
238
  } from "./oauth-handlers.ts";
213
239
  import { renderNotFoundPage } from "./oauth-ui.ts";
214
- import { buildHubBoundOrigins } from "./origin-check.ts";
240
+ import { assertSameOriginForCookieMutation, buildHubBoundOrigins } from "./origin-check.ts";
215
241
  import { clearPid, writePid } from "./process-state.ts";
216
242
  import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
217
243
  import { classifyUpstream } from "./proxy-state.ts";
@@ -220,6 +246,7 @@ import {
220
246
  FIRST_PARTY_FALLBACKS,
221
247
  KNOWN_MODULES,
222
248
  effectivePublicExposure,
249
+ findServiceByShort,
223
250
  shortNameForManifest,
224
251
  } from "./service-spec.ts";
225
252
  import { type ServiceEntry, readManifest, readManifestLenient } from "./services-manifest.ts";
@@ -412,6 +439,45 @@ function hasVaultInstalled(manifestPath: string): boolean {
412
439
  }
413
440
  }
414
441
 
442
+ /**
443
+ * Snapshot of every installed module's `.parachute/module.json` + its
444
+ * user-facing mount path, for the Connections engine (2026-06-09 modular-UI
445
+ * architecture, P5). Read at request time so a freshly-installed module's
446
+ * declared events/actions surface without a hub restart. A malformed manifest
447
+ * on one module is skipped (logged), never 500s the whole catalog — same
448
+ * posture as `/api/modules`.
449
+ *
450
+ * `mount` is the first non-`.parachute` services.json path (the proxied
451
+ * user-facing prefix, e.g. `/channel`), which the engine joins with a sink
452
+ * action's `endpoint` to build the hub-proxied webhook.
453
+ */
454
+ async function collectInstalledModules(
455
+ manifestPath: string,
456
+ readManifestFn: (installDir: string) => Promise<ModuleManifest | null>,
457
+ ): Promise<import("./admin-connections.ts").InstalledModuleInfo[]> {
458
+ // Lenient — see hub#406.
459
+ const services = readManifestLenient(manifestPath).services;
460
+ const out: import("./admin-connections.ts").InstalledModuleInfo[] = [];
461
+ await Promise.all(
462
+ services.map(async (entry) => {
463
+ if (!entry.installDir) return;
464
+ const short = shortNameForManifest(entry.name) ?? entry.name;
465
+ const userPath = (entry.paths ?? []).find(
466
+ (p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
467
+ );
468
+ try {
469
+ const manifest = await readManifestFn(entry.installDir);
470
+ if (!manifest) return;
471
+ out.push({ short, manifest, mount: userPath ?? null });
472
+ } catch (err) {
473
+ const msg = err instanceof Error ? err.message : String(err);
474
+ console.warn(`connections: skipping module ${short}: ${msg}`);
475
+ }
476
+ }),
477
+ );
478
+ return out;
479
+ }
480
+
415
481
  /**
416
482
  * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
417
483
  * every request reaches it via one of three trusted forwarders (or directly
@@ -698,6 +764,50 @@ async function proxyToVault(
698
764
  return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
699
765
  }
700
766
 
767
+ /**
768
+ * Reverse-proxy `/vault/admin` + `/vault/admin/*` to the vault MODULE's
769
+ * daemon — the daemon-level multi-vault admin surface (B-route, 2026-06-09
770
+ * hub-module-boundary migration), NOT a per-instance path.
771
+ *
772
+ * Resolution is via `findServiceByShort(services, "vault")` (the canonical
773
+ * self-registered `parachute-vault` row — same shape as the channelEntry
774
+ * lookup in the Connections deps), deliberately NOT `findVaultUpstream`:
775
+ * vault must NOT self-register `/vault/admin` in `paths[]`, because every
776
+ * consumer that derives instance names from paths (`vaultInstanceNameFor`,
777
+ * the well-known vaults[] fan-out, `findExistingVault`, the mint
778
+ * allowlists, the users vault-picker) would fabricate a phantom vault named
779
+ * "admin". The mount is hub-owned and gated on the B2h `admin` name
780
+ * reservation, so no real instance can ever claim it.
781
+ *
782
+ * Applies the SAME `publicExposure: "loopback"` 404-cloak as `proxyToVault`
783
+ * (the per-vault proxy's only layer-gate; "allowed"/"auth-required" pass
784
+ * through and the daemon self-gates). Vault's row declares no `stripPrefix`
785
+ * — the FULL path forwards, so the daemon's own `/vault/admin` routing
786
+ * branch (vault wave, B3) serves it.
787
+ *
788
+ * Returns `undefined` when no vault module is installed (caller 404s).
789
+ * NOTE: legacy per-instance rows named `parachute-vault-<name>` don't
790
+ * resolve through `findServiceByShort` (it only knows the canonical
791
+ * manifest name) — on such an install this surface 404s until vault's boot
792
+ * selfRegister rewrites the canonical row, which is the documented
793
+ * old-install degradation, not a routing hole.
794
+ */
795
+ async function proxyToVaultAdmin(
796
+ req: Request,
797
+ manifestPath: string,
798
+ supervisor: Supervisor | undefined,
799
+ peerAddr: string | null,
800
+ ): Promise<Response | undefined> {
801
+ // Lenient — see hub#406 (same posture as proxyToVault).
802
+ const services = readManifestLenient(manifestPath).services;
803
+ const entry = findServiceByShort(services, "vault");
804
+ if (!entry) return undefined;
805
+ if (effectivePublicExposure(entry) === "loopback" && layerOf(req, peerAddr) !== "loopback") {
806
+ return new Response("not found", { status: 404 });
807
+ }
808
+ return proxyRequest(req, entry.port, "vault", "vault", supervisor);
809
+ }
810
+
701
811
  /**
702
812
  * Resolve which (non-vault) ServiceEntry should handle a given request.
703
813
  * Generic longest-prefix match across every service's `paths[]`. Vault
@@ -784,15 +894,15 @@ async function proxyToService(
784
894
  ) {
785
895
  return new Response("not found", { status: 404 });
786
896
  }
787
- // Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
788
- // Pre-hub#310, scribe's `stripPrefix: true` lived only in hub's vendored
789
- // fallback; post-#310 scribe self-registers with `stripPrefix: true` on
790
- // its row, so the entry-based path is authoritative for scribe. The
791
- // fallback consultation now matters only for notes / channel
792
- // (FIRST_PARTY_FALLBACKS shorts that haven't yet self-registered with
793
- // the canonical declaration). Explicit-on-entry still wins; absent →
794
- // fallback → false (preserving the keep-prefix default for unknown
795
- // services).
897
+ // Consult FIRST_PARTY_FALLBACKS / KNOWN_MODULES as a fallback for
898
+ // `stripPrefix` (#196). Pre-hub#310, scribe's `stripPrefix: true` lived
899
+ // only in hub's vendored fallback; post-#310 scribe (and post-D3 channel)
900
+ // self-register with `stripPrefix: true` on their rows, so the entry-based
901
+ // path is authoritative. The registry consultation now matters only for
902
+ // notes (the remaining FALLBACK short) and legacy rows written before the
903
+ // module emitted the field (KNOWN_MODULES canonicalStripPrefix).
904
+ // Explicit-on-entry still wins; absent → fallback → false (preserving the
905
+ // keep-prefix default for unknown services).
796
906
  const stripPrefix = stripPrefixFor(match.entry);
797
907
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
798
908
  // Resolve canonical short for classification — falls back to the
@@ -807,14 +917,15 @@ async function proxyToService(
807
917
  /**
808
918
  * Resolve effective `stripPrefix` for a service entry. Explicit on-entry
809
919
  * wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (for
810
- * notes / channel — vault/scribe/runner retired their FALLBACK entries in
811
- * hub#310 and self-register with the canonical `stripPrefix` declaration on
812
- * their services.json row). `KNOWN_MODULES[short]?.canonicalStripPrefix`
813
- * is the next fallback — covers the edge case where a self-registering
814
- * module wrote its row before the `stripPrefix` field was being emitted
815
- * (e.g. pre-scribe#50 services.json rows). Defaults to `false` — keep the
816
- * prefix matching the pre-#196 dispatch behavior for unknown / third-
817
- * party services.
920
+ * notes — vault/scribe/runner retired their FALLBACK entries in hub#310,
921
+ * channel in boundary D3; all self-register with the canonical `stripPrefix`
922
+ * declaration on their services.json row).
923
+ * `KNOWN_MODULES[short]?.canonicalStripPrefix` is the next fallback — covers
924
+ * the edge case where a self-registering module wrote its row before the
925
+ * `stripPrefix` field was being emitted (e.g. pre-scribe#50 or pre-D3
926
+ * channel services.json rows). Defaults to `false` keep the prefix
927
+ * matching the pre-#196 dispatch behavior for unknown / third-party
928
+ * services.
818
929
  *
819
930
  * For a self-registering KNOWN_MODULES short whose row is missing entirely
820
931
  * (uninstalled, never booted), the request never reaches this code path —
@@ -873,6 +984,11 @@ export interface HubFetchDeps {
873
984
  * the doc reflect `parachute vault create` etc. without re-running expose.
874
985
  */
875
986
  manifestPath?: string;
987
+ /**
988
+ * Path to `connections.json` (the Connections store, P5). Tests point this
989
+ * at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
990
+ */
991
+ connectionsStorePath?: string;
876
992
  /**
877
993
  * Directory containing the built SPA bundle (`index.html` + `assets/`). When
878
994
  * absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
@@ -1024,8 +1140,9 @@ function defaultSpaDistDir(): string {
1024
1140
  * The admin SPA serves at a single mount: `/admin/*` (since hub#231).
1025
1141
  *
1026
1142
  * Routes:
1027
- * - `/admin/vaults` vault list (the SPA's home)
1028
- * - `/admin/vaults/new` → vault create form
1143
+ * - `/admin/` Home (the admin-shell overview)
1144
+ * - `/admin/vaults` legacy vault list; feature-detects a new-manifest
1145
+ * vault and forwards to `/vault/admin/` (B5)
1029
1146
  * - `/admin/permissions` → OAuth consent grant management
1030
1147
  * - `/admin/tokens` → token registry: mint / list / revoke
1031
1148
  *
@@ -1497,11 +1614,17 @@ export function hubFetch(
1497
1614
  async function dispatch(): Promise<Response> {
1498
1615
  // 301 back-compat for the pre-hub#231 admin-SPA mounts:
1499
1616
  //
1500
- // `/vault` → `/admin/vaults`
1501
- // `/vault/new` → `/admin/vaults/new`
1617
+ // `/vault`, `/vault/new` → `/vault/admin/` (vault's own daemon-level
1618
+ // admin surface — B5, 2026-06-09 hub-module-
1619
+ // boundary migration. These pointed at
1620
+ // `/admin/vaults[/new]` until B5; with the
1621
+ // list+create UX module-owned, pointing DIRECTLY
1622
+ // at the target avoids a redirect → SPA-load →
1623
+ // client-side-forward chain)
1502
1624
  // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
1503
- // it now retargets at the new admin mount instead
1504
- // of the interim `/vault` mount)
1625
+ // /admin/vaults survives as the feature-detected
1626
+ // legacy list, so the target stays valid on both
1627
+ // old-vault and new-vault boxes)
1505
1628
  // `/hub/permissions` → `/admin/permissions`
1506
1629
  // `/hub/tokens` → `/admin/tokens`
1507
1630
  // `/hub` (bare) → `/admin/vaults`
@@ -1513,12 +1636,13 @@ export function hubFetch(
1513
1636
  // POST endpoint to protect.
1514
1637
  //
1515
1638
  // `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
1516
- // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
1639
+ // content proxy (Notes PWA, etc.), not the admin SPA. The exact-match
1640
+ // condition here also never touches `/vault/admin*` — the daemon-level
1641
+ // mount dispatched further down.
1517
1642
  if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
1518
- const sub = pathname === "/vault/new" ? "/new" : "";
1519
1643
  return new Response("", {
1520
1644
  status: 301,
1521
- headers: { location: `/admin/vaults${sub}${url.search}` },
1645
+ headers: { location: `/vault/admin/${url.search}` },
1522
1646
  });
1523
1647
  }
1524
1648
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
@@ -1627,8 +1751,11 @@ export function hubFetch(
1627
1751
  // succeeding, so `probeDbLiveness` alone would report `db:"ok"` on a
1628
1752
  // database that's gone from disk (the /health lie the issue calls
1629
1753
  // out). `probeDbPath` stat()s the path + compares inodes; on a
1630
- // gone/replaced verdict it ALSO self-heals (reopen-or-exit) and we
1631
- // surface the fault so the #591 adoption probe + monitoring see it.
1754
+ // "replaced" verdict it self-heals in-place (reopen-or-exit, adopt
1755
+ // the new inode); on a "gone" verdict it exits the process directly
1756
+ // (#621 — a full wipe needs a clean platform-manager restart, not an
1757
+ // empty-db reopen). Either way we surface the fault so the #591
1758
+ // adoption probe + monitoring see it.
1632
1759
  const pathVerdict = deps?.probeDbPath?.();
1633
1760
  if (pathVerdict === "gone" || pathVerdict === "replaced") {
1634
1761
  // One-request anomaly on "replaced": probeDbPath already healed the
@@ -1792,7 +1919,35 @@ export function hubFetch(
1792
1919
  );
1793
1920
  }
1794
1921
 
1795
- if (pathname === "/" || pathname === "/hub.html") {
1922
+ // Bare `/` `/admin` (admin-shell IA, R1). The home page and the admin
1923
+ // SPA used to be two disconnected surfaces; `/` now funnels straight into
1924
+ // the single coherent admin shell, whose Home/Overview carries the
1925
+ // discovery content (hub-native sections, modules, user surfaces) that
1926
+ // used to live here.
1927
+ //
1928
+ // Ordering matters: this sits AFTER the fresh-hub wizard funnel above
1929
+ // (so a brand-new operator still lands on `/admin/setup`, not a 404 inside
1930
+ // the shell) and AFTER the pre-admin lockout (so an admin-less hub still
1931
+ // 503s API callers correctly). 302 (not 301) — `/` is reclaimed for
1932
+ // future use, but a permanent redirect would get cached and we may want
1933
+ // `/` back later.
1934
+ //
1935
+ // The signed-out path is preserved: a signed-out visitor lands on
1936
+ // `/admin`, where the SPA's AuthIndicator shows a "Sign in" link that
1937
+ // round-trips through `/login?next=/admin/...` and back. We don't pin the
1938
+ // redirect on session state — the shell handles both auth states itself.
1939
+ //
1940
+ // `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
1941
+ // page (used by the static `parachute expose --set-path=/` disk file and
1942
+ // any bookmark to the explicit `.html`). Only the bare `/` redirects.
1943
+ if (pathname === "/") {
1944
+ return new Response(null, {
1945
+ status: 302,
1946
+ headers: { location: "/admin" },
1947
+ });
1948
+ }
1949
+
1950
+ if (pathname === "/hub.html") {
1796
1951
  // When a DB is configured, render the discovery page dynamically so
1797
1952
  // the header carries a "Signed in as <name>" affordance for the
1798
1953
  // active session. Without a DB, fall back to the static disk file
@@ -2060,6 +2215,46 @@ export function hubFetch(
2060
2215
  });
2061
2216
  }
2062
2217
 
2218
+ // DELETE /vaults/<name> — destroy a vault with the full identity
2219
+ // cascade (B1, 2026-06-09 hub-module-boundary: lifecycle symmetry).
2220
+ // Bearer parachute:host:admin + {"confirm": "<name>"} body. See
2221
+ // admin-vaults.handleDeleteVault for the enumerated cascade.
2222
+ if (pathname.startsWith("/vaults/")) {
2223
+ if (!getDb) return dbNotConfigured();
2224
+ const name = decodeURIComponent(pathname.slice("/vaults/".length));
2225
+ const services = readManifestLenient(manifestPath).services;
2226
+ // Channel's row carries its MANIFEST name — resolve via
2227
+ // findServiceByShort (see the /admin/connections note below).
2228
+ const channelEntry = findServiceByShort(services, "channel");
2229
+ const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2230
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2231
+ const match = findVaultUpstream(
2232
+ readManifestLenient(manifestPath).services,
2233
+ `/vault/${vaultName}`,
2234
+ );
2235
+ return match ? `http://127.0.0.1:${match.port}` : null;
2236
+ };
2237
+ const supervisor = deps?.supervisor;
2238
+ return handleDeleteVault(req, name, {
2239
+ db: getDb(),
2240
+ issuer: oauthDeps(req).issuer,
2241
+ manifestPath,
2242
+ connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2243
+ channelOrigin,
2244
+ resolveVaultOrigin,
2245
+ // Daemon eviction — the same in-process supervisor the lifecycle
2246
+ // verbs drive (module-ops API); restarting vault evicts the open
2247
+ // store handle + re-runs selfRegister (services.json path rebuild).
2248
+ ...(supervisor
2249
+ ? {
2250
+ restartVaultModule: async () => {
2251
+ await supervisor.restart("vault");
2252
+ },
2253
+ }
2254
+ : {}),
2255
+ });
2256
+ }
2257
+
2063
2258
  // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
2064
2259
  // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
2065
2260
  // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
@@ -2073,6 +2268,97 @@ export function hubFetch(
2073
2268
  });
2074
2269
  }
2075
2270
 
2271
+ if (pathname === "/admin/channel-token") {
2272
+ if (!getDb) return dbNotConfigured();
2273
+ return handleChannelToken(req, {
2274
+ db: getDb(),
2275
+ issuer: oauthDeps(req).issuer,
2276
+ });
2277
+ }
2278
+
2279
+ // Generic per-module config-UI bearer mint (2026-06-09 modular-UI
2280
+ // architecture, P3). `<short>:admin` for any single-audience module —
2281
+ // the admin scope each module-owned config UI needs to call its own
2282
+ // endpoints. Cookie-gated to the first-admin operator, exactly like
2283
+ // /admin/channel-token + /admin/vault-admin-token. Gated on
2284
+ // self-registration (services.json row + readable module.json) with the
2285
+ // bootstrap registries as a fallback (boundary C5) — a genuinely
2286
+ // third-party module mints here with zero hub code changes. Vault is
2287
+ // per-instance and routed to /admin/vault-admin-token/<name> instead.
2288
+ if (pathname.startsWith("/admin/module-token/")) {
2289
+ if (!getDb) return dbNotConfigured();
2290
+ const short = decodeURIComponent(pathname.slice("/admin/module-token/".length));
2291
+ return handleModuleToken(req, short, {
2292
+ db: getDb(),
2293
+ issuer: oauthDeps(req).issuer,
2294
+ // Lenient + per-request — a module that self-registered since hub
2295
+ // boot is mintable without a restart (see hub#406 for lenient).
2296
+ readServices: () => readManifestLenient(manifestPath).services,
2297
+ ...(deps?.readModuleManifest ? { readModuleManifest: deps.readModuleManifest } : {}),
2298
+ });
2299
+ }
2300
+
2301
+ // Note: the legacy `/admin/channels` bespoke vault-channel orchestration
2302
+ // endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
2303
+ // superseded by the general engine below. Channel's own admin page
2304
+ // drives `/admin/connections` + `/admin/channel-token`.
2305
+
2306
+ // Connections — the GENERAL module event→action engine (2026-06-09
2307
+ // modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
2308
+ // the available events/actions read from each installed module's
2309
+ // `module.json`; `/admin/connections` (GET/POST) lists + provisions;
2310
+ // `/admin/connections/:id` (DELETE) tears down. Cookie-gated to the
2311
+ // first-admin operator. The provisioning engine derives the vault
2312
+ // trigger's webhook + scope from the SINK action's declaration —
2313
+ // nothing is channel-hardcoded.
2314
+ if (
2315
+ pathname === "/api/connections/catalog" ||
2316
+ pathname === "/admin/connections" ||
2317
+ pathname.startsWith("/admin/connections/")
2318
+ ) {
2319
+ if (!getDb) return dbNotConfigured();
2320
+ const services = readManifestLenient(manifestPath).services;
2321
+ // Channel's services.json row carries its MANIFEST name
2322
+ // (`parachute-channel`), not the bare short `channel` — resolve via
2323
+ // findServiceByShort so the lookup matches the on-disk row. (A bare
2324
+ // `s.name === "channel"` never matched, leaving channelOrigin null →
2325
+ // "channel not installed".)
2326
+ const channelEntry = findServiceByShort(services, "channel");
2327
+ const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2328
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2329
+ const match = findVaultUpstream(
2330
+ readManifestLenient(manifestPath).services,
2331
+ `/vault/${vaultName}`,
2332
+ );
2333
+ return match ? `http://127.0.0.1:${match.port}` : null;
2334
+ };
2335
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
2336
+ const modules = await collectInstalledModules(manifestPath, readManifestFn);
2337
+ const connectionsDeps: ConnectionsDeps = {
2338
+ db: getDb(),
2339
+ hubOrigin: oauthDeps(req).issuer,
2340
+ modules,
2341
+ resolveVaultOrigin,
2342
+ channelOrigin,
2343
+ storePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2344
+ };
2345
+ if (pathname === "/api/connections/catalog") {
2346
+ return handleConnectionsCatalog(req, connectionsDeps);
2347
+ }
2348
+ // CSRF belt (hub#632, boundary C1): cookie-authed POST/DELETE must
2349
+ // carry a matching Origin. The seam's canonical consumer — channel's
2350
+ // admin page POSTing link-vault with `credentials: "include"` — is a
2351
+ // same-origin fetch() and passes; see origin-check.ts
2352
+ // `assertSameOriginForCookieMutation` for the belted-endpoint
2353
+ // enumeration.
2354
+ {
2355
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
2356
+ if (rejected) return rejected;
2357
+ }
2358
+ const subPath = pathname.slice("/admin/connections".length);
2359
+ return handleConnections(req, subPath, connectionsDeps);
2360
+ }
2361
+
2076
2362
  if (pathname.startsWith("/admin/vault-admin-token/")) {
2077
2363
  if (!getDb) return dbNotConfigured();
2078
2364
  const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
@@ -2215,28 +2501,12 @@ export function hubFetch(
2215
2501
  });
2216
2502
  }
2217
2503
 
2218
- // Per-module config surface (hub#260) schema + values GET, values PUT.
2219
- // Sits ahead of the install/restart/upgrade/uninstall switch below so
2220
- // `/api/modules/<short>/config[/schema]` doesn't fall into the default-
2221
- // branch 404 (`parseModulesPath` only matches the action-suffix shape,
2222
- // not the `config` / `config/schema` shape).
2223
- //
2224
- // Diverges from the action endpoints in two ways: doesn't require a
2225
- // supervisor (we just proxy to the running module's HTTP surface, not
2226
- // spawn a child), and mints a `<short>:admin` token at proxy time so
2227
- // the upstream auth gate is satisfied without forcing the SPA bearer
2228
- // to carry per-module scopes.
2229
- {
2230
- const configMatch = parseModulesConfigPath(pathname);
2231
- if (configMatch) {
2232
- if (!getDb) return dbNotConfigured();
2233
- return handleApiModulesConfig(req, configMatch, {
2234
- db: getDb(),
2235
- issuer: oauthDeps(req).issuer,
2236
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2237
- });
2238
- }
2239
- }
2504
+ // NOTE: the hub-hosted generic per-module config proxy
2505
+ // (`/api/modules/<short>/config[/schema]`) + its SPA form were RETIRED in
2506
+ // the 2026-06-09 modular-UI architecture P3. Config is module-owned +
2507
+ // hub-framed now: the Modules page "Configure" action opens the module's
2508
+ // OWN config UI (`configUiUrl`), which mints its admin Bearer from the
2509
+ // cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
2240
2510
 
2241
2511
  // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
2242
2512
  if (pathname.startsWith("/api/modules/")) {
@@ -2666,8 +2936,10 @@ export function hubFetch(
2666
2936
  }
2667
2937
 
2668
2938
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
2669
- // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
2670
- // post-login redirect lands somewhere useful. The route stays here in
2939
+ // retired post-SPA-rework. 301 → /admin/vaults so any bookmark or stale
2940
+ // post-login redirect lands somewhere useful (post-B5 that's the
2941
+ // feature-detected vaults surface — legacy list on an old-vault box,
2942
+ // forward to /vault/admin/ on a new one). The route stays here in
2671
2943
  // dispatch order (above the /admin/* SPA catch-all) so the redirect
2672
2944
  // wins over a SPA shell render.
2673
2945
  if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
@@ -2677,10 +2949,31 @@ export function hubFetch(
2677
2949
  });
2678
2950
  }
2679
2951
 
2952
+ // /vault/admin + /vault/admin/* — the vault MODULE's daemon-level
2953
+ // admin surface (B-route, 2026-06-09 hub-module-boundary). MUST run
2954
+ // BEFORE the per-vault proxy dispatch below: this is a module-level
2955
+ // mount, not an instance path. "admin" is a reserved vault name (B2h)
2956
+ // so no instance can claim it, and `findVaultUpstream` never sees it —
2957
+ // no consumer fabricates a phantom vault named "admin". Exact-segment
2958
+ // match only: a vault instance named e.g. "adminx" still routes
2959
+ // per-instance through the branch below.
2960
+ if (pathname === "/vault/admin" || pathname.startsWith("/vault/admin/")) {
2961
+ // Same per-request force-change-password gate as the per-vault proxy
2962
+ // (P0-1 / hub#469) — a pre-rotation signed-in user can't reach the
2963
+ // multi-vault admin surface on an un-rotated temp password either.
2964
+ if (getDb) {
2965
+ const gate = forceChangePasswordGate(getDb(), req);
2966
+ if (gate) return gate;
2967
+ }
2968
+ const proxied = await proxyToVaultAdmin(req, manifestPath, deps?.supervisor, peerAddr);
2969
+ if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2970
+ return new Response("not found", { status: 404 });
2971
+ }
2972
+
2680
2973
  // /vault/<name>/* — per-vault content proxy. Stays as user-facing
2681
2974
  // surface (the Notes PWA loads through here, etc.). The bare `/vault`
2682
2975
  // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
2683
- // the top of dispatch now. Multi-segment requests like
2976
+ // the top of dispatch now (to `/vault/admin/` since B5). Multi-segment requests like
2684
2977
  // `/vault/<unknown>/health` are vault-API shapes targeting a
2685
2978
  // non-existent vault and 404 directly — there's no SPA-shell fallback
2686
2979
  // here anymore (the SPA moved to /admin), so we can't accidentally
@@ -2710,9 +3003,9 @@ export function hubFetch(
2710
3003
  // SPA's own router renders the page and handles 404 client-side for
2711
3004
  // unknown sub-paths.
2712
3005
  if (pathname === "/admin" || pathname === "/admin/") {
2713
- // Unprefixed /admin → SPA shell pointed at the vault list (its home).
2714
- // The SPA's basename is /admin, so the router will land on / and
2715
- // render VaultsList.
3006
+ // Unprefixed /admin → SPA shell at its index route. The SPA's
3007
+ // basename is /admin, so the router lands on / and renders Home
3008
+ // (the admin-shell overview).
2716
3009
  if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2717
3010
  return serveSpa(spaDistDir, pathname, "/admin");
2718
3011
  }
package/src/invites.ts CHANGED
@@ -289,3 +289,25 @@ export function revokeInvite(db: Database, tokenHash: string, now: Date = new Da
289
289
  .run(now.toISOString(), tokenHash);
290
290
  return res.changes > 0;
291
291
  }
292
+
293
+ /**
294
+ * Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): invalidate
295
+ * every UNREDEEMED invite pinned to the deleted vault. An un-revoked pending
296
+ * invite carrying `vault_name = <deleted>` would re-provision (resurrect)
297
+ * the name on redemption — the cascade must close that door. Used/already-
298
+ * revoked invites are untouched (terminal states). `vault_name` is an exact
299
+ * `=` comparison — no pattern matching. Returns the number of invites
300
+ * newly revoked.
301
+ */
302
+ export function revokeInvitesForVault(
303
+ db: Database,
304
+ vaultName: string,
305
+ now: Date = new Date(),
306
+ ): number {
307
+ const res = db
308
+ .prepare(
309
+ "UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_at IS NULL AND revoked_at IS NULL",
310
+ )
311
+ .run(now.toISOString(), vaultName);
312
+ return Number(res.changes);
313
+ }