@openparachute/hub 0.7.4-rc.8 → 0.7.4

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 (71) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +276 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
31
31
  import { join } from "node:path";
32
32
  import { hubDbPath, openHubDb } from "../hub-db.ts";
33
33
  import { hubFetch } from "../hub-server.ts";
34
+ import { setRootRedirect, setSetting } from "../hub-settings.ts";
34
35
  import { writeManifest } from "../services-manifest.ts";
35
36
  import { createUser } from "../users.ts";
36
37
 
@@ -101,9 +102,7 @@ describe("setup gate (no admin yet)", () => {
101
102
  test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
102
103
  const db = openHubDb(hubDbPath(h.dir));
103
104
  try {
104
- const res = await hubFetch(h.dir, { getDb: () => db })(
105
- req("/login", { method: "POST" }),
106
- );
105
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/login", { method: "POST" }));
107
106
  expect(res.status).toBe(503);
108
107
  const body = (await res.json()) as Record<string, unknown>;
109
108
  expect(body.error).toBe("setup_required");
@@ -368,3 +367,112 @@ describe("setup gate (admin exists)", () => {
368
367
  }
369
368
  });
370
369
  });
370
+
371
+ describe("configurable bare-`/` redirect target", () => {
372
+ let h: Harness;
373
+ beforeEach(() => {
374
+ h = makeHarness();
375
+ });
376
+ afterEach(() => h.cleanup());
377
+
378
+ /** A set-up hub (admin + vault) so the bare-`/` redirect is reached. */
379
+ function setUpHub(db: ReturnType<typeof openHubDb>): void {
380
+ writeManifest(
381
+ {
382
+ services: [
383
+ {
384
+ name: "parachute-vault",
385
+ version: "0.1.0",
386
+ port: 1940,
387
+ paths: ["/vault/default"],
388
+ health: "/health",
389
+ },
390
+ ],
391
+ },
392
+ join(h.dir, "services.json"),
393
+ );
394
+ }
395
+
396
+ function handler(db: ReturnType<typeof openHubDb>) {
397
+ return hubFetch(h.dir, { getDb: () => db, manifestPath: join(h.dir, "services.json") });
398
+ }
399
+
400
+ test("a configured root_redirect retargets the bare-`/` 302", async () => {
401
+ const db = openHubDb(hubDbPath(h.dir));
402
+ try {
403
+ await createUser(db, "owner", "pw");
404
+ setUpHub(db);
405
+ setRootRedirect(db, "/surface/reading-room");
406
+ const res = await handler(db)(req("/"));
407
+ expect(res.status).toBe(302);
408
+ expect(res.headers.get("location")).toBe("/surface/reading-room");
409
+ } finally {
410
+ db.close();
411
+ }
412
+ });
413
+
414
+ test("an unsafe stored root_redirect falls back to /admin (never an open redirect)", async () => {
415
+ const db = openHubDb(hubDbPath(h.dir));
416
+ try {
417
+ await createUser(db, "owner", "pw");
418
+ setUpHub(db);
419
+ // A hand-edited sqlite row that bypassed write-side validation.
420
+ setSetting(db, "root_redirect", "//evil.com");
421
+ const res = await handler(db)(req("/"));
422
+ expect(res.status).toBe(302);
423
+ expect(res.headers.get("location")).toBe("/admin");
424
+ } finally {
425
+ db.close();
426
+ }
427
+ });
428
+
429
+ test("PARACHUTE_HUB_ROOT_REDIRECT env retargets the bare-`/` 302 (no DB row)", async () => {
430
+ const db = openHubDb(hubDbPath(h.dir));
431
+ const prev = process.env.PARACHUTE_HUB_ROOT_REDIRECT;
432
+ process.env.PARACHUTE_HUB_ROOT_REDIRECT = "/surface/from-env";
433
+ try {
434
+ await createUser(db, "owner", "pw");
435
+ setUpHub(db);
436
+ const res = await handler(db)(req("/"));
437
+ expect(res.status).toBe(302);
438
+ expect(res.headers.get("location")).toBe("/surface/from-env");
439
+ } finally {
440
+ // Restore process.env to its pre-test state. `delete` (not assign-undefined,
441
+ // which would coerce to the string "undefined") removes a key we added.
442
+ if (prev === undefined) {
443
+ // biome-ignore lint/performance/noDelete: env-key cleanup, not a hot path
444
+ delete process.env.PARACHUTE_HUB_ROOT_REDIRECT;
445
+ } else {
446
+ process.env.PARACHUTE_HUB_ROOT_REDIRECT = prev;
447
+ }
448
+ db.close();
449
+ }
450
+ });
451
+
452
+ test("wizard funnel WINS: a configured root_redirect does NOT bypass setup on a fresh hub", async () => {
453
+ const db = openHubDb(hubDbPath(h.dir));
454
+ try {
455
+ // No admin yet → not-set-up hub. Even with a surface configured, the
456
+ // bare-`/` must funnel to the wizard, not a surface that can't work yet.
457
+ setRootRedirect(db, "/surface/reading-room");
458
+ const res = await handler(db)(req("/"));
459
+ expect(res.status).toBe(302);
460
+ expect(res.headers.get("location")).toBe("/admin/setup");
461
+ } finally {
462
+ db.close();
463
+ }
464
+ });
465
+
466
+ test("default is unchanged: bare-`/` → /admin when nothing is configured", async () => {
467
+ const db = openHubDb(hubDbPath(h.dir));
468
+ try {
469
+ await createUser(db, "owner", "pw");
470
+ setUpHub(db);
471
+ const res = await handler(db)(req("/"));
472
+ expect(res.status).toBe(302);
473
+ expect(res.headers.get("location")).toBe("/admin");
474
+ } finally {
475
+ db.close();
476
+ }
477
+ });
478
+ });
@@ -83,6 +83,7 @@ const SUCCESS_BODY = {
83
83
  grants_dropped: 2,
84
84
  user_vaults_removed: 4,
85
85
  invites_invalidated: 1,
86
+ vault_cap_removed: true,
86
87
  connections_torn_down: 1,
87
88
  orphaned_channels: [],
88
89
  vault_removed: true,
@@ -156,6 +157,7 @@ describe("vaultRemove — 200 success", () => {
156
157
  expect(text).toContain("3");
157
158
  expect(text).toContain("user_vaults removed:");
158
159
  expect(text).toContain("4");
160
+ expect(text).toContain("storage cap removed:");
159
161
  expect(text).toContain("vault removed:");
160
162
  });
161
163
 
@@ -194,19 +196,35 @@ describe("vaultRemove — 200 success", () => {
194
196
  });
195
197
  });
196
198
 
197
- describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
198
- test("returns NON-ZERO and NEVER spawns parachute-vault", async () => {
199
+ describe("vaultRemove — last vault (#678: cascade-then-delete, no 409)", () => {
200
+ test("the last vault deletes via the cascade (200) and NEVER spawns parachute-vault directly", async () => {
199
201
  await withSpawnSpy(async (spawned) => {
200
- const { fetch, calls } = fakeFetch([
201
- {
202
- status: 409,
203
- body: {
204
- error: "last_vault",
205
- error_description:
206
- '"scratch" is the last vault on this hub. Create another vault first, or use the CLI.',
207
- },
202
+ // The endpoint no longer refuses the last vault — it returns 200 with the
203
+ // cascade summary, identical to any other delete. The CLI just renders it.
204
+ const lastVaultBody = {
205
+ ok: true,
206
+ name: "scratch",
207
+ cascade: {
208
+ tokens_revoked: 2,
209
+ grants_rewritten: 0,
210
+ grants_dropped: 1,
211
+ user_vaults_removed: 1,
212
+ invites_invalidated: 0,
213
+ vault_cap_removed: true,
214
+ connections_torn_down: 0,
215
+ orphaned_channels: [],
216
+ vault_removed: true,
217
+ module_restarted: true,
208
218
  },
209
- ]);
219
+ warnings: [
220
+ {
221
+ step: "last_vault",
222
+ detail:
223
+ "the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
224
+ },
225
+ ],
226
+ };
227
+ const { fetch, calls } = fakeFetch([{ status: 200, body: lastVaultBody }]);
210
228
  const sinks = makeSinks();
211
229
  const code = await vaultRemove(["scratch"], {
212
230
  resolveBearer: async () => BEARER,
@@ -214,17 +232,20 @@ describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
214
232
  log: sinks.log,
215
233
  logError: sinks.logError,
216
234
  });
217
- // Non-zero exit.
218
- expect(code).not.toBe(0);
219
- // Exactly ONE fetch (the DELETE) — no fall-through retry path.
235
+ // 200 → success exit; the cascade did its work.
236
+ expect(code).toBe(0);
237
+ // Exactly ONE fetch (the DELETE) — the cascade runs server-side over loopback.
220
238
  expect(calls).toHaveLength(1);
221
239
  expect(calls[0]?.method).toBe("DELETE");
222
- // The load-bearing invariant: no `parachute-vault` spawn.
240
+ // The load-bearing invariant still holds: the CLI never spawns
241
+ // `parachute-vault` itself — destruction goes through the hub endpoint.
223
242
  expect(spawned.count).toBe(0);
224
- // Surfaces the endpoint message + the cascade-skip warning on the escape hatch.
225
- const errText = sinks.errText();
226
- expect(errText).toContain("last vault");
227
- expect(errText).toContain("SKIPS the identity cascade");
243
+ // The cascade summary renders, including the last_vault heads-up warning.
244
+ const text = sinks.text();
245
+ expect(text).toContain("tokens revoked:");
246
+ expect(text).toContain("vault removed:");
247
+ expect(text).toContain("last_vault");
248
+ expect(text).toContain("auto_create: false");
228
249
  });
229
250
  });
230
251
  });
@@ -472,13 +472,48 @@ describe("buildWellKnown", () => {
472
472
  );
473
473
  });
474
474
 
475
- test("falls back to / for empty paths", () => {
475
+ test("an empty-paths VAULT row is skipped entirely — no phantom default (#478)", () => {
476
+ // A vault services row with `paths: []` means "module installed but no
477
+ // servable vault instance" (vault's self-register emits this at zero
478
+ // vaults). It must NOT fabricate a vault entry at root in either the
479
+ // `vaults` array or the flat `services` catalog. Mirrors the empty-paths
480
+ // skip in admin-vaults.ts / vault-names.ts / oauth-handlers.ts.
476
481
  const entry: ServiceEntry = { ...vault, paths: [] };
477
482
  const doc = buildWellKnown({
478
483
  services: [entry],
479
484
  canonicalOrigin: "https://x.example",
480
485
  });
481
- expect(doc.vaults[0]?.url).toBe("https://x.example/");
486
+ expect(doc.vaults).toEqual([]);
487
+ // The row contributes nothing to the flat services list either — no
488
+ // phantom `/` mount advertised.
489
+ expect(doc.services).toEqual([]);
490
+ });
491
+
492
+ test("positive control: a vault row WITH a path still emits its vault + services entries (#478)", () => {
493
+ const doc = buildWellKnown({
494
+ services: [{ ...vault, paths: ["/vault/default"] }],
495
+ canonicalOrigin: "https://x.example",
496
+ });
497
+ expect(doc.vaults).toEqual([
498
+ {
499
+ name: "default",
500
+ url: "https://x.example/vault/default",
501
+ version: "0.2.4",
502
+ },
503
+ ]);
504
+ expect(doc.services.map((s) => s.name)).toEqual(["parachute-vault"]);
505
+ });
506
+
507
+ test("a NON-vault row with empty paths still falls back to / (#478 scope guard)", () => {
508
+ // The empty-paths skip is vault-only. A non-vault service legitimately
509
+ // mounts at root when path-less — that behavior is unchanged.
510
+ const entry: ServiceEntry = { ...notes, paths: [] };
511
+ const doc = buildWellKnown({
512
+ services: [entry],
513
+ canonicalOrigin: "https://x.example",
514
+ });
515
+ expect(doc.services.map((s) => s.path)).toEqual(["/"]);
516
+ expect(doc.notes).toEqual([{ url: "https://x.example/", version: "0.0.1" }]);
482
517
  });
483
518
 
484
519
  // Hierarchical sub-units (hub#313 — parachute-app design doc §12). Each
@@ -62,6 +62,7 @@
62
62
  * scope-guard.
63
63
  */
64
64
  import type { Database } from "bun:sqlite";
65
+ import { recordLoginUnlock } from "./admin-lock.ts";
65
66
  import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
66
67
  import { type RunResult, provisionVault } from "./admin-vaults.ts";
67
68
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
@@ -528,6 +529,7 @@ export async function handleAccountSetupPost(
528
529
 
529
530
  // (6) Sign the invitee in + land them on /account/.
530
531
  const session = createSession(deps.db, { userId });
532
+ recordLoginUnlock(deps.db, session.id);
531
533
  const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
532
534
  secure: isHttpsRequest(req),
533
535
  });
@@ -131,6 +131,16 @@ export interface AgentGrantsDeps {
131
131
  * (`<hubOrigin>/oauth/agent-grant/callback`).
132
132
  */
133
133
  hubOrigin: string;
134
+ /**
135
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
136
+ * per-request issuer), built via `buildHubBoundOrigins`. The module's
137
+ * host-admin bearer `iss` is validated against THIS set rather than the
138
+ * single `hubOrigin`, so the agent module's credential minted under a
139
+ * still-valid prior origin keeps working across an origin switch (hub#516
140
+ * parity). Minted tokens still carry `hubOrigin`. Absent → falls back to
141
+ * `[hubOrigin]` (the prior strict per-request behavior).
142
+ */
143
+ knownIssuers?: readonly string[];
134
144
  /** Absolute path to `agent-grants.json` in the hub state dir. */
135
145
  storePath: string;
136
146
  /** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
@@ -249,7 +259,12 @@ async function requireModuleAuth(
249
259
  deps: AgentGrantsDeps,
250
260
  ): Promise<AdminAuthContext | Response> {
251
261
  try {
252
- return await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.hubOrigin);
262
+ return await requireScope(
263
+ deps.db,
264
+ req,
265
+ HOST_ADMIN_SCOPE,
266
+ deps.knownIssuers ?? [deps.hubOrigin],
267
+ );
253
268
  } catch (err) {
254
269
  return adminAuthErrorResponse(err as AdminAuthError);
255
270
  }
package/src/admin-auth.ts CHANGED
@@ -59,15 +59,24 @@ export function extractBearerToken(req: Request): string {
59
59
  * and check it carries `requiredScope`. Returns surfaced claims on success;
60
60
  * throws `AdminAuthError` (401 or 403) otherwise.
61
61
  *
62
- * `expectedIssuer` MUST be the hub's own origin — the same value baked into
63
- * tokens we sign. Defense in depth: even though we can only verify our own
64
- * keys, the `iss` mismatch reject keeps cross-issuer confusion impossible.
62
+ * `expectedIssuer` is the hub's own origin(s) — the same value(s) baked into
63
+ * tokens we sign. Pass a single string for a single-origin hub, or the SET of
64
+ * origins the hub legitimately answers on (`buildHubBoundOrigins`: loopback
65
+ * expose-state ∪ platform ∪ per-request issuer) so a credential minted under
66
+ * a still-valid prior origin keeps validating across an origin switch — the
67
+ * same multi-origin posture the OAuth path and `validateHostAdminToken`
68
+ * already use. Defense in depth: even though we can only verify our own keys,
69
+ * the `iss`-∈-set reject keeps cross-issuer confusion impossible. SECURITY:
70
+ * the set is ONLY an additive `iss` membership relaxation — `validateAccessToken`
71
+ * verifies the signature against the hub's own key FIRST, so only tokens this
72
+ * hub minted ever reach the `iss` check; never pass a raw request Host, only a
73
+ * `buildHubBoundOrigins`-derived set.
65
74
  */
66
75
  export async function requireScope(
67
76
  db: Database,
68
77
  req: Request,
69
78
  requiredScope: string,
70
- expectedIssuer: string,
79
+ expectedIssuer: string | readonly string[],
71
80
  ): Promise<AdminAuthContext> {
72
81
  const token = extractBearerToken(req);
73
82
 
@@ -4,8 +4,11 @@
4
4
  * without round-tripping through the `/oauth/authorize` flow (whose
5
5
  * `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
6
6
  *
7
- * GET /api/oauth/clients/<client_id> client details
8
- * POST /api/oauth/clients/<client_id>/approve flip status to approved
7
+ * GET /api/oauth/clients/<client_id> client details
8
+ * POST /api/oauth/clients/<client_id>/approve flip status to approved
9
+ * DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
10
+ * the TOP-LEVEL prefix, see
11
+ * handleDeleteClient
9
12
  *
10
13
  * Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
11
14
  * /api/auth/tokens, etc.). The SPA mints one via the session cookie at
@@ -54,13 +57,22 @@ import {
54
57
  requireScope,
55
58
  } from "./admin-auth.ts";
56
59
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
57
- import { approveClient, getClient } from "./clients.ts";
60
+ import { approveClient, deleteClient, getClient } from "./clients.ts";
58
61
  import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
59
62
 
60
63
  export interface AdminClientsDeps {
61
64
  db: Database;
62
65
  /** Hub origin — passed through to JWT validation as the expected `iss`. */
63
66
  issuer: string;
67
+ /**
68
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
69
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
70
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
71
+ * credential minted under a still-valid prior origin keeps working across an
72
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
73
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
74
+ */
75
+ knownIssuers?: readonly string[];
64
76
  }
65
77
 
66
78
  export interface AdminClientView {
@@ -90,7 +102,7 @@ export async function handleGetClient(
90
102
  return jsonError(405, "method_not_allowed", "use GET");
91
103
  }
92
104
  try {
93
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
105
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
94
106
  } catch (err) {
95
107
  return adminAuthErrorResponse(err as AdminAuthError);
96
108
  }
@@ -126,7 +138,7 @@ export async function handleApproveClient(
126
138
  }
127
139
  let ctx: AdminAuthContext;
128
140
  try {
129
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
141
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
130
142
  } catch (err) {
131
143
  return adminAuthErrorResponse(err as AdminAuthError);
132
144
  }
@@ -177,6 +189,55 @@ export async function handleApproveClient(
177
189
  });
178
190
  }
179
191
 
192
+ /**
193
+ * RFC 7592 Dynamic Client Registration *deletion* (deregistration).
194
+ *
195
+ * DELETE /oauth/clients/<client_id> remove the client + its cascade
196
+ *
197
+ * Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
198
+ * because that's the path parachute-surface's remove-flow actually calls
199
+ * (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
200
+ * carrying the operator token as a Bearer. Before this route existed the
201
+ * hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
202
+ * `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
203
+ * reaper for legacy orphans is a separate follow-up).
204
+ *
205
+ * Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
206
+ * `requireScope`. Returns 204 (no content) on a successful delete, 404 when
207
+ * the client isn't registered — the same shape the surface already tolerates
208
+ * (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
209
+ *
210
+ * Audit: emits a `client deleted: ...` line in the same `key=value` shape as
211
+ * the `client approved: ...` line, so cross-machine "who removed this client"
212
+ * is greppable in hub.log.
213
+ */
214
+ export async function handleDeleteClient(
215
+ req: Request,
216
+ clientId: string,
217
+ deps: AdminClientsDeps,
218
+ ): Promise<Response> {
219
+ if (req.method !== "DELETE") {
220
+ return jsonError(405, "method_not_allowed", "use DELETE");
221
+ }
222
+ let ctx: AdminAuthContext;
223
+ try {
224
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
225
+ } catch (err) {
226
+ return adminAuthErrorResponse(err as AdminAuthError);
227
+ }
228
+ // Capture the name BEFORE deleting so the audit line can carry it.
229
+ const before = getClient(deps.db, clientId);
230
+ const removed = deleteClient(deps.db, clientId);
231
+ if (!removed) {
232
+ return jsonError(404, "not_found", `no client registered with id ${clientId}`);
233
+ }
234
+ console.log(
235
+ `client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
236
+ );
237
+ // 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
238
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
239
+ }
240
+
180
241
  interface ApproveClientResponse {
181
242
  client_id: string;
182
243
  status: "approved";
@@ -38,6 +38,15 @@ export interface AdminGrantsDeps {
38
38
  db: Database;
39
39
  /** Hub origin — passed through to JWT validation as the expected `iss`. */
40
40
  issuer: string;
41
+ /**
42
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
43
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
44
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
45
+ * credential minted under a still-valid prior origin keeps working across an
46
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
47
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
48
+ */
49
+ knownIssuers?: readonly string[];
41
50
  }
42
51
 
43
52
  export interface AdminGrantListing {
@@ -55,7 +64,7 @@ export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Pro
55
64
  }
56
65
  let ctx: AdminAuthContext;
57
66
  try {
58
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
67
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
59
68
  } catch (err) {
60
69
  return adminAuthErrorResponse(err as AdminAuthError);
61
70
  }
@@ -111,7 +120,7 @@ export async function handleRevokeGrant(
111
120
  }
112
121
  let ctx: AdminAuthContext;
113
122
  try {
114
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
123
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
115
124
  } catch (err) {
116
125
  return adminAuthErrorResponse(err as AdminAuthError);
117
126
  }
@@ -11,6 +11,7 @@
11
11
  * (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
12
12
  */
13
13
  import type { Database } from "bun:sqlite";
14
+ import { recordLoginUnlock } from "./admin-lock.ts";
14
15
  import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
15
16
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
16
17
  import {
@@ -283,6 +284,7 @@ function mintSessionAndRedirect(
283
284
  extraCookies: string[] = [],
284
285
  ): Response {
285
286
  const session = createSession(db, { userId: user.id });
287
+ recordLoginUnlock(db, session.id);
286
288
  const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
287
289
  secure: isHttpsRequest(req),
288
290
  });
@@ -49,7 +49,14 @@
49
49
  import type { Database } from "bun:sqlite";
50
50
  import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
51
51
  import { signAccessToken } from "./jwt-sign.ts";
52
- import { findSession, parseSessionCookie } from "./sessions.ts";
52
+ import { isHttpsRequest } from "./request-protocol.ts";
53
+ import {
54
+ SESSION_TTL_MS,
55
+ buildSessionCookie,
56
+ findSession,
57
+ parseSessionCookie,
58
+ touchSession,
59
+ } from "./sessions.ts";
53
60
  import { isFirstAdmin } from "./users.ts";
54
61
 
55
62
  /** Short TTL — page-snapshot threats can't carry the token forever. */
@@ -115,6 +122,21 @@ export async function handleHostAdminToken(
115
122
  // sentinel matching admin OAuth tokens.
116
123
  vaultScope: [],
117
124
  });
125
+ // Sliding session renewal (THE frequent-re-login fix). The SPA re-mints here
126
+ // roughly every ~10 min while a tab is open; each successful mint pushes the
127
+ // session's `expires_at` forward, so an active operator isn't hard-logged-out
128
+ // at the 24h mark. A closed tab stops minting and still expires; the absolute
129
+ // ceiling in `touchSession` bounds a left-open-but-idle tab. The renewed
130
+ // Set-Cookie keeps the EXACT attributes session creation uses — HttpOnly,
131
+ // Secure-when-https, SameSite=Lax, Path=/, host-only (no Domain) — so the
132
+ // cookie's Max-Age tracks the extended expiry without broadening the cookie.
133
+ // This does NOT touch the admin-lock idle window (sliding there is
134
+ // heartbeat-only, by design — see admin-lock.ts); the two windows are
135
+ // independent.
136
+ touchSession(deps.db, sid);
137
+ const sessionCookie = buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000), {
138
+ secure: isHttpsRequest(req),
139
+ });
118
140
  return new Response(
119
141
  JSON.stringify({
120
142
  token: minted.token,
@@ -128,6 +150,7 @@ export async function handleHostAdminToken(
128
150
  // No browser cache — token rotates per-fetch, and a stale 200 from a
129
151
  // back/forward navigation could hand the SPA a long-expired JWT.
130
152
  "cache-control": "no-store",
153
+ "set-cookie": sessionCookie,
131
154
  },
132
155
  },
133
156
  );
package/src/admin-lock.ts CHANGED
@@ -174,6 +174,22 @@ export function recordUnlock(
174
174
  unlockedUntil.set(sessionId, now + idleSeconds * 1000);
175
175
  }
176
176
 
177
+ /**
178
+ * Open an unlock window for a session that JUST completed a full auth
179
+ * (password + any 2FA). The PIN's threat model is the idle/grabbed tab, NOT a
180
+ * third gate the instant after the auth boundary — re-prompting for the PIN
181
+ * the moment after a successful login is pure friction with no security gain
182
+ * (the user just proved a stronger factor). No-op when the lock feature is off.
183
+ *
184
+ * Called from every session-mint point (password login, OAuth login,
185
+ * account-setup, setup-wizard) so a freshly-authenticated session always lands
186
+ * working, never on the lock screen. Idle re-entry still re-locks as before.
187
+ */
188
+ export function recordLoginUnlock(db: Database, sessionId: string, now: number = Date.now()): void {
189
+ if (!isLockConfigured(db)) return;
190
+ recordUnlock(sessionId, getIdleSeconds(db), now);
191
+ }
192
+
177
193
  /**
178
194
  * Slide the unlock window forward by `idleSeconds` IF the session is currently
179
195
  * unlocked. Called on admin activity (heartbeat + every successful mint) so an