@openparachute/hub 0.7.1 → 0.7.2

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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Optional idle screen-lock for the hub ADMIN UI (phone-style lock).
3
+ *
4
+ * ## What this guards (and what it deliberately doesn't)
5
+ *
6
+ * The hub admin UI is reachable remotely once `parachute expose` is up
7
+ * (Tailscale / Cloudflare). A grabbed or left-logged-in admin browser session
8
+ * is a real risk: the password-login session cookie lasts 24h, so an
9
+ * unattended tab is a standing admin console. This adds an OPTIONAL operator
10
+ * PIN that LOCKS THE WHOLE ADMIN SURFACE — one lock over everything, not
11
+ * per-action gating (per-action would friction-up configuration, which is the
12
+ * dangerous stuff we most want behind the lock anyway). Unlock with the PIN →
13
+ * a frictionless working window; admin activity refreshes it; idle re-locks.
14
+ *
15
+ * THREAT MODEL: this is a WEB/UI-layer guard for the EXPOSED admin portal. It
16
+ * does NOT protect against someone with a SHELL on the box — they read
17
+ * `~/.parachute/operator.token` / the vault DB directly and bypass the hub
18
+ * entirely. That's an OS concern (disk encryption, a locked OS screen, SSH-key
19
+ * hygiene). The lock shuts the *portal* door — the one the internet can reach —
20
+ * which is the point.
21
+ *
22
+ * ## The single chokepoint
23
+ *
24
+ * The admin SPA + every module config UI get their working Bearer from one of
25
+ * the cookie-gated mint endpoints:
26
+ * - `GET /admin/host-admin-token` (the SPA's own Bearer)
27
+ * - `GET /admin/agent-token` (agent chat + config UIs)
28
+ * - `GET /admin/vault-admin-token/<name>`(per-vault admin SPA)
29
+ * - `GET /admin/module-token/<short>` (generic module config UI Bearer)
30
+ *
31
+ * All four share the exact `parseSessionCookie → findSession → [isFirstAdmin]
32
+ * → signAccessToken` shape. Inserting {@link requireUnlocked} into each makes
33
+ * the lock cascade to EVERY admin surface with no per-module changes: when
34
+ * locked, the mint returns 423 and the relevant UI shows the lock screen / its
35
+ * admin calls fail closed (no Bearer). A surface OAuth'ing in via `/oauth/*`
36
+ * never touches these endpoints, so the OAuth issuer is untouched — see the
37
+ * design note (`design/2026-06-17-admin-ui-lock.md`).
38
+ *
39
+ * ## Unlock state — per-session, in-memory, "unlocked-until"
40
+ *
41
+ * Simplest workable model. A successful PIN unlock records `unlockedUntil` for
42
+ * the session id (the cookie value) in a process-local Map. The session is
43
+ * UNLOCKED iff a future `unlockedUntil` exists; genuine USER activity (the SPA's
44
+ * debounced `/heartbeat`, fired on pointer/key/scroll) slides it forward by the
45
+ * idle window. Token mints do NOT slide it — the SPA polls some endpoints in the
46
+ * background, and letting a poll's re-mint extend the window would keep an
47
+ * idle-but-open tab unlocked forever. LOCKED when:
48
+ * - no PIN is set → feature OFF, never locked (today's behavior);
49
+ * - PIN set + no unlock recorded (fresh load / first visit);
50
+ * - PIN set + the recorded unlock is in the past (idle-expired);
51
+ * - a hub restart wipes the Map → re-lock (a feature, not a bug).
52
+ *
53
+ * The unlock state is NEVER persisted and NEVER put in the cookie — a stolen
54
+ * cookie alone can't carry an unlocked window; the attacker still needs the
55
+ * PIN. (The cookie already authenticates the password session; the PIN is the
56
+ * second, idle-bounded gate on top.)
57
+ */
58
+ import type { Database } from "bun:sqlite";
59
+ import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
60
+ import { deleteSetting, getSetting, setSetting } from "./hub-settings.ts";
61
+ import { RateLimiter } from "./rate-limit.ts";
62
+
63
+ /** Default idle window before a session re-locks. Phone-lock territory. */
64
+ export const DEFAULT_ADMIN_LOCK_IDLE_SECONDS = 15 * 60;
65
+
66
+ /** Clamp on the operator-configurable idle window: 1 min … 24h. */
67
+ export const MIN_ADMIN_LOCK_IDLE_SECONDS = 60;
68
+ export const MAX_ADMIN_LOCK_IDLE_SECONDS = 24 * 60 * 60;
69
+
70
+ /**
71
+ * PIN format: 4–12 digits. A numeric PIN is the phone-lock affordance the
72
+ * feature is named for; the real defense is the idle window + brute-force
73
+ * limiter, not PIN entropy (the session is already password-authenticated —
74
+ * this is a second, convenience-grade gate). Operators wanting a full secret
75
+ * use the OS lock / a strong account password; this is the "don't leave the
76
+ * console open on the train" guard.
77
+ */
78
+ export const ADMIN_LOCK_PIN_RE = /^[0-9]{4,12}$/;
79
+
80
+ export type ValidatePinResult = { valid: true } | { valid: false; reason: "format" };
81
+
82
+ export function validatePin(pin: string): ValidatePinResult {
83
+ return ADMIN_LOCK_PIN_RE.test(pin) ? { valid: true } : { valid: false, reason: "format" };
84
+ }
85
+
86
+ // --- PIN storage (argon2id hash in hub_settings) ---------------------------
87
+
88
+ /** True iff an admin-lock PIN is configured (feature is ON). */
89
+ export function isLockConfigured(db: Database): boolean {
90
+ const h = getSetting(db, "admin_lock_pin_hash");
91
+ return typeof h === "string" && h.length > 0;
92
+ }
93
+
94
+ /**
95
+ * Store (or rotate) the PIN. Hashes with argon2id — the same family the hub
96
+ * already uses for passwords + TOTP backup codes (`@node-rs/argon2`). The
97
+ * caller MUST have validated the PIN format first; this trusts its input.
98
+ */
99
+ export async function setPin(db: Database, pin: string): Promise<void> {
100
+ const h = await argonHash(pin);
101
+ setSetting(db, "admin_lock_pin_hash", h);
102
+ }
103
+
104
+ /** Remove the PIN (turn the feature OFF). Idempotent. Also clears idle config. */
105
+ export function clearPin(db: Database): void {
106
+ deleteSetting(db, "admin_lock_pin_hash");
107
+ deleteSetting(db, "admin_lock_idle_seconds");
108
+ }
109
+
110
+ /**
111
+ * Verify a submitted PIN against the stored hash. Returns false when no PIN is
112
+ * configured (defensive — callers gate on {@link isLockConfigured} first) or
113
+ * the hash is malformed.
114
+ */
115
+ export async function verifyPin(db: Database, pin: string): Promise<boolean> {
116
+ const h = getSetting(db, "admin_lock_pin_hash");
117
+ if (typeof h !== "string" || h.length === 0) return false;
118
+ try {
119
+ return await argonVerify(h, pin);
120
+ } catch {
121
+ // Corrupt/unparseable hash — fail closed.
122
+ return false;
123
+ }
124
+ }
125
+
126
+ // --- idle window config ----------------------------------------------------
127
+
128
+ /** Read the configured idle window (clamped). Falls back to the default. */
129
+ export function getIdleSeconds(db: Database): number {
130
+ const raw = getSetting(db, "admin_lock_idle_seconds");
131
+ if (typeof raw !== "string") return DEFAULT_ADMIN_LOCK_IDLE_SECONDS;
132
+ const n = Number.parseInt(raw, 10);
133
+ if (!Number.isFinite(n)) return DEFAULT_ADMIN_LOCK_IDLE_SECONDS;
134
+ return clampIdleSeconds(n);
135
+ }
136
+
137
+ export function clampIdleSeconds(n: number): number {
138
+ if (n < MIN_ADMIN_LOCK_IDLE_SECONDS) return MIN_ADMIN_LOCK_IDLE_SECONDS;
139
+ if (n > MAX_ADMIN_LOCK_IDLE_SECONDS) return MAX_ADMIN_LOCK_IDLE_SECONDS;
140
+ return Math.floor(n);
141
+ }
142
+
143
+ /** Persist the operator's idle-window choice (clamped). */
144
+ export function setIdleSeconds(db: Database, seconds: number): void {
145
+ setSetting(db, "admin_lock_idle_seconds", String(clampIdleSeconds(seconds)));
146
+ }
147
+
148
+ // --- per-session unlock state (in-memory, never persisted) -----------------
149
+
150
+ /**
151
+ * sessionId → unlockedUntil epoch ms. Process-local; a restart wipes it (which
152
+ * is the re-lock-on-fresh-process behavior we want). Bounded by an
153
+ * opportunistic prune of expired rows on every touch, so an attacker churning
154
+ * session ids can't grow it unboundedly.
155
+ */
156
+ const unlockedUntil = new Map<string, number>();
157
+
158
+ function pruneExpired(now: number): void {
159
+ for (const [sid, until] of unlockedUntil) {
160
+ if (until <= now) unlockedUntil.delete(sid);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Record a fresh unlock for `sessionId`, valid for `idleSeconds` from now.
166
+ * Called after a successful PIN verify.
167
+ */
168
+ export function recordUnlock(
169
+ sessionId: string,
170
+ idleSeconds: number,
171
+ now: number = Date.now(),
172
+ ): void {
173
+ pruneExpired(now);
174
+ unlockedUntil.set(sessionId, now + idleSeconds * 1000);
175
+ }
176
+
177
+ /**
178
+ * Slide the unlock window forward by `idleSeconds` IF the session is currently
179
+ * unlocked. Called on admin activity (heartbeat + every successful mint) so an
180
+ * actively-used console doesn't lock mid-work. Does NOT create an unlock for a
181
+ * locked session — only an explicit PIN entry can do that.
182
+ */
183
+ export function refreshActivity(
184
+ sessionId: string,
185
+ idleSeconds: number,
186
+ now: number = Date.now(),
187
+ ): void {
188
+ const until = unlockedUntil.get(sessionId);
189
+ if (until === undefined || until <= now) return; // locked — activity doesn't unlock
190
+ unlockedUntil.set(sessionId, now + idleSeconds * 1000);
191
+ }
192
+
193
+ /** Is this session currently within an unlock window? */
194
+ export function isSessionUnlocked(sessionId: string, now: number = Date.now()): boolean {
195
+ const until = unlockedUntil.get(sessionId);
196
+ return until !== undefined && until > now;
197
+ }
198
+
199
+ /** Force-lock a session immediately ("Lock now"). Idempotent. */
200
+ export function lockSession(sessionId: string): void {
201
+ unlockedUntil.delete(sessionId);
202
+ }
203
+
204
+ /** Remaining unlock seconds for a session (0 when locked). For status UI. */
205
+ export function unlockSecondsRemaining(sessionId: string, now: number = Date.now()): number {
206
+ const until = unlockedUntil.get(sessionId);
207
+ if (until === undefined || until <= now) return 0;
208
+ return Math.ceil((until - now) / 1000);
209
+ }
210
+
211
+ /** Test seam: wipe all in-memory unlock state. */
212
+ export function _resetUnlockStateForTest(): void {
213
+ unlockedUntil.clear();
214
+ }
215
+
216
+ // --- the gate the four token-mint chokepoints call -------------------------
217
+
218
+ /**
219
+ * The single lock gate. Returns whether the mint may proceed.
220
+ *
221
+ * - feature OFF (no PIN) → ALWAYS allow (today's behavior, byte-for-byte).
222
+ * - feature ON + session unlocked → allow.
223
+ * - feature ON + session locked → DENY (the caller returns 423 Locked).
224
+ *
225
+ * Deliberately a PURE CHECK — it does NOT slide the idle window. Sliding is
226
+ * driven exclusively by genuine USER activity (the SPA's debounced
227
+ * `/heartbeat`, fired on pointer/key/scroll), NOT by token mints. The SPA polls
228
+ * some endpoints in the background (e.g. the version badge every 30s), and each
229
+ * such poll re-mints a host-admin Bearer; if a mint extended the window, a
230
+ * left-open-but-idle tab would never lock — defeating the whole feature. The
231
+ * heartbeat is the one thing that means "a human is here."
232
+ *
233
+ * `now` is injectable for tests.
234
+ */
235
+ export function requireUnlocked(
236
+ db: Database,
237
+ sessionId: string,
238
+ now: number = Date.now(),
239
+ ): { ok: true } | { ok: false; reason: "locked" } {
240
+ if (!isLockConfigured(db)) return { ok: true };
241
+ if (isSessionUnlocked(sessionId, now)) {
242
+ return { ok: true };
243
+ }
244
+ return { ok: false, reason: "locked" };
245
+ }
246
+
247
+ /** RFC-shaped 423 the mint chokepoints return when locked. */
248
+ export function lockedResponse(): Response {
249
+ return new Response(
250
+ JSON.stringify({
251
+ error: "locked",
252
+ error_description:
253
+ "the admin UI is locked — enter your PIN to unlock (POST /api/admin-lock/unlock)",
254
+ }),
255
+ {
256
+ status: 423,
257
+ headers: {
258
+ "content-type": "application/json",
259
+ "cache-control": "no-store",
260
+ },
261
+ },
262
+ );
263
+ }
264
+
265
+ // --- unlock brute-force limiter --------------------------------------------
266
+
267
+ /**
268
+ * Per-session unlock-attempt limiter. The unlock endpoint is session-gated, so
269
+ * the threat is a compromised session (stolen cookie) grinding argon2id PIN
270
+ * verifications without bound — keyed by session id, same posture as
271
+ * `/account/change-password` (keyed by user). 5 wrong PINs / 5 min is the
272
+ * floor; the idle window + the limiter together are the real defense (the PIN
273
+ * itself is convenience-grade — see the module header).
274
+ */
275
+ export const ADMIN_LOCK_UNLOCK_MAX_ATTEMPTS = 5;
276
+ export const ADMIN_LOCK_UNLOCK_WINDOW_MS = 5 * 60 * 1000;
277
+
278
+ export const unlockLimiter = new RateLimiter(
279
+ ADMIN_LOCK_UNLOCK_MAX_ATTEMPTS,
280
+ ADMIN_LOCK_UNLOCK_WINDOW_MS,
281
+ );
@@ -8,15 +8,15 @@
8
8
  * hub frames/links those surfaces consistently (the Modules page "Configure"
9
9
  * action). Each module-owned config UI, served behind the hub proxy to a
10
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
11
+ * `<short>:admin`-gated endpoints — the same shape the agent config UI gets
12
+ * from `/admin/agent-token` and the vault admin SPA gets from
13
13
  * `/admin/vault-admin-token/<name>`. This is the GENERIC mint that covers every
14
14
  * other self-registered single-audience module, so the hub doesn't grow a
15
15
  * bespoke per-module mint endpoint as each module ships a config UI.
16
16
  *
17
17
  * Scope + audience: `<short>:admin`, audience = `<short>` (the bare service
18
18
  * prefix). Modules validate the JWT's `aud` against their literal short name
19
- * (`scribe`, `runner`, `surface`, `channel`) — the same shape `inferAudience`
19
+ * (`scribe`, `runner`, `surface`, `agent`) — the same shape `inferAudience`
20
20
  * stamps for the public OAuth flow, so a hub-minted and an OAuth-minted admin
21
21
  * token are indistinguishable to the module. This mirrors the per-request
22
22
  * `<short>:admin` proxy token `api-modules-config.ts` used to mint; the
@@ -32,13 +32,14 @@
32
32
  *
33
33
  * Gate: the session must belong to the first admin (the single hub admin under
34
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
35
+ * host-admin-token / vault-admin-token / agent-token. A friend account holds
36
36
  * a valid session but must not mint a module admin Bearer.
37
37
  *
38
38
  * Tokens are short-lived (10 min — matches the sibling admin-token mints); the
39
39
  * config UI re-fetches on near-expiry.
40
40
  */
41
41
  import type { Database } from "bun:sqlite";
42
+ import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
42
43
  import { signAccessToken } from "./jwt-sign.ts";
43
44
  import {
44
45
  type ModuleManifest,
@@ -49,7 +50,7 @@ import type { ServiceEntry } from "./services-manifest.ts";
49
50
  import { findSession, parseSessionCookie } from "./sessions.ts";
50
51
  import { isFirstAdmin } from "./users.ts";
51
52
 
52
- /** Short TTL — matches host/vault/channel admin-token. UI re-fetches on near-expiry. */
53
+ /** Short TTL — matches host/vault/agent admin-token. UI re-fetches on near-expiry. */
53
54
  export const MODULE_TOKEN_TTL_SECONDS = 10 * 60;
54
55
  const MODULE_TOKEN_CLIENT_ID = "parachute-hub-spa";
55
56
 
@@ -143,10 +144,10 @@ export async function handleModuleToken(
143
144
  }
144
145
  const sid = parseSessionCookie(req.headers.get("cookie"));
145
146
  const session = sid ? findSession(deps.db, sid) : null;
146
- if (!session) {
147
+ if (!session || !sid) {
147
148
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
148
149
  }
149
- // First-admin gate (mirrors host/vault/channel-admin-token). A friend account
150
+ // First-admin gate (mirrors host/vault/agent-admin-token). A friend account
150
151
  // (non-first-admin user) holds a valid session but must not mint a module
151
152
  // admin Bearer.
152
153
  if (!isFirstAdmin(deps.db, session.userId)) {
@@ -156,6 +157,13 @@ export async function handleModuleToken(
156
157
  "module admin token mint is restricted to the hub admin — your account home is at /account/",
157
158
  );
158
159
  }
160
+ // Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
161
+ // session can't mint a module admin Bearer, so every module-owned config UI
162
+ // fails closed until the operator unlocks. This is what makes the lock
163
+ // cascade to ALL modules with zero per-module changes. Off by default.
164
+ if (!requireUnlocked(deps.db, sid).ok) {
165
+ return lockedResponse();
166
+ }
159
167
  const scope = `${short}:admin`;
160
168
  const minted = await signAccessToken(deps.db, {
161
169
  sub: session.userId,
@@ -25,6 +25,7 @@
25
25
  * for their assigned vault; they don't get vault admin via this endpoint.
26
26
  */
27
27
  import type { Database } from "bun:sqlite";
28
+ import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
28
29
  import { signAccessToken } from "./jwt-sign.ts";
29
30
  import { findSession, parseSessionCookie } from "./sessions.ts";
30
31
  import { isFirstAdmin } from "./users.ts";
@@ -66,7 +67,7 @@ export async function handleVaultAdminToken(
66
67
  }
67
68
  const sid = parseSessionCookie(req.headers.get("cookie"));
68
69
  const session = sid ? findSession(deps.db, sid) : null;
69
- if (!session) {
70
+ if (!session || !sid) {
70
71
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
71
72
  }
72
73
  // Multi-user Phase 1 privesc gate (mirrors host-admin-token). vault:<name>:admin
@@ -80,6 +81,12 @@ export async function handleVaultAdminToken(
80
81
  "vault admin token mint is restricted to the hub admin — your account home is at /account/",
81
82
  );
82
83
  }
84
+ // Admin screen-lock gate (see admin-host-admin-token.ts). A locked admin
85
+ // session can't mint a vault admin Bearer, so the vault admin SPA fails
86
+ // closed until the operator unlocks. Off by default.
87
+ if (!requireUnlocked(deps.db, sid).ok) {
88
+ return lockedResponse();
89
+ }
83
90
  const scope = `vault:${vaultName}:admin`;
84
91
  // Per-vault audience: vault validates the JWT's `aud` claim against
85
92
  // `vault.<name>` derived from its own URL-bound config (vault src/auth.ts
@@ -537,8 +537,8 @@ export interface DeleteVaultDeps {
537
537
  manifestPath?: string;
538
538
  /** Absolute path to `connections.json` in the hub state dir. */
539
539
  connectionsStorePath: string;
540
- /** Loopback origin for the channel daemon, or `null` when not installed. */
541
- channelOrigin: string | null;
540
+ /** Loopback origin for the agent daemon, or `null` when not installed. */
541
+ agentOrigin: string | null;
542
542
  /** Resolve a vault's loopback origin from services.json (trigger teardown). */
543
543
  resolveVaultOrigin: (vaultName: string) => string | null;
544
544
  /**
@@ -651,7 +651,7 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
651
651
  * the name);
652
652
  * 5. connections whose source/provisioned vault is the deleted vault
653
653
  * (via `teardownConnection`, which post-B0 also revokes the registered
654
- * long-lived mints) + a report-only scan of channel's `/api/channels`
654
+ * long-lived mints) + a report-only scan of the agent's `/api/channels`
655
655
  * for legacy vault-backed entries (`orphaned_channels`);
656
656
  * 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
657
657
  * CLI stays the single source of truth for vault destruction,
@@ -764,7 +764,7 @@ export async function handleDeleteVault(
764
764
  ...(deps.resolveModuleOrigin !== undefined
765
765
  ? { resolveModuleOrigin: deps.resolveModuleOrigin }
766
766
  : {}),
767
- channelOrigin: deps.channelOrigin,
767
+ agentOrigin: deps.agentOrigin,
768
768
  storePath: deps.connectionsStorePath,
769
769
  ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
770
770
  ...(deps.now !== undefined ? { now: deps.now } : {}),
@@ -797,9 +797,9 @@ export async function handleDeleteVault(
797
797
 
798
798
  // Legacy channel scan — vault-backed channel entries still referencing the
799
799
  // vault after connection teardown (pre-Connections wiring). REPORT ONLY:
800
- // channel's config is the channel module's domain; the operator removes
801
- // them from channel's own UI.
802
- if (deps.channelOrigin !== null) {
800
+ // the channel config is the agent module's domain; the operator removes
801
+ // them from the agent's own UI.
802
+ if (deps.agentOrigin !== null) {
803
803
  try {
804
804
  const fetchImpl = deps.fetchImpl ?? fetch;
805
805
  // Short-lived (60s) — stays below the registered-mint threshold by
@@ -808,8 +808,8 @@ export async function handleDeleteVault(
808
808
  const scanToken = (
809
809
  await signAccessToken(deps.db, {
810
810
  sub: adminSub,
811
- scopes: ["channel:admin"],
812
- audience: "channel",
811
+ scopes: ["agent:admin"],
812
+ audience: "agent",
813
813
  clientId: DELETE_VAULT_CLIENT_ID,
814
814
  issuer: deps.issuer,
815
815
  ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
@@ -817,7 +817,7 @@ export async function handleDeleteVault(
817
817
  ...(deps.now !== undefined ? { now: deps.now } : {}),
818
818
  })
819
819
  ).token;
820
- const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
820
+ const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
821
821
  headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
822
822
  });
823
823
  if (res.ok) {
@@ -830,11 +830,11 @@ export async function handleDeleteVault(
830
830
  }
831
831
  }
832
832
  } else {
833
- warnings.push({ step: "channel_scan", detail: `channel list returned ${res.status}` });
833
+ warnings.push({ step: "agent_scan", detail: `agent channel list returned ${res.status}` });
834
834
  }
835
835
  } catch (err) {
836
836
  warnings.push({
837
- step: "channel_scan",
837
+ step: "agent_scan",
838
838
  detail: err instanceof Error ? err.message : String(err),
839
839
  });
840
840
  }