@openparachute/hub 0.7.0 → 0.7.2-rc.1

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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -47,6 +47,7 @@
47
47
  * in line with the admin scope-set semantics from hub#214 / #222.
48
48
  */
49
49
  import type { Database } from "bun:sqlite";
50
+ import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
50
51
  import { signAccessToken } from "./jwt-sign.ts";
51
52
  import { findSession, parseSessionCookie } from "./sessions.ts";
52
53
  import { isFirstAdmin } from "./users.ts";
@@ -72,7 +73,7 @@ export async function handleHostAdminToken(
72
73
  }
73
74
  const sid = parseSessionCookie(req.headers.get("cookie"));
74
75
  const session = sid ? findSession(deps.db, sid) : null;
75
- if (!session) {
76
+ if (!session || !sid) {
76
77
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
77
78
  }
78
79
  // First-admin gate. A friend account (non-first-admin user created via
@@ -89,6 +90,18 @@ export async function handleHostAdminToken(
89
90
  "host-admin token mint is restricted to the hub admin — your account home is at /account/",
90
91
  );
91
92
  }
93
+ // Admin screen-lock gate (optional, off by default). When a lock PIN is set
94
+ // AND this session isn't within an unlock window, refuse to mint — the SPA
95
+ // shows the lock screen on the 423. No PIN configured → always allowed
96
+ // (today's behavior). This mint is a PURE CHECK — it does NOT slide the idle
97
+ // window; sliding is driven only by genuine user activity (the SPA's debounced
98
+ // `/heartbeat`), so a background re-mint (e.g. the 30s version-badge poll) can't
99
+ // keep an idle tab unlocked. See the `requireUnlocked` docblock in admin-lock.ts.
100
+ // The OAuth issuer (`/oauth/*`) never reaches this endpoint, so it's
101
+ // unaffected by the lock.
102
+ if (!requireUnlocked(deps.db, sid).ok) {
103
+ return lockedResponse();
104
+ }
92
105
  const minted = await signAccessToken(deps.db, {
93
106
  sub: session.userId,
94
107
  scopes: [...HOST_ADMIN_SCOPES],
@@ -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
+ );
@@ -161,8 +161,18 @@ export interface InviteSetupProps {
161
161
  /**
162
162
  * When the invite pins a vault name the redeemer can't choose one — we show
163
163
  * it read-only. When null the redeemer names their own vault (a text field).
164
+ * With `provisionVault: false` a pinned name is a SHARED-VAULT invite: the
165
+ * redeemer is being given `role` access to the operator's existing vault.
164
166
  */
165
167
  pinnedVaultName: string | null;
168
+ /**
169
+ * When the invite pre-names the account, the username is shown read-only
170
+ * and ENFORCED server-side (the redeem handler ignores the form field).
171
+ * Null = the redeemer picks their own.
172
+ */
173
+ pinnedUsername: string | null;
174
+ /** The `user_vaults` role redemption grants ('read' | 'write') — shown on the shared-vault row. */
175
+ role: string;
166
176
  /** Whether redemption provisions a vault at all (shows the vault row iff true). */
167
177
  provisionVault: boolean;
168
178
  username?: string;
@@ -177,15 +187,55 @@ export interface InviteSetupProps {
177
187
  * same `/account/setup/<token>` path. Reuses the shared login chrome.
178
188
  */
179
189
  export function renderInviteSetup(props: InviteSetupProps): string {
180
- const { token, csrfToken, pinnedVaultName, provisionVault, username, vaultName, errorMessage } =
181
- props;
190
+ const {
191
+ token,
192
+ csrfToken,
193
+ pinnedVaultName,
194
+ pinnedUsername,
195
+ role,
196
+ provisionVault,
197
+ username,
198
+ vaultName,
199
+ errorMessage,
200
+ } = props;
182
201
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
183
202
  const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
184
203
 
185
- // Vault row: shown only when the invite provisions a vault. Pinned → a
186
- // read-only display of the name the admin chose (redeemer can't squat names).
187
- // Unpinned a text field the redeemer fills.
204
+ // Username row: pre-named → read-only display (the server ENFORCES the
205
+ // invite's username; the disabled input never submits and the handler
206
+ // ignores the field anyway). Unpinned the normal pick-a-name field.
207
+ const usernameRow =
208
+ pinnedUsername !== null
209
+ ? `
210
+ <label class="field">
211
+ <span class="field-label">Username</span>
212
+ <input type="text" value="${escapeAttr(pinnedUsername)}" readonly disabled />
213
+ <span class="field-hint">Your hub operator chose this username for you.</span>
214
+ </label>`
215
+ : `
216
+ <label class="field">
217
+ <span class="field-label">Username</span>
218
+ <input type="text" name="username" id="username" autocomplete="username" autofocus
219
+ required minlength="2" maxlength="32"
220
+ pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
221
+ spellcheck="false" autocapitalize="off"${usernameAttr} />
222
+ <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
223
+ </label>`;
224
+
225
+ // Vault row. Provisioning invites show the new vault's name (pinned →
226
+ // read-only; unpinned → a text field the redeemer fills). A shared-vault
227
+ // invite (no provisioning + a pinned name) shows what the redeemer is
228
+ // being given access to, including the role.
188
229
  let vaultRow = "";
230
+ if (!provisionVault && pinnedVaultName !== null) {
231
+ const roleLabel = role === "read" ? "read-only" : "read &amp; write";
232
+ vaultRow = `
233
+ <label class="field">
234
+ <span class="field-label">Shared vault</span>
235
+ <input type="text" value="${escapeAttr(pinnedVaultName)}" readonly disabled />
236
+ <span class="field-hint">You're being given ${roleLabel} access to this existing vault.</span>
237
+ </label>`;
238
+ }
189
239
  if (provisionVault) {
190
240
  if (pinnedVaultName !== null) {
191
241
  vaultRow = `
@@ -240,21 +290,20 @@ export function renderInviteSetup(props: InviteSetupProps): string {
240
290
  <div class="card-header">
241
291
  ${header()}
242
292
  <h1>Claim your invite</h1>
243
- <p class="subtitle">Pick a username and password to create your Parachute account${
244
- provisionVault ? " and your own vault" : ""
293
+ <p class="subtitle">${
294
+ pinnedUsername !== null ? "Pick a password" : "Pick a username and password"
295
+ } to create your Parachute account${
296
+ provisionVault
297
+ ? " and your own vault"
298
+ : pinnedVaultName !== null
299
+ ? " with access to a shared vault"
300
+ : ""
245
301
  }.</p>
246
302
  </div>
247
303
  ${error}
248
304
  <form method="POST" action="/account/setup/${escapeAttr(token)}" class="auth-form">
249
305
  ${renderCsrfHiddenInput(csrfToken)}
250
- <label class="field">
251
- <span class="field-label">Username</span>
252
- <input type="text" name="username" id="username" autocomplete="username" autofocus
253
- required minlength="2" maxlength="32"
254
- pattern="[a-z0-9_-]+" title="lowercase letters, digits, _ - (2–32 chars)"
255
- spellcheck="false" autocapitalize="off"${usernameAttr} />
256
- <span class="field-hint">lowercase letters, digits, <code>_</code>, <code>-</code></span>
257
- </label>
306
+ ${usernameRow}
258
307
  <label class="field">
259
308
  <span class="field-label">Password</span>
260
309
  <input type="password" name="password" autocomplete="new-password"
@@ -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,10 +537,16 @@ 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
+ /**
545
+ * Resolve a module's loopback origin by short name (H4 — best-effort
546
+ * credential-removal notification during connection teardown). Optional:
547
+ * absent → the notification step records a warning, revocation still runs.
548
+ */
549
+ resolveModuleOrigin?: (short: string) => string | null;
544
550
  /** Test seam: run `parachute-vault remove` — same Runner seam as create. */
545
551
  runCommand?: CreateVaultDeps["runCommand"];
546
552
  /**
@@ -645,7 +651,7 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
645
651
  * the name);
646
652
  * 5. connections whose source/provisioned vault is the deleted vault
647
653
  * (via `teardownConnection`, which post-B0 also revokes the registered
648
- * 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`
649
655
  * for legacy vault-backed entries (`orphaned_channels`);
650
656
  * 6. mechanics: shell to `parachute-vault remove <name> --yes` (the module
651
657
  * CLI stays the single source of truth for vault destruction,
@@ -755,7 +761,10 @@ export async function handleDeleteVault(
755
761
  hubOrigin: deps.issuer,
756
762
  modules: [], // teardown never consults the catalog
757
763
  resolveVaultOrigin: deps.resolveVaultOrigin,
758
- channelOrigin: deps.channelOrigin,
764
+ ...(deps.resolveModuleOrigin !== undefined
765
+ ? { resolveModuleOrigin: deps.resolveModuleOrigin }
766
+ : {}),
767
+ agentOrigin: deps.agentOrigin,
759
768
  storePath: deps.connectionsStorePath,
760
769
  ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
761
770
  ...(deps.now !== undefined ? { now: deps.now } : {}),
@@ -788,9 +797,9 @@ export async function handleDeleteVault(
788
797
 
789
798
  // Legacy channel scan — vault-backed channel entries still referencing the
790
799
  // 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) {
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) {
794
803
  try {
795
804
  const fetchImpl = deps.fetchImpl ?? fetch;
796
805
  // Short-lived (60s) — stays below the registered-mint threshold by
@@ -799,8 +808,8 @@ export async function handleDeleteVault(
799
808
  const scanToken = (
800
809
  await signAccessToken(deps.db, {
801
810
  sub: adminSub,
802
- scopes: ["channel:admin"],
803
- audience: "channel",
811
+ scopes: ["agent:admin"],
812
+ audience: "agent",
804
813
  clientId: DELETE_VAULT_CLIENT_ID,
805
814
  issuer: deps.issuer,
806
815
  ttlSeconds: DELETE_VAULT_PROVISION_TTL_SECONDS,
@@ -808,7 +817,7 @@ export async function handleDeleteVault(
808
817
  ...(deps.now !== undefined ? { now: deps.now } : {}),
809
818
  })
810
819
  ).token;
811
- const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
820
+ const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
812
821
  headers: { authorization: `Bearer ${scanToken}`, accept: "application/json" },
813
822
  });
814
823
  if (res.ok) {
@@ -821,11 +830,11 @@ export async function handleDeleteVault(
821
830
  }
822
831
  }
823
832
  } else {
824
- warnings.push({ step: "channel_scan", detail: `channel list returned ${res.status}` });
833
+ warnings.push({ step: "agent_scan", detail: `agent channel list returned ${res.status}` });
825
834
  }
826
835
  } catch (err) {
827
836
  warnings.push({
828
- step: "channel_scan",
837
+ step: "agent_scan",
829
838
  detail: err instanceof Error ? err.message : String(err),
830
839
  });
831
840
  }