@openparachute/hub 0.6.5-rc.8 → 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 (50) 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-server.test.ts +319 -21
  12. package/src/__tests__/invites.test.ts +27 -0
  13. package/src/__tests__/module-manifest.test.ts +305 -8
  14. package/src/__tests__/serve-boot.test.ts +133 -2
  15. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  16. package/src/__tests__/setup-gate.test.ts +13 -7
  17. package/src/__tests__/setup-wizard.test.ts +228 -1
  18. package/src/__tests__/vault-name.test.ts +20 -5
  19. package/src/__tests__/well-known.test.ts +44 -8
  20. package/src/account-vault-admin-token.ts +43 -14
  21. package/src/admin-channel-token.ts +135 -0
  22. package/src/admin-connections.ts +980 -0
  23. package/src/admin-module-token.ts +197 -0
  24. package/src/admin-vaults.ts +390 -12
  25. package/src/api-hub-upgrade.ts +4 -3
  26. package/src/api-modules-ops.ts +41 -16
  27. package/src/api-modules.ts +238 -116
  28. package/src/api-tokens.ts +8 -5
  29. package/src/commands/serve-boot.ts +80 -3
  30. package/src/commands/setup.ts +4 -4
  31. package/src/connections-store.ts +161 -0
  32. package/src/grants.ts +50 -0
  33. package/src/hub-server.ts +349 -59
  34. package/src/invites.ts +22 -0
  35. package/src/jwt-sign.ts +41 -1
  36. package/src/module-manifest.ts +429 -23
  37. package/src/origin-check.ts +106 -0
  38. package/src/proxy-error-ui.ts +1 -1
  39. package/src/service-spec.ts +132 -41
  40. package/src/setup-wizard.ts +68 -6
  41. package/src/users.ts +11 -0
  42. package/src/vault-name.ts +27 -7
  43. package/src/well-known.ts +41 -33
  44. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  45. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  46. package/web/ui/dist/index.html +2 -2
  47. package/src/__tests__/api-modules-config.test.ts +0 -882
  48. package/src/api-modules-config.ts +0 -421
  49. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  50. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `GET /admin/module-token/<short>` — exchange a valid admin session cookie for
3
+ * a short-lived JWT carrying `<short>:admin` (audience = the bare module short).
4
+ *
5
+ * Why this exists (2026-06-09 modular-UI architecture, P3): modules now own
6
+ * their config/admin UIs and declare `configUiUrl` in `module.json` (scribe
7
+ * `/scribe/admin`, runner `/runner/admin`, surface `/surface/admin/`, …). The
8
+ * hub frames/links those surfaces consistently (the Modules page "Configure"
9
+ * action). Each module-owned config UI, served behind the hub proxy to a
10
+ * logged-in portal operator, needs an admin-scoped hub Bearer to call its own
11
+ * `<short>:admin`-gated endpoints — the same shape the channel config UI gets
12
+ * from `/admin/channel-token` and the vault admin SPA gets from
13
+ * `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
14
+ * other self-registered single-audience module, so the hub doesn't grow a
15
+ * bespoke per-module mint endpoint as each module ships a config UI.
16
+ *
17
+ * Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
18
+ * prefix). Modules validate the JWT's `aud` against their literal short name
19
+ * (`scribe`, `runner`, `surface`, `channel`) — the same shape `inferAudience`
20
+ * stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
21
+ * token are indistinguishable to the module. This mirrors the per-request
22
+ * `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
23
+ * difference is this endpoint hands the token to the module's OWN UI rather
24
+ * than proxying a hub-side config form.
25
+ *
26
+ * Multi-vault note: VAULT is excluded here. Vault's admin scope is per-instance
27
+ * (`vault:<name>:admin`, audience `vault.<name>`), which needs a vault-name
28
+ * parameter and a known-vault check — that lives in `/admin/vault-admin-token/
29
+ * <name>` (`admin-vault-admin-token.ts`). A request for `vault` here returns a
30
+ * 400 pointing at that endpoint, so a caller can't accidentally mint a useless
31
+ * bare `vault:admin`.
32
+ *
33
+ * Gate: the session must belong to the first admin (the single hub admin under
34
+ * the Phase 1 multi-user model — `users.ts:isFirstAdmin`), exactly like
35
+ * host-admin-token / vault-admin-token / channel-token. A friend account holds
36
+ * a valid session but must not mint a module admin Bearer.
37
+ *
38
+ * Tokens are short-lived (10 min — matches the sibling admin-token mints); the
39
+ * config UI re-fetches on near-expiry.
40
+ */
41
+ import type { Database } from "bun:sqlite";
42
+ import { signAccessToken } from "./jwt-sign.ts";
43
+ import {
44
+ type ModuleManifest,
45
+ readModuleManifest as defaultReadModuleManifest,
46
+ } from "./module-manifest.ts";
47
+ import { findServiceByShort, isKnownModuleShort } from "./service-spec.ts";
48
+ import type { ServiceEntry } from "./services-manifest.ts";
49
+ import { findSession, parseSessionCookie } from "./sessions.ts";
50
+ import { isFirstAdmin } from "./users.ts";
51
+
52
+ /** Short TTL — matches host/vault/channel admin-token. UI re-fetches on near-expiry. */
53
+ export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
54
+ const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
55
+
56
+ /** Lowercase short-name charset, matching the module-name rule + path parsing. */
57
+ const MODULE_SHORT_RE = /^[a-z][a-z0-9-]*$/;
58
+
59
+ export interface MintModuleTokenDeps {
60
+ db: Database;
61
+ /** Hub origin — written into JWT `iss`. */
62
+ issuer: string;
63
+ /**
64
+ * Snapshot of services.json rows, read at request time so a module that
65
+ * self-registered since hub boot is mintable without a restart. Used by the
66
+ * self-registration gate (boundary C5) — see {@link isSelfRegisteredModule}.
67
+ */
68
+ readServices: () => readonly ServiceEntry[];
69
+ /** Test seam — defaults to the real `readModuleManifest` (disk read). */
70
+ readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
71
+ }
72
+
73
+ /**
74
+ * The self-registration gate (C5 of the 2026-06-09 hub-module-boundary
75
+ * migration): a short is mintable when it resolves to a services.json row
76
+ * whose `installDir` carries a readable `.parachute/module.json`.
77
+ *
78
+ * Resolution mirrors the rest of the hub (`/api/modules`,
79
+ * `collectInstalledModules`): first-party rows resolve through
80
+ * `findServiceByShort` (manifest name ↔ short map); a genuinely third-party
81
+ * row matches by its literal services.json `name` — the same
82
+ * `shortNameForManifest(name) ?? name` convention the catalog uses.
83
+ *
84
+ * Why this is enough against a forged short: services.json is written only by
85
+ * same-disk modules, which the charter's trust statement already covers — an
86
+ * installed module runs a daemon on the operator's machine, strictly more
87
+ * power than an admin Bearer. Requiring the registered row AND a readable
88
+ * manifest still keeps a typo'd short from minting `<anything>:admin` and
89
+ * masquerading as a real (but unusable) credential.
90
+ */
91
+ async function isSelfRegisteredModule(short: string, deps: MintModuleTokenDeps): Promise<boolean> {
92
+ const services = deps.readServices();
93
+ const row =
94
+ findServiceByShort(services, short) ?? services.find((s): boolean => s.name === short);
95
+ if (!row?.installDir) return false;
96
+ const readManifest = deps.readModuleManifest ?? defaultReadModuleManifest;
97
+ try {
98
+ const manifest = await readManifest(row.installDir);
99
+ return manifest !== null;
100
+ } catch {
101
+ // Malformed manifest — treat as not-a-module rather than 500ing the mint.
102
+ return false;
103
+ }
104
+ }
105
+
106
+ export async function handleModuleToken(
107
+ req: Request,
108
+ short: string,
109
+ deps: MintModuleTokenDeps,
110
+ ): Promise<Response> {
111
+ if (req.method !== "GET") {
112
+ return jsonError(405, "method_not_allowed", "use GET");
113
+ }
114
+ if (!MODULE_SHORT_RE.test(short)) {
115
+ return jsonError(400, "invalid_request", `module short "${short}" is not a valid identifier`);
116
+ }
117
+ // Vault is per-instance — its admin scope needs a vault name. Route the caller
118
+ // to the dedicated per-vault endpoint rather than minting a useless bare
119
+ // `vault:admin` (no vault validates that audience).
120
+ if (short === "vault") {
121
+ return jsonError(
122
+ 400,
123
+ "use_vault_admin_token",
124
+ "vault admin tokens are per-instance — use GET /admin/vault-admin-token/<name>",
125
+ );
126
+ }
127
+ // Only mint for modules the hub can verify exist. Two paths (boundary C5 —
128
+ // closes the charter's third-party-test gap, where this endpoint used to
129
+ // require bootstrap-registry presence):
130
+ // 1. SELF-REGISTERED: the short resolves to a services.json row whose
131
+ // installDir carries a readable module.json. This is the canonical
132
+ // gate — any module (first- or third-party) that completed
133
+ // self-registration mints with zero hub code changes, per
134
+ // `parachute-patterns/patterns/hub-module-boundary.md` (the seam,
135
+ // mechanism 1).
136
+ // 2. KNOWN bootstrap short (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS): kept
137
+ // as a fallback so a first-party module mid-install (row not yet
138
+ // written) still mints.
139
+ // Either way a forged/typo'd short can't mint `<anything>:admin` and mask
140
+ // itself as a real (but unusable) credential.
141
+ if (!isKnownModuleShort(short) && !(await isSelfRegisteredModule(short, deps))) {
142
+ return jsonError(404, "not_found", `no module "${short}" known to this hub`);
143
+ }
144
+ const sid = parseSessionCookie(req.headers.get("cookie"));
145
+ const session = sid ? findSession(deps.db, sid) : null;
146
+ if (!session) {
147
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
148
+ }
149
+ // First-admin gate (mirrors host/vault/channel-admin-token). A friend account
150
+ // (non-first-admin user) holds a valid session but must not mint a module
151
+ // admin Bearer.
152
+ if (!isFirstAdmin(deps.db, session.userId)) {
153
+ return jsonError(
154
+ 403,
155
+ "not_admin",
156
+ "module admin token mint is restricted to the hub admin — your account home is at /account/",
157
+ );
158
+ }
159
+ const scope = `${short}:admin`;
160
+ const minted = await signAccessToken(deps.db, {
161
+ sub: session.userId,
162
+ scopes: [scope],
163
+ // Bare service audience — modules validate `aud === <short>`, the same
164
+ // shape `inferAudience` stamps for the OAuth flow.
165
+ audience: short,
166
+ clientId: MODULE_TOKEN_CLIENT_ID,
167
+ issuer: deps.issuer,
168
+ ttlSeconds: MODULE_TOKEN_TTL_SECONDS,
169
+ // No per-user vault pin — this Bearer talks to a module-scoped endpoint,
170
+ // not a single vault. Empty `vault_scope` is the "no per-user restriction"
171
+ // sentinel, matching host-admin / channel tokens.
172
+ vaultScope: [],
173
+ });
174
+ return new Response(
175
+ JSON.stringify({
176
+ token: minted.token,
177
+ expires_at: minted.expiresAt,
178
+ scopes: [scope],
179
+ }),
180
+ {
181
+ status: 200,
182
+ headers: {
183
+ "content-type": "application/json",
184
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
185
+ // back/forward navigation could hand the UI a long-expired JWT.
186
+ "cache-control": "no-store",
187
+ },
188
+ },
189
+ );
190
+ }
191
+
192
+ function jsonError(status: number, error: string, description: string): Response {
193
+ return new Response(JSON.stringify({ error, error_description: description }), {
194
+ status,
195
+ headers: { "content-type": "application/json" },
196
+ });
197
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * `POST /vaults` — provision a new vault on the host.
3
+ * `DELETE /vaults/<name>` — destroy a vault WITH the identity cascade
4
+ * (B1, 2026-06-09 hub-module-boundary migration — see `handleDeleteVault`).
3
5
  *
4
6
  * The hub's first authenticated, mutating endpoint. Until now the hub has
5
7
  * been a pure issuer; Phase 1 of the vault-config-and-scopes design (D1)
@@ -57,30 +59,29 @@
57
59
  */
58
60
  import type { Database } from "bun:sqlite";
59
61
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
62
+ import { type ConnectionsDeps, teardownConnection } from "./admin-connections.ts";
60
63
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
64
+ import { readConnections } from "./connections-store.ts";
65
+ import { rewriteGrantsRemovingVault } from "./grants.ts";
66
+ import { revokeInvitesForVault } from "./invites.ts";
67
+ import { revokeTokensNamingVault, signAccessToken } from "./jwt-sign.ts";
61
68
  import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
62
69
  import { enrichedPath } from "./spawn-path.ts";
63
- import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
70
+ import { removeVaultAssignments } from "./users.ts";
71
+ import { RESERVED_VAULT_NAMES, VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
64
72
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
65
73
 
66
74
  /** Scope required to call POST /vaults. */
67
75
  export const HOST_ADMIN_SCOPE = "parachute:host:admin";
68
76
 
69
- /**
70
- * Mirror parachute-vault's `cmdCreate` validation rules, plus hub-only
71
- * reservations for SPA-route shadowing. `list` matches the CLI; `new` and
72
- * `assets` would collide with `/vault/new` (the SPA's create-vault route)
73
- * and `/vault/assets/*` (the SPA's static asset bundle) respectively, so
74
- * the hub rejects them at the API edge before a vault under those names
75
- * can register and capture the proxy path.
76
- */
77
77
  // Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
78
78
  // init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
79
79
  // uppercased name through that vault would then lowercase or reject, drifting
80
- // the hub's idea of the vault from vault's. The hub-only reservations (`new`,
81
- // `assets`) shadow SPA routes and stay on top of vault's `list`.
80
+ // the hub's idea of the vault from vault's. The reserved set is the ONE
81
+ // consolidated `RESERVED_VAULT_NAMES` from vault-name.ts (B2h) this file
82
+ // used to carry its own `{list, new, assets}` copy that drifted from the
83
+ // `{list}`-only set gating the wizard + invite redemption.
82
84
  const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
83
- const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
84
85
 
85
86
  export interface CreateVaultRequest {
86
87
  name: string;
@@ -504,3 +505,380 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
504
505
  headers: { "content-type": "application/json" },
505
506
  });
506
507
  }
508
+
509
+ // ===========================================================================
510
+ // DELETE /vaults/<name> — the identity cascade (B1, 2026-06-09
511
+ // hub-module-boundary migration: lifecycle symmetry)
512
+ // ===========================================================================
513
+ //
514
+ // "Every provision flow must have a deprovision flow that cascades the
515
+ // identity artifacts it created." Mechanics deletion without identity
516
+ // cascade is a security hole: before B1, vault deletion was CLI-only
517
+ // (`parachute-vault remove`) and left every hub-side identity artifact
518
+ // naming the vault alive — scoped tokens, grants, user assignments, pinned
519
+ // invites, connections. This handler enumerates the full artifact list and
520
+ // handles every entry.
521
+ //
522
+ // Documented bound: short-lived (≤10-min) unregistered interactive mints
523
+ // ride to expiry by design — the cascade revokes persisted registry rows
524
+ // and publishes the revocation list; it does not claim instant revocation
525
+ // of in-flight interactive JWTs.
526
+
527
+ /** Client id stamped on the short-lived channel-scan mint. */
528
+ const DELETE_VAULT_CLIENT_ID = "parachute-hub-spa";
529
+ /** TTL for the cascade's interactive provisioning mints (channel scan). */
530
+ const DELETE_VAULT_PROVISION_TTL_SECONDS = 60;
531
+
532
+ export interface DeleteVaultDeps {
533
+ db: Database;
534
+ /** Hub origin — JWT `iss` validation + cascade mint issuer. */
535
+ issuer: string;
536
+ /** Override the services.json path. Defaults to `~/.parachute/services.json`. */
537
+ manifestPath?: string;
538
+ /** Absolute path to `connections.json` in the hub state dir. */
539
+ connectionsStorePath: string;
540
+ /** Loopback origin for the channel daemon, or `null` when not installed. */
541
+ channelOrigin: string | null;
542
+ /** Resolve a vault's loopback origin from services.json (trigger teardown). */
543
+ resolveVaultOrigin: (vaultName: string) => string | null;
544
+ /** Test seam: run `parachute-vault remove` — same Runner seam as create. */
545
+ runCommand?: CreateVaultDeps["runCommand"];
546
+ /**
547
+ * Supervisor-restart the vault module — the daemon-eviction cascade step.
548
+ * The running daemon caches open store handles, so rmSync alone leaves the
549
+ * deleted vault SERVING from the open fd; and vault's boot `selfRegister`
550
+ * rebuilds services.json paths from `listVaults()`, dropping the deleted
551
+ * path. Wired to the same supervisor machinery the lifecycle verbs use.
552
+ * Absent (no supervisor — CLI-mode hub, tests) → recorded as a warning in
553
+ * the response, not silently skipped. (The boundary-conformant per-daemon
554
+ * store-eviction endpoint is tracked as E9.)
555
+ */
556
+ restartVaultModule?: () => Promise<void>;
557
+ /** Test seam — `globalThis.fetch` in production. */
558
+ fetchImpl?: typeof fetch;
559
+ /** Test seam for the clock. */
560
+ now?: () => Date;
561
+ }
562
+
563
+ interface DeleteVaultBody {
564
+ confirm?: unknown;
565
+ }
566
+
567
+ /** Wire shape of the cascade summary in the 200/500 response. */
568
+ interface CascadeSummary {
569
+ tokens_revoked: number;
570
+ grants_rewritten: number;
571
+ grants_dropped: number;
572
+ user_vaults_removed: number;
573
+ invites_invalidated: number;
574
+ connections_torn_down: number;
575
+ /**
576
+ * Vault-backed channel-daemon entries still referencing the deleted vault
577
+ * AFTER connection teardown (legacy pre-Connections wiring). Surfaced for
578
+ * the operator — the hub does NOT silently delete channel's config; remove
579
+ * them from channel's own UI.
580
+ */
581
+ orphaned_channels: string[];
582
+ vault_removed: boolean;
583
+ module_restarted: boolean;
584
+ }
585
+
586
+ function emptyCascadeSummary(): CascadeSummary {
587
+ return {
588
+ tokens_revoked: 0,
589
+ grants_rewritten: 0,
590
+ grants_dropped: 0,
591
+ user_vaults_removed: 0,
592
+ invites_invalidated: 0,
593
+ connections_torn_down: 0,
594
+ orphaned_channels: [],
595
+ vault_removed: false,
596
+ module_restarted: false,
597
+ };
598
+ }
599
+
600
+ /** Every vault instance name currently registered in services.json. */
601
+ function listVaultInstanceNames(manifestPath: string): Set<string> {
602
+ const names = new Set<string>();
603
+ let manifest: ReturnType<typeof readManifest>;
604
+ try {
605
+ manifest = readManifestLenient(manifestPath);
606
+ } catch {
607
+ return names;
608
+ }
609
+ for (const svc of manifest.services) {
610
+ if (!isVaultEntry(svc)) continue;
611
+ if (svc.paths.length === 0) {
612
+ names.add(vaultInstanceNameFor(svc.name, undefined));
613
+ continue;
614
+ }
615
+ for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
616
+ }
617
+ return names;
618
+ }
619
+
620
+ /**
621
+ * `DELETE /vaults/<name>` — destroy a vault with the full identity cascade.
622
+ *
623
+ * Gate: Bearer `parachute:host:admin` (same gate as create). Body MUST carry
624
+ * `{"confirm": "<name>"}` — a deliberate retype-the-name guard against
625
+ * fat-finger disasters (mismatch → 400).
626
+ *
627
+ * Refusals:
628
+ * - unknown vault → 404;
629
+ * - LAST remaining vault → 409. Vault's boot auto-creates `default` at
630
+ * zero vaults, so deleting the last one would silently resurrect a fresh
631
+ * `default` (with a fresh global API key) — refusing sidesteps the
632
+ * resurrection class entirely. The CLI (`parachute-vault remove`) is the
633
+ * escape hatch for an operator who really means it.
634
+ * - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
635
+ * squatted `admin`/`new`/`assets` vault created before the B2h
636
+ * reservation must be removable through this endpoint.
637
+ *
638
+ * Cascade, in order (identity first, mechanics last — revocation is the safe
639
+ * direction if a later step fails):
640
+ * 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
641
+ * 2. grants rewrite (drop `vault:<name>:*` entries; drop the row only when
642
+ * it empties — a (user,client) grant can span multiple vaults);
643
+ * 3. `user_vaults` rows;
644
+ * 4. unredeemed invites pinned to the vault (redemption would resurrect
645
+ * the name);
646
+ * 5. connections whose source/provisioned vault is the deleted vault
647
+ * (via `teardownConnection`, which post-B0 also revokes the registered
648
+ * long-lived mints) + a report-only scan of channel's `/api/channels`
649
+ * for legacy vault-backed entries (`orphaned_channels`);
650
+ * 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
651
+ * CLI stays the single source of truth for vault destruction,
652
+ * mirroring create);
653
+ * 7. daemon eviction: supervisor-restart the vault module (open
654
+ * store-handle eviction + `selfRegister` services.json path rebuild).
655
+ *
656
+ * Response: 200 with a structured per-step summary (counts +
657
+ * `orphaned_channels` + warnings). A mechanics failure responds 500 with
658
+ * the partial summary — the identity artifacts already revoked stay revoked.
659
+ */
660
+ export async function handleDeleteVault(
661
+ req: Request,
662
+ rawName: string,
663
+ deps: DeleteVaultDeps,
664
+ ): Promise<Response> {
665
+ if (req.method !== "DELETE") {
666
+ return new Response("method not allowed", { status: 405 });
667
+ }
668
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
669
+ const runCommand = deps.runCommand ?? defaultRunCommand;
670
+ const now = deps.now?.() ?? new Date();
671
+
672
+ // Auth gate: parachute:host:admin — the same gate as POST /vaults.
673
+ let adminSub: string;
674
+ try {
675
+ const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
676
+ adminSub = auth.sub;
677
+ } catch (err) {
678
+ return adminAuthErrorResponse(err as AdminAuthError);
679
+ }
680
+
681
+ // Name shape guard. NOTE: no reserved-name check — see docstring.
682
+ const name = rawName.trim();
683
+ if (!VAULT_NAME_PATTERN.test(name)) {
684
+ return jsonError(
685
+ 400,
686
+ "invalid_request",
687
+ "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
688
+ );
689
+ }
690
+
691
+ // Confirm body — the retype-the-name guard.
692
+ let body: DeleteVaultBody;
693
+ try {
694
+ body = (await req.json()) as DeleteVaultBody;
695
+ } catch {
696
+ return jsonError(400, "confirm_mismatch", `body must be JSON: {"confirm": "${name}"}`);
697
+ }
698
+ if (!body || typeof body !== "object" || body.confirm !== name) {
699
+ return jsonError(
700
+ 400,
701
+ "confirm_mismatch",
702
+ `deleting a vault requires the body {"confirm": "${name}"} (retype the vault name)`,
703
+ );
704
+ }
705
+
706
+ // Existence.
707
+ const existing = findExistingVault(manifestPath, name);
708
+ if (!existing) {
709
+ return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
710
+ }
711
+
712
+ // Last-vault refusal (resurrection guard).
713
+ const instanceNames = listVaultInstanceNames(manifestPath);
714
+ instanceNames.delete(name);
715
+ if (instanceNames.size === 0) {
716
+ return jsonError(
717
+ 409,
718
+ "last_vault",
719
+ `"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
720
+ );
721
+ }
722
+
723
+ const summary = emptyCascadeSummary();
724
+ const warnings: { step: string; detail: string }[] = [];
725
+
726
+ // --- 1. Registry sweep: revoke every tokens row naming the vault. --------
727
+ summary.tokens_revoked = revokeTokensNamingVault(deps.db, name, now);
728
+
729
+ // NOTE on `auth_codes.scopes` — the one identity-artifact column from the
730
+ // charter's enumeration deliberately NOT swept here. Authorization codes
731
+ // are transient by construction: AUTH_CODE_TTL_SECONDS = 60 (auth-codes.ts)
732
+ // and single-use. A code naming the deleted vault either (a) expires
733
+ // unredeemed within the minute, or (b) redeems into tokens whose registry
734
+ // rows the sweep above already governs — and whose requests the evicted
735
+ // daemon no longer serves. Sweeping a 60-second-lived table adds a step
736
+ // with no security delta; same class as the documented ≤10-min
737
+ // unregistered interactive-mint bound.
738
+
739
+ // --- 2. Grants rewrite (drop rows only when emptied). ---------------------
740
+ const grants = rewriteGrantsRemovingVault(deps.db, name);
741
+ summary.grants_rewritten = grants.rewritten;
742
+ summary.grants_dropped = grants.dropped;
743
+
744
+ // --- 3. user_vaults assignments. ------------------------------------------
745
+ summary.user_vaults_removed = removeVaultAssignments(deps.db, name);
746
+
747
+ // --- 4. Unredeemed invites pinned to the vault. ----------------------------
748
+ summary.invites_invalidated = revokeInvitesForVault(deps.db, name, now);
749
+
750
+ // --- 5. Connections teardown (+ legacy channel scan, report-only). --------
751
+ // Runs BEFORE the CLI remove so the vault daemon is still alive to accept
752
+ // the trigger-deregister calls.
753
+ const connectionsDeps: ConnectionsDeps = {
754
+ db: deps.db,
755
+ hubOrigin: deps.issuer,
756
+ modules: [], // teardown never consults the catalog
757
+ resolveVaultOrigin: deps.resolveVaultOrigin,
758
+ channelOrigin: deps.channelOrigin,
759
+ storePath: deps.connectionsStorePath,
760
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
761
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
762
+ };
763
+ const records = readConnections(deps.connectionsStorePath).filter(
764
+ (r) => r.source.vault === name || r.provisioned?.vault === name,
765
+ );
766
+ for (const record of records) {
767
+ try {
768
+ const res = await teardownConnection(record.id, adminSub, connectionsDeps);
769
+ if (res.status === 200) {
770
+ summary.connections_torn_down++;
771
+ } else {
772
+ // 207 partial — the record is removed + mints revoked; remote steps
773
+ // failed. Surface as warnings, count as torn down (the identity side
774
+ // is done; the operator can clean the remote residue).
775
+ summary.connections_torn_down++;
776
+ const out = (await res.json()) as { errors?: { step: string; detail: string }[] };
777
+ for (const e of out.errors ?? []) {
778
+ warnings.push({ step: `connection_${record.id}_${e.step}`, detail: e.detail });
779
+ }
780
+ }
781
+ } catch (err) {
782
+ warnings.push({
783
+ step: `connection_${record.id}`,
784
+ detail: err instanceof Error ? err.message : String(err),
785
+ });
786
+ }
787
+ }
788
+
789
+ // Legacy channel scan — vault-backed channel entries still referencing the
790
+ // vault after connection teardown (pre-Connections wiring). REPORT ONLY:
791
+ // channel's config is the channel module's domain; the operator removes
792
+ // them from channel's own UI.
793
+ if (deps.channelOrigin !== null) {
794
+ try {
795
+ const fetchImpl = deps.fetchImpl ?? fetch;
796
+ // Short-lived (60s) — stays below the registered-mint threshold by
797
+ // design (the documented ≤10-min interactive bound; see B0's
798
+ // REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts).
799
+ const scanToken = (
800
+ await signAccessToken(deps.db, {
801
+ sub: adminSub,
802
+ scopes: ["channel:admin"],
803
+ audience: "channel",
804
+ clientId: DELETE_VAULT_CLIENT_ID,
805
+ issuer: deps.issuer,
806
+ ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
807
+ vaultScope: [],
808
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
809
+ })
810
+ ).token;
811
+ const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
812
+ headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
813
+ });
814
+ if (res.ok) {
815
+ const listed = (await res.json()) as { channels?: unknown };
816
+ const rawList = Array.isArray(listed?.channels) ? listed.channels : [];
817
+ for (const c of rawList) {
818
+ const row = (c ?? {}) as Record<string, unknown>;
819
+ if (row.transport === "vault" && row.vault === name && typeof row.name === "string") {
820
+ summary.orphaned_channels.push(row.name);
821
+ }
822
+ }
823
+ } else {
824
+ warnings.push({ step: "channel_scan", detail: `channel list returned ${res.status}` });
825
+ }
826
+ } catch (err) {
827
+ warnings.push({
828
+ step: "channel_scan",
829
+ detail: err instanceof Error ? err.message : String(err),
830
+ });
831
+ }
832
+ }
833
+
834
+ // --- 6. Mechanics: the module CLI is the source of truth for destruction. -
835
+ try {
836
+ const result = await runCommand(["parachute-vault", "remove", name, "--yes"]);
837
+ if (result.exitCode !== 0) {
838
+ const stderrTail = result.stderr.trim();
839
+ const tailSuffix = stderrTail ? `: ${stderrTail.slice(-500)}` : "";
840
+ return jsonError(
841
+ 500,
842
+ "server_error",
843
+ `parachute-vault remove ${name} --yes exited with code ${result.exitCode}${tailSuffix} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
844
+ );
845
+ }
846
+ summary.vault_removed = true;
847
+ } catch (err) {
848
+ const msg = err instanceof Error ? err.message : String(err);
849
+ return jsonError(
850
+ 500,
851
+ "server_error",
852
+ `orchestration failed: ${msg} — identity artifacts already revoked (summary: ${JSON.stringify(summary)})`,
853
+ );
854
+ }
855
+
856
+ // --- 7. Daemon eviction: supervisor-restart the vault module. -------------
857
+ if (deps.restartVaultModule) {
858
+ try {
859
+ await deps.restartVaultModule();
860
+ summary.module_restarted = true;
861
+ } catch (err) {
862
+ warnings.push({
863
+ step: "module_restart",
864
+ detail: `vault module restart failed: ${err instanceof Error ? err.message : String(err)} — the running daemon may still serve the deleted vault from its open store handle until restarted (parachute restart vault)`,
865
+ });
866
+ }
867
+ } else {
868
+ warnings.push({
869
+ step: "module_restart",
870
+ detail:
871
+ "no supervisor available — restart the vault module (parachute restart vault) to evict the deleted vault's open store handle and refresh services.json",
872
+ });
873
+ }
874
+
875
+ return new Response(
876
+ JSON.stringify({
877
+ ok: true,
878
+ name,
879
+ cascade: summary,
880
+ ...(warnings.length > 0 ? { warnings } : {}),
881
+ }),
882
+ { status: 200, headers: { "content-type": "application/json" } },
883
+ );
884
+ }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * ── WHY A DEDICATED ENDPOINT (not /api/modules/hub/*) ──────────────────────
6
6
  *
7
- * The hub is NOT a supervised module — `CURATED_MODULES` rejects `hub`, so
8
- * `parseModulesPath("/api/modules/hub/upgrade")` returns undefined and the
9
- * module-ops switch never reaches a hub case. The hub needs its OWN endpoint
7
+ * The hub is NOT a supervised module — `parseModulesPath` rejects `hub`
8
+ * (`isKnownModuleShort("hub")` is false: hub isn't in KNOWN_MODULES /
9
+ * FIRST_PARTY_FALLBACKS), so `parseModulesPath("/api/modules/hub/upgrade")`
10
+ * returns undefined and the module-ops switch never reaches a hub case. The hub needs its OWN endpoint
10
11
  * because the constraint is unique: the hub can't restart itself synchronously
11
12
  * (the request dies with the old process before it can report success). So:
12
13
  *