@openparachute/hub 0.7.1 → 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 (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,335 @@
1
+ /**
2
+ * Admin screen-lock management API (hub admin-lock feature).
3
+ *
4
+ * All endpoints are session-cookie-gated to the first admin — the same gate
5
+ * the admin-token mints use (`isFirstAdmin`). These manage the lock itself, so
6
+ * they are NOT behind the lock gate (you must be able to unlock + set the PIN
7
+ * even when the surface is locked).
8
+ *
9
+ * GET /api/admin-lock → status { configured, locked, idle_seconds, unlock_seconds_remaining }
10
+ * POST /api/admin-lock/set → set the FIRST PIN (no PIN configured yet)
11
+ * POST /api/admin-lock/change → rotate PIN (requires current PIN or unlocked session)
12
+ * POST /api/admin-lock/remove → turn the feature OFF (requires current PIN or unlocked session)
13
+ * POST /api/admin-lock/unlock → verify PIN, open an unlock window
14
+ * POST /api/admin-lock/lock → "Lock now" — drop the session's unlock window
15
+ * POST /api/admin-lock/heartbeat → slide the idle window forward on activity
16
+ *
17
+ * The chicken-and-egg: setting the FIRST PIN is an authenticated admin action
18
+ * (logged-in session, no PIN yet → allowed). Once a PIN exists, change/remove
19
+ * require proving knowledge of the current PIN OR an already-unlocked session
20
+ * (the operator just unlocked, so they hold the PIN — re-typing would be
21
+ * friction). Setting over an existing PIN is rejected (use change).
22
+ *
23
+ * State-changing POSTs additionally require a CSRF token (`__csrf`) — these
24
+ * are JSON endpoints on the same origin as the cookie, and SameSite=Lax alone
25
+ * doesn't stop same-site CSRF (see src/csrf.ts). The SPA sources the token
26
+ * from `/api/me`.
27
+ */
28
+ import type { Database } from "bun:sqlite";
29
+ import {
30
+ clampIdleSeconds,
31
+ clearPin,
32
+ getIdleSeconds,
33
+ isLockConfigured,
34
+ isSessionUnlocked,
35
+ lockSession,
36
+ recordUnlock,
37
+ refreshActivity,
38
+ setIdleSeconds,
39
+ setPin,
40
+ unlockLimiter,
41
+ unlockSecondsRemaining,
42
+ validatePin,
43
+ verifyPin,
44
+ } from "./admin-lock.ts";
45
+ import { verifyCsrfToken } from "./csrf.ts";
46
+ import { findSession, parseSessionCookie } from "./sessions.ts";
47
+ import { isFirstAdmin } from "./users.ts";
48
+
49
+ export interface AdminLockDeps {
50
+ db: Database;
51
+ /** Injectable clock for tests. */
52
+ now?: () => Date;
53
+ }
54
+
55
+ function json(status: number, body: unknown): Response {
56
+ return new Response(JSON.stringify(body), {
57
+ status,
58
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
59
+ });
60
+ }
61
+
62
+ function err(status: number, error: string, description: string): Response {
63
+ return json(status, { error, error_description: description });
64
+ }
65
+
66
+ /**
67
+ * Resolve the first-admin session for this request, or an error Response.
68
+ * Mirrors the gate the token-mint endpoints apply, so the lock-management
69
+ * surface has the same audience.
70
+ */
71
+ function requireAdminSession(
72
+ db: Database,
73
+ req: Request,
74
+ ): { ok: true; sessionId: string; userId: string } | { ok: false; res: Response } {
75
+ const sid = parseSessionCookie(req.headers.get("cookie"));
76
+ const session = sid ? findSession(db, sid) : null;
77
+ if (!session || !sid) {
78
+ return {
79
+ ok: false,
80
+ res: err(401, "unauthenticated", "no admin session — sign in at /login first"),
81
+ };
82
+ }
83
+ if (!isFirstAdmin(db, session.userId)) {
84
+ return {
85
+ ok: false,
86
+ res: err(
87
+ 403,
88
+ "not_admin",
89
+ "admin lock management is restricted to the hub admin — your account home is at /account/",
90
+ ),
91
+ };
92
+ }
93
+ return { ok: true, sessionId: sid, userId: session.userId };
94
+ }
95
+
96
+ async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
97
+ try {
98
+ const body = (await req.json()) as unknown;
99
+ return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+
105
+ function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
106
+ const token = typeof body.__csrf === "string" ? body.__csrf : null;
107
+ return verifyCsrfToken(req, token);
108
+ }
109
+
110
+ /**
111
+ * The lock-management router. `subpath` is the path AFTER `/api/admin-lock`
112
+ * (e.g. "" for status, "/unlock", "/set"). The hub-server dispatcher slices it.
113
+ */
114
+ export async function handleAdminLock(
115
+ req: Request,
116
+ subpath: string,
117
+ deps: AdminLockDeps,
118
+ ): Promise<Response> {
119
+ const { db } = deps;
120
+ const now = deps.now ?? (() => new Date());
121
+
122
+ // GET status (no body, no CSRF — read-only).
123
+ if (subpath === "" || subpath === "/") {
124
+ if (req.method !== "GET") return err(405, "method_not_allowed", "use GET");
125
+ const gate = requireAdminSession(db, req);
126
+ if (!gate.ok) return gate.res;
127
+ const configured = isLockConfigured(db);
128
+ const nowMs = now().getTime();
129
+ const locked = configured && !isSessionUnlocked(gate.sessionId, nowMs);
130
+ return json(200, {
131
+ configured,
132
+ locked,
133
+ idle_seconds: getIdleSeconds(db),
134
+ unlock_seconds_remaining: configured ? unlockSecondsRemaining(gate.sessionId, nowMs) : 0,
135
+ });
136
+ }
137
+
138
+ // Everything below is a POST.
139
+ if (req.method !== "POST") return err(405, "method_not_allowed", "use POST");
140
+ const gate = requireAdminSession(db, req);
141
+ if (!gate.ok) return gate.res;
142
+ const body = await readJsonBody(req);
143
+ if (!checkCsrf(req, body)) {
144
+ return err(403, "csrf_failed", "missing or invalid CSRF token");
145
+ }
146
+
147
+ switch (subpath) {
148
+ case "/set":
149
+ return handleSet(db, gate.sessionId, body);
150
+ case "/change":
151
+ return handleChange(db, gate.sessionId, body, now);
152
+ case "/remove":
153
+ return handleRemove(db, gate.sessionId, body, now);
154
+ case "/unlock":
155
+ return handleUnlock(db, gate.sessionId, gate.userId, body, now);
156
+ case "/lock":
157
+ lockSession(gate.sessionId);
158
+ return json(200, { locked: true });
159
+ case "/heartbeat":
160
+ // Slide the idle window forward if (and only if) currently unlocked.
161
+ refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
162
+ return json(200, {
163
+ locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
164
+ unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
165
+ });
166
+ default:
167
+ return err(404, "not_found", `no admin-lock route at /api/admin-lock${subpath}`);
168
+ }
169
+ }
170
+
171
+ /** Optional idle-seconds override from a request body field. Clamped; undefined when absent/invalid. */
172
+ function readIdleSeconds(body: Record<string, unknown>): number | undefined {
173
+ const raw = body.idle_seconds;
174
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
175
+ return clampIdleSeconds(raw);
176
+ }
177
+
178
+ async function handleSet(
179
+ db: Database,
180
+ sessionId: string,
181
+ body: Record<string, unknown>,
182
+ ): Promise<Response> {
183
+ // Setting the FIRST PIN — refuse if one already exists (use /change).
184
+ if (isLockConfigured(db)) {
185
+ return err(409, "already_configured", "a PIN is already set — use /api/admin-lock/change");
186
+ }
187
+ const pin = typeof body.pin === "string" ? body.pin : "";
188
+ if (!validatePin(pin).valid) {
189
+ return err(400, "invalid_pin", "PIN must be 4–12 digits");
190
+ }
191
+ const idle = readIdleSeconds(body);
192
+ if (idle !== undefined) setIdleSeconds(db, idle);
193
+ await setPin(db, pin);
194
+ // Setting the PIN immediately opens an unlock window for this session — the
195
+ // operator who just typed it isn't locked out of their own current session.
196
+ recordUnlock(sessionId, getIdleSeconds(db));
197
+ return json(201, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
198
+ }
199
+
200
+ async function handleChange(
201
+ db: Database,
202
+ sessionId: string,
203
+ body: Record<string, unknown>,
204
+ now: () => Date,
205
+ ): Promise<Response> {
206
+ if (!isLockConfigured(db)) {
207
+ return err(409, "not_configured", "no PIN is set — use /api/admin-lock/set");
208
+ }
209
+ const newPin = typeof body.new_pin === "string" ? body.new_pin : "";
210
+ if (!validatePin(newPin).valid) {
211
+ return err(400, "invalid_pin", "new PIN must be 4–12 digits");
212
+ }
213
+ // Authorize the change: an already-unlocked session OR a correct current PIN.
214
+ const authorized = await authorizeMutation(db, sessionId, body, now);
215
+ if (!authorized.ok) return authorized.res;
216
+ const idle = readIdleSeconds(body);
217
+ if (idle !== undefined) setIdleSeconds(db, idle);
218
+ await setPin(db, newPin);
219
+ // Re-open the unlock window for this session under the new PIN.
220
+ recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
221
+ return json(200, { configured: true, locked: false, idle_seconds: getIdleSeconds(db) });
222
+ }
223
+
224
+ async function handleRemove(
225
+ db: Database,
226
+ sessionId: string,
227
+ body: Record<string, unknown>,
228
+ now: () => Date,
229
+ ): Promise<Response> {
230
+ if (!isLockConfigured(db)) {
231
+ // Idempotent — already off.
232
+ return json(200, { configured: false, locked: false });
233
+ }
234
+ const authorized = await authorizeMutation(db, sessionId, body, now);
235
+ if (!authorized.ok) return authorized.res;
236
+ clearPin(db);
237
+ // Feature is off now — drop any unlock window (it no longer means anything).
238
+ lockSession(sessionId);
239
+ return json(200, { configured: false, locked: false });
240
+ }
241
+
242
+ /**
243
+ * Shared authorization for change/remove: succeed when the session is already
244
+ * unlocked, OR a correct `current_pin` is supplied. The current-PIN path runs
245
+ * through the same brute-force limiter as /unlock.
246
+ */
247
+ async function authorizeMutation(
248
+ db: Database,
249
+ sessionId: string,
250
+ body: Record<string, unknown>,
251
+ now: () => Date,
252
+ ): Promise<{ ok: true } | { ok: false; res: Response }> {
253
+ if (isSessionUnlocked(sessionId, now().getTime())) return { ok: true };
254
+ const currentPin = typeof body.current_pin === "string" ? body.current_pin : "";
255
+ if (!currentPin) {
256
+ return {
257
+ ok: false,
258
+ res: err(
259
+ 401,
260
+ "pin_required",
261
+ "this session is locked — supply current_pin (or unlock first)",
262
+ ),
263
+ };
264
+ }
265
+ // Rate-limit BEFORE the argon2id verify (a stolen cookie shouldn't grind).
266
+ const rl = unlockLimiter.checkAndRecord(sessionId, now());
267
+ if (!rl.allowed) {
268
+ return {
269
+ ok: false,
270
+ res: new Response(
271
+ JSON.stringify({
272
+ error: "too_many_attempts",
273
+ error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
274
+ }),
275
+ {
276
+ status: 429,
277
+ headers: {
278
+ "content-type": "application/json",
279
+ "cache-control": "no-store",
280
+ "retry-after": String(rl.retryAfterSeconds ?? 60),
281
+ },
282
+ },
283
+ ),
284
+ };
285
+ }
286
+ const ok = await verifyPin(db, currentPin);
287
+ if (!ok) return { ok: false, res: err(401, "wrong_pin", "incorrect PIN") };
288
+ return { ok: true };
289
+ }
290
+
291
+ async function handleUnlock(
292
+ db: Database,
293
+ sessionId: string,
294
+ _userId: string,
295
+ body: Record<string, unknown>,
296
+ now: () => Date,
297
+ ): Promise<Response> {
298
+ if (!isLockConfigured(db)) {
299
+ // No PIN → nothing to unlock; treat as already-open so the SPA proceeds.
300
+ return json(200, { unlocked: true, configured: false, idle_seconds: getIdleSeconds(db) });
301
+ }
302
+ const pin = typeof body.pin === "string" ? body.pin : "";
303
+ // Rate-limit BEFORE the argon2id verify — keyed by session (the session
304
+ // already identifies the actor; a stolen cookie shouldn't get fresh buckets
305
+ // across IPs). The denied attempt still counts toward the bucket so a wrong
306
+ // PIN can't be retried infinitely.
307
+ const rl = unlockLimiter.checkAndRecord(sessionId, now());
308
+ if (!rl.allowed) {
309
+ return new Response(
310
+ JSON.stringify({
311
+ error: "too_many_attempts",
312
+ error_description: `too many PIN attempts — retry in ${rl.retryAfterSeconds}s`,
313
+ }),
314
+ {
315
+ status: 429,
316
+ headers: {
317
+ "content-type": "application/json",
318
+ "cache-control": "no-store",
319
+ "retry-after": String(rl.retryAfterSeconds ?? 60),
320
+ },
321
+ },
322
+ );
323
+ }
324
+ const ok = await verifyPin(db, pin);
325
+ if (!ok) {
326
+ return err(401, "wrong_pin", "incorrect PIN");
327
+ }
328
+ recordUnlock(sessionId, getIdleSeconds(db), now().getTime());
329
+ return json(200, {
330
+ unlocked: true,
331
+ configured: true,
332
+ idle_seconds: getIdleSeconds(db),
333
+ unlock_seconds_remaining: unlockSecondsRemaining(sessionId, now().getTime()),
334
+ });
335
+ }
@@ -286,8 +286,9 @@ interface PathMatch {
286
286
  * for — but the gate is now "is this a KNOWN module" (`isKnownModuleShort`:
287
287
  * KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old `CURATED_MODULES`
288
288
  * whitelist (2026-06-09 modular-UI architecture, P2). This is what makes
289
- * `POST /api/modules/channel/install` resolve — channel was discoverable +
290
- * running yet un-installable because it sat outside the whitelist.
289
+ * `POST /api/modules/agent/install` resolve — the agent module (then named
290
+ * channel) was discoverable + running yet un-installable because it sat
291
+ * outside the whitelist.
291
292
  *
292
293
  * The hub (`hub`) and genuinely third-party services.json rows still fall
293
294
  * through to undefined: there's no install package to act on.
@@ -53,8 +53,8 @@ import {
53
53
  shortNameForManifest,
54
54
  } from "./service-spec.ts";
55
55
  // `FIRST_PARTY_FALLBACKS` and `KNOWN_MODULES` are both consulted by
56
- // `lookupModule` below — the former for notes/channel (vendored manifests
57
- // still required) and the latter for vault/scribe/runner (post-FALLBACK
56
+ // `lookupModule` below — the former for notes (vendored manifest still
57
+ // required) and the latter for vault/scribe/runner/agent (post-FALLBACK
58
58
  // retirement, hub#310). The local helper hides the split from the rest of
59
59
  // this file. `discoverableShorts` enumerates their UNION — the
60
60
  // self-registration-driven discovery surface that replaced the old
@@ -267,14 +267,14 @@ interface ModuleWireShape {
267
267
  * `.parachute/module.json` `configUiUrl`, joined against its mount path the
268
268
  * same way `management_url` resolves `managementUrl`/`uiUrl`. Drives the
269
269
  * Modules page's consistent **Configure** action — clicking lands the
270
- * operator on the module's own config UI (channel `/channel/admin`, scribe
270
+ * operator on the module's own config UI (agent `/agent/admin`, scribe
271
271
  * `/scribe/admin`, …), which mints its admin Bearer from the hub's
272
- * cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
272
+ * cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
273
273
  *
274
274
  * Null when the module hasn't declared `configUiUrl` — the SPA omits the
275
275
  * Configure action for that module rather than rendering a dead button.
276
276
  * Distinct from `management_url`: a module may declare one, both, or
277
- * neither. Channel declares `configUiUrl: "/channel/admin"` + `uiUrl`;
277
+ * neither. Agent declares `configUiUrl: "/agent/admin"` + `uiUrl`;
278
278
  * vault declares `managementUrl` (its admin SPA is the config surface).
279
279
  */
280
280
  config_ui_url: string | null;
@@ -493,8 +493,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
493
493
  );
494
494
  if (resolvedManagement !== undefined) managementUrlByShort.set(short, resolvedManagement);
495
495
  // The config surface resolves with the SAME rule. A module may declare
496
- // `configUiUrl` independently of `managementUrl` — channel ships
497
- // `configUiUrl: "/channel/admin"` (single-instance, origin-absolute)
496
+ // `configUiUrl` independently of `managementUrl` — agent ships
497
+ // `configUiUrl: "/agent/admin"` (single-instance, origin-absolute)
498
498
  // alongside a separate `uiUrl`.
499
499
  const resolvedConfig = resolveModuleUrl(m.configUiUrl, value.mountPath, short);
500
500
  if (resolvedConfig !== undefined) configUiUrlByShort.set(short, resolvedConfig);
@@ -744,8 +744,8 @@ let warnedLegacyVaultAdminCandidate = false;
744
744
  * - `undefined` candidate → `undefined` (the module didn't declare it).
745
745
  * - Absolute http(s) URL → returned verbatim (off-origin escape hatch).
746
746
  * - Leading-`/` path → ORIGIN-ABSOLUTE, returned verbatim. Single-instance
747
- * modules (surface, scribe, runner, channel) declare their full hub-origin
748
- * path this way (`/surface/admin/`, `/scribe/admin`, `/channel/admin`);
747
+ * modules (surface, scribe, runner, agent) declare their full hub-origin
748
+ * path this way (`/surface/admin/`, `/scribe/admin`, `/agent/admin`);
749
749
  * vault's daemon-level surface is `/vault/admin/`.
750
750
  * - Relative path (no leading slash) → MOUNT-JOINED: the per-instance form
751
751
  * (`admin/` on a `/vault/default` mount → `/vault/default/admin/`). With
package/src/cli.ts CHANGED
@@ -939,7 +939,19 @@ async function main(argv: string[]): Promise<number> {
939
939
  // after `vault` (including --help) is passed through verbatim.
940
940
  if (rest.length === 0) return await mod.dispatchVault(["--help"]);
941
941
 
942
- // Everything under `vault` forwards transparently to `parachute-vault`.
942
+ // Intercept the delete verbs BEFORE the transparent passthrough (B3):
943
+ // `parachute vault remove <name>` must route through the hub's identity
944
+ // cascade (`DELETE /vaults/<name>`), NOT forward verbatim to
945
+ // `parachute-vault remove` (mechanics-only — orphans hub-side tokens,
946
+ // grants, user_vaults rows). `rm` is a convenience alias to the same path.
947
+ const sub = rest[0];
948
+ if (sub === "remove" || sub === "rm") {
949
+ const rm = await loadCommand("vault-remove", () => import("./commands/vault-remove.ts"));
950
+ if (!rm) return 1;
951
+ return await rm.vaultRemove(rest.slice(1));
952
+ }
953
+
954
+ // Everything else under `vault` forwards transparently to `parachute-vault`.
943
955
  // `vault tokens create` used to route through a guided interactive
944
956
  // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
945
957
  // subcommand — it now exits 1 with migration guidance. Access tokens are
package/src/clients.ts CHANGED
@@ -215,6 +215,94 @@ export function requireRegisteredRedirectUri(client: OAuthClient, candidate: str
215
215
  return candidate;
216
216
  }
217
217
 
218
+ /**
219
+ * Cross-hub-origin redirect_uri expansion for hub-served DCR clients
220
+ * (surface#118; the "hub assist" half of the fix).
221
+ *
222
+ * THE PROBLEM. A hub-served module (surface-host, notes) registers its OAuth
223
+ * client at install time, when the only origin it knows is the loopback hub
224
+ * URL (`http://127.0.0.1:1939`). Once the operator runs `parachute expose`,
225
+ * the browser loads the surface from the PUBLIC hub origin and the
226
+ * surface-client runtime computes its `redirect_uri` from `window.location.
227
+ * origin` (the public one) — which was never registered. Authorize-time
228
+ * matching is deliberately strict exact-match (`requireRegisteredRedirectUri`,
229
+ * RFC 8252 anti-open-redirect), so the public-origin redirect_uri is rejected
230
+ * ("Redirect mismatch") and no off-localhost user can sign in.
231
+ *
232
+ * THE FIX. At DCR time, for each submitted redirect_uri WHOSE ORIGIN IS ONE
233
+ * OF THE HUB'S OWN KNOWN ORIGINS (issuer, expose-state hubOrigin, platform
234
+ * origin, loopback aliases — i.e. the same set `buildHubBoundOrigins`
235
+ * produces), ALSO register the same PATH on every OTHER known hub origin. A
236
+ * loopback-registered hub-served client thus becomes valid on the public hub
237
+ * origin too, without ever loosening the strict authorize-time match.
238
+ *
239
+ * THE INVARIANT (no open redirect). Expansion ONLY ever produces URIs rooted
240
+ * at the hub's OWN known origins. A submitted redirect_uri whose origin is
241
+ * FOREIGN — a separate-origin surface on its own domain (e.g. my-vault-ui at
242
+ * `https://notes.example`), a third-party app — is stored VERBATIM: not
243
+ * expanded onto any hub origin, and not dropped. Only hub-origin-rooted URIs
244
+ * receive the cross-origin fan-out. Because authorize-time matching stays
245
+ * strict exact-match against this stored set, an attacker who registers a
246
+ * foreign redirect_uri gets back exactly what they submitted (no hub-origin
247
+ * variant minted for them), and a hub-origin-rooted URI only ever fans out to
248
+ * other hub origins the operator already controls.
249
+ *
250
+ * Order-preserving + de-duplicated: the originally-submitted URIs come first
251
+ * (preserving caller order), with the synthesized hub-origin variants
252
+ * appended in a stable order; duplicates are collapsed.
253
+ */
254
+ export function expandRedirectUrisForHubOrigins(
255
+ submitted: readonly string[],
256
+ hubOrigins: readonly string[],
257
+ ): string[] {
258
+ // Parse the hub's known origins into a Set for membership tests. Malformed
259
+ // entries are skipped — the function fails safe (no expansion) rather than
260
+ // throwing.
261
+ const hubOriginSet = new Set<string>();
262
+ for (const raw of hubOrigins) {
263
+ try {
264
+ hubOriginSet.add(new URL(raw).origin);
265
+ } catch {
266
+ // Skip malformed hub origin.
267
+ }
268
+ }
269
+
270
+ // Preserve submitted order + dedupe; append synthesized variants after.
271
+ const out: string[] = [];
272
+ const seen = new Set<string>();
273
+ const push = (uri: string) => {
274
+ if (seen.has(uri)) return;
275
+ seen.add(uri);
276
+ out.push(uri);
277
+ };
278
+
279
+ // 1) Every submitted URI is stored as-is (foreign + hub-origin alike). This
280
+ // is what guarantees a foreign redirect_uri is never dropped.
281
+ for (const uri of submitted) push(uri);
282
+
283
+ // 2) For each submitted URI rooted at a hub origin, synthesize the same path
284
+ // on every OTHER hub origin. Foreign-origin URIs are skipped here — they
285
+ // never spawn a hub-origin variant (the open-redirect guard).
286
+ if (hubOriginSet.size > 1) {
287
+ for (const uri of submitted) {
288
+ let parsed: URL;
289
+ try {
290
+ parsed = new URL(uri);
291
+ } catch {
292
+ continue;
293
+ }
294
+ if (!hubOriginSet.has(parsed.origin)) continue; // foreign → no expansion
295
+ const pathSuffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
296
+ for (const origin of hubOriginSet) {
297
+ if (origin === parsed.origin) continue;
298
+ push(`${origin}${pathSuffix}`);
299
+ }
300
+ }
301
+ }
302
+
303
+ return out;
304
+ }
305
+
218
306
  export function verifyClientSecret(client: OAuthClient, presented: string): boolean {
219
307
  if (!client.clientSecretHash) return false;
220
308
  const presentedHash = createHash("sha256").update(presented).digest("hex");
@@ -125,9 +125,16 @@ export function resolveInstallChannel(opts: {
125
125
  * install lens` during the ~3-day window keeps working. Remove after
126
126
  * launch sinks in and `parachute install lens` has stopped appearing
127
127
  * in support threads.
128
+ *
129
+ * `channel → agent` (2026-06-17): parachute-channel was renamed to
130
+ * parachute-agent. `parachute install channel` keeps resolving to the
131
+ * agent module for one release cycle so operators mid-upgrade aren't
132
+ * stranded. Remove alongside the `parachute-channel → agent` legacy
133
+ * manifest alias in service-spec.ts.
128
134
  */
129
135
  const SERVICE_ALIASES: Record<string, string> = {
130
136
  lens: "notes",
137
+ channel: "agent",
131
138
  };
132
139
 
133
140
  export interface InstallOpts {
@@ -78,12 +78,13 @@ export interface BuildSpawnRequestOpts {
78
78
  *
79
79
  * Why (channel#41): the hub is the port authority, and a first-party module
80
80
  * with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
81
- * vault / scribe / surface / channel / notes / runner) has exactly ONE correct
81
+ * vault / scribe / surface / agent / notes / runner) has exactly ONE correct
82
82
  * port: its canonical one. The injected `PORT`, the supervisor's readiness
83
83
  * probe, and the reverse-proxy target are ALL derived from the services.json
84
- * row's `port`. So once a transiently-wrong port lands in that row (the channel
85
- * row was observed live carrying `19415` instead of `1941`), it self-perpetuates:
86
- * the supervisor probes/proxies the wrong port forever and `/channel/*` routes
84
+ * row's `port`. So once a transiently-wrong port lands in that row (the agent
85
+ * row — then named channel — was observed live carrying `19415` instead of
86
+ * `1941`), it self-perpetuates:
87
+ * the supervisor probes/proxies the wrong port forever and `/agent/*` routes
87
88
  * to a dead port. The canonical port is authoritative, so we reconcile the row
88
89
  * before spawn rather than honoring the drift.
89
90
  *
@@ -38,6 +38,7 @@ import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../h
38
38
  import { hubDbPath, openHubDb } from "../hub-db.ts";
39
39
  import { hubFetch } from "../hub-server.ts";
40
40
  import { writeHubFile } from "../hub.ts";
41
+ import { createWsBridgeHandlers } from "../ws-bridge.ts";
41
42
  import { enrichedPath } from "../spawn-path.ts";
42
43
  import { Supervisor } from "../supervisor.ts";
43
44
  import { createUser, userCount } from "../users.ts";
@@ -45,6 +46,35 @@ import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
45
46
  import { WELL_KNOWN_DIR } from "../well-known.ts";
46
47
  import { bootSupervisedModules } from "./serve-boot.ts";
47
48
 
49
+ /**
50
+ * Build the `Bun.serve` options for the hub listener. Extracted (pure) so the
51
+ * load-bearing wiring — crucially the `websocket` bridge handler — is unit-
52
+ * testable and can't silently drift from the other `Bun.serve` in
53
+ * `hub-server.ts`. That drift is exactly how the in-page-terminal WS upgrade
54
+ * started 500ing: the bridge was wired into hub-server.ts's serve but NOT this
55
+ * production `parachute serve` path, so `server.upgrade()` (in `hubFetch`'s
56
+ * `maybeUpgradeWebSocket`) threw "set the websocket object in Bun.serve({})".
57
+ */
58
+ export function hubServeOptions(args: {
59
+ port: number;
60
+ hostname: string;
61
+ fetch: ReturnType<typeof hubFetch>;
62
+ }) {
63
+ return {
64
+ port: args.port,
65
+ hostname: args.hostname,
66
+ // Hold idle keep-alive connections for Bun's maximum 255s so reverse-proxy
67
+ // edges (Render, Cloudflare, fly.io) don't race us reusing pooled
68
+ // connections (hub#399).
69
+ idleTimeout: 255,
70
+ fetch: args.fetch,
71
+ // The WebSocket upgrade bridge. `maybeUpgradeWebSocket` (in `hubFetch`) calls
72
+ // `server.upgrade()`, which THROWS unless the server declares this handler —
73
+ // so it MUST be present on every Bun.serve that runs `hubFetch`.
74
+ websocket: createWsBridgeHandlers(),
75
+ };
76
+ }
77
+
48
78
  export interface ServeOpts {
49
79
  /** Override PORT (test-only). Real callers thread env via process.env. */
50
80
  port?: number;
@@ -456,26 +486,22 @@ export async function serve(opts: ServeOpts = {}): Promise<{
456
486
  // fail fast and cleanly, leaving the live hub's children untouched.
457
487
  let server: ReturnType<typeof Bun.serve>;
458
488
  try {
459
- server = Bun.serve({
460
- port,
461
- hostname,
462
- // Hold idle keep-alive connections for Bun's maximum 255s so reverse-
463
- // proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
464
- // pooled connections. See `src/hub-server.ts` for the full rationale —
465
- // this is the active code path for `bun src/cli.ts serve` (the Docker
466
- // CMD), so the fix has to land here too. Closes hub#399.
467
- idleTimeout: 255,
468
- fetch: hubFetch(WELL_KNOWN_DIR, {
469
- getDb: () => dbHolder.get(),
470
- onDbError: (err) => dbHolder.healOrExit(err),
471
- // #610: /health's db check probes the path so monitoring + the #591
472
- // adoption probe see a wipe instead of the ghost-fd lie.
473
- probeDbPath: () => dbHolder.probePath(),
474
- issuer,
475
- loopbackPort: port,
476
- supervisor,
489
+ server = Bun.serve(
490
+ hubServeOptions({
491
+ port,
492
+ hostname,
493
+ fetch: hubFetch(WELL_KNOWN_DIR, {
494
+ getDb: () => dbHolder.get(),
495
+ onDbError: (err) => dbHolder.healOrExit(err),
496
+ // #610: /health's db check probes the path so monitoring + the #591
497
+ // adoption probe see a wipe instead of the ghost-fd lie.
498
+ probeDbPath: () => dbHolder.probePath(),
499
+ issuer,
500
+ loopbackPort: port,
501
+ supervisor,
502
+ }),
477
503
  }),
478
- });
504
+ );
479
505
  } catch (err) {
480
506
  const conflict = hubPortConflictMessage(err, port);
481
507
  if (conflict) throw new Error(conflict);
@@ -109,13 +109,13 @@ function defaultAvailability(): InteractiveAvailability {
109
109
 
110
110
  /**
111
111
  * Survey the eligible services. We include the four first-party shortnames
112
- * (vault / notes / scribe / channel + runner) but flag channel as exploratory
112
+ * (vault / notes / scribe / agent + runner) but flag agent as exploratory
113
113
  * in the blurb so operators don't grab it by reflex. `installed` is true when
114
114
  * the service has a row in services.json.
115
115
  *
116
116
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
117
117
  * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
118
- * (vault / scribe / runner / channel / surface) ship `.parachute/module.json`
118
+ * (vault / scribe / runner / agent / surface) ship `.parachute/module.json`
119
119
  * and self-register; pre-install we know manifestName + the urlForEntry quirk
120
120
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
121
121
  */
@@ -155,7 +155,8 @@ const BLURBS: Record<string, string> = {
155
155
  notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `app`)",
156
156
  scribe: "audio transcription for dictation + recordings",
157
157
  runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
158
- channel: "(exploratory — may retire) notification fan-out across modules",
158
+ agent:
159
+ "(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
159
160
  };
160
161
 
161
162
  function blurbFor(choice: ServiceChoice): string {