@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21

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 (75) hide show
  1. package/package.json +4 -11
  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-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  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-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/hub-server.ts CHANGED
@@ -69,9 +69,11 @@
69
69
  * # "CSRF-belted" = strict same-origin Origin check on cookie-authed
70
70
  * # mutations (hub#632, boundary C1) — origin-check.ts
71
71
  * # `assertSameOriginForCookieMutation` carries the canonical enumeration.
72
- * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
72
+ * /api/me (GET) → who-am-I (session+CSRF+two_factor_enabled or hasSession:false)
73
73
  * /api/admin-lock (GET) → screen-lock status (cookie-gated; first-admin)
74
74
  * /api/admin-lock/{set,change,remove,unlock,lock,heartbeat} (POST) → manage the optional admin idle PIN lock (cookie-gated; CSRF)
75
+ * /api/account/2fa/{start,confirm,disable} (POST) → self-service 2FA for the SPA (cookie-gated; CSRF; self-only) — hub#85
76
+ * /api/account/password (POST) → self-service password change for the SPA (cookie-gated; CSRF; self-only) — hub#85
75
77
  * /api/hub (GET) → hub version + uptime + install-source (host:admin)
76
78
  * /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
77
79
  * /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
@@ -85,6 +87,7 @@
85
87
  * /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
86
88
  * /api/modules/operations/:id (GET) → poll async op status
87
89
  * /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
90
+ * /api/settings/root-redirect (GET + PUT) → bare-`/` redirect target (host:admin)
88
91
  * /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
89
92
  * /api/auth/revoke-token (POST) → revoke registry-row token by jti
90
93
  * /api/auth/tokens (GET) → paginated registry list
@@ -169,7 +172,7 @@ import {
169
172
  handleOAuthGrantCallback,
170
173
  } from "./admin-agent-grants.ts";
171
174
  import { handleAgentToken } from "./admin-agent-token.ts";
172
- import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
175
+ import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
173
176
  import {
174
177
  type ConnectionsDeps,
175
178
  handleConnections,
@@ -186,6 +189,7 @@ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
186
189
  import { handleModuleToken } from "./admin-module-token.ts";
187
190
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
188
191
  import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
192
+ import { handleApiAccount } from "./api-account-2fa.ts";
189
193
  import {
190
194
  handleAccountChangePasswordGet,
191
195
  handleAccountChangePasswordPost,
@@ -214,6 +218,7 @@ import { handleApiReady } from "./api-ready.ts";
214
218
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
215
219
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
216
220
  import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
221
+ import { handleApiSettingsRootRedirect } from "./api-settings-root-redirect.ts";
217
222
  import { handleApiTokens } from "./api-tokens.ts";
218
223
  import {
219
224
  handleCreateUser,
@@ -243,7 +248,7 @@ import {
243
248
  startDbPathLivenessTimer,
244
249
  } from "./hub-db-liveness.ts";
245
250
  import { hubDbPath, openHubDb } from "./hub-db.ts";
246
- import { getHubOrigin } from "./hub-settings.ts";
251
+ import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
247
252
  import { type RenderHubOpts, renderHub } from "./hub.ts";
248
253
  import { pemToJwk } from "./jwks.ts";
249
254
  import {
@@ -2476,23 +2481,32 @@ export function hubFetch(
2476
2481
  );
2477
2482
  }
2478
2483
 
2479
- // Bare `/` → `/admin` (admin-shell IA, R1). The home page and the admin
2480
- // SPA used to be two disconnected surfaces; `/` now funnels straight into
2481
- // the single coherent admin shell, whose Home/Overview carries the
2482
- // discovery content (hub-native sections, modules, user surfaces) that
2483
- // used to live here.
2484
+ // Bare `/` → configurable target (default `/admin`, the admin-shell IA).
2485
+ // The home page and the admin SPA used to be two disconnected surfaces;
2486
+ // `/` funnels straight into the single coherent admin shell, whose
2487
+ // Home/Overview carries the discovery content (hub-native sections,
2488
+ // modules, user surfaces) that used to live here.
2489
+ //
2490
+ // The target is operator-configurable (resolveRootRedirect): a hub_settings
2491
+ // `root_redirect` row → `PARACHUTE_HUB_ROOT_REDIRECT` env → `/admin`
2492
+ // default. Lets an operator point their hub's root at a surface (e.g. a
2493
+ // team reading-room) instead of the admin shell, without redeploying. The
2494
+ // resolver re-validates every layer through the same-origin guard
2495
+ // (`isSafeRedirectPath`) so a stored/env value can NEVER produce an open
2496
+ // redirect — an unsafe value is ignored and falls back to `/admin`.
2484
2497
  //
2485
2498
  // Ordering matters: this sits AFTER the fresh-hub wizard funnel above
2486
- // (so a brand-new operator still lands on `/admin/setup`, not a 404 inside
2487
- // the shell) and AFTER the pre-admin lockout (so an admin-less hub still
2488
- // 503s API callers correctly). 302 (not 301) — `/` is reclaimed for
2489
- // future use, but a permanent redirect would get cached and we may want
2490
- // `/` back later.
2499
+ // (so a brand-new operator still lands on `/admin/setup`, not a surface
2500
+ // that can't work yet) and AFTER the pre-admin lockout (so an admin-less
2501
+ // hub still 503s API callers correctly). 302 (not 301) — the target is
2502
+ // operator-mutable, so a permanent/cached redirect would strand visitors
2503
+ // on a stale destination after the operator flips it.
2491
2504
  //
2492
- // The signed-out path is preserved: a signed-out visitor lands on
2493
- // `/admin`, where the SPA's AuthIndicator shows a "Sign in" link that
2494
- // round-trips through `/login?next=/admin/...` and back. We don't pin the
2495
- // redirect on session state — the shell handles both auth states itself.
2505
+ // The signed-out path is preserved when the target is `/admin`: a
2506
+ // signed-out visitor lands on `/admin`, where the SPA's AuthIndicator
2507
+ // shows a "Sign in" link that round-trips through `/login?next=/admin/...`
2508
+ // and back. We don't pin the redirect on session state — the shell
2509
+ // handles both auth states itself.
2496
2510
  //
2497
2511
  // `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
2498
2512
  // page (used by the static `parachute expose --set-path=/` disk file and
@@ -2500,7 +2514,7 @@ export function hubFetch(
2500
2514
  if (pathname === "/") {
2501
2515
  return new Response(null, {
2502
2516
  status: 302,
2503
- headers: { location: "/admin" },
2517
+ headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
2504
2518
  });
2505
2519
  }
2506
2520
 
@@ -2764,6 +2778,34 @@ export function hubFetch(
2764
2778
  return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
2765
2779
  }
2766
2780
 
2781
+ // RFC 7592 client deregistration: DELETE /oauth/clients/<id> (hub#640).
2782
+ // Mounted at this TOP-LEVEL `/oauth/clients/` prefix — NOT under
2783
+ // `/api/oauth/clients/` — because that's the path parachute-surface's
2784
+ // remove-flow actually calls (`packages/surface-host/src/dcr.ts` fires a
2785
+ // best-effort DELETE on every Notes/Claude reconnect, carrying the
2786
+ // operator token as a Bearer). Without it the hub 404'd every such
2787
+ // DELETE and orphaned a `clients` row per reconnect. Operator-bearer-
2788
+ // gated (parachute:host:admin) inside handleDeleteClient; 204 on delete,
2789
+ // 404 if absent. CORS-wrapped + OPTIONS-preflighted like its OAuth
2790
+ // siblings (the top-of-dispatch isCorsAllowedRoute("/oauth/") preempts
2791
+ // the preflight). The GET/approve sub-paths stay on `/api/oauth/clients/`
2792
+ // (the SPA-facing admin surface) below.
2793
+ if (pathname.startsWith("/oauth/clients/")) {
2794
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
2795
+ const clientId = decodeURIComponent(pathname.slice("/oauth/clients/".length));
2796
+ if (!clientId || clientId.includes("/")) {
2797
+ return applyCorsHeaders(req, new Response("not found", { status: 404 }));
2798
+ }
2799
+ return applyCorsHeaders(
2800
+ req,
2801
+ await handleDeleteClient(req, clientId, {
2802
+ db: getDb(),
2803
+ issuer: oauthDeps(req).issuer,
2804
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2805
+ }),
2806
+ );
2807
+ }
2808
+
2767
2809
  // Agent-connector OAuth-client callback (Phase 4b-2). The operator's
2768
2810
  // browser is redirected here by a REMOTE issuer after consenting to a
2769
2811
  // `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
@@ -2801,6 +2843,7 @@ export function hubFetch(
2801
2843
  return handleCreateVault(req, {
2802
2844
  db: getDb(),
2803
2845
  issuer: oauthDeps(req).issuer,
2846
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2804
2847
  });
2805
2848
  }
2806
2849
 
@@ -2827,6 +2870,7 @@ export function hubFetch(
2827
2870
  return handleDeleteVault(req, name, {
2828
2871
  db: getDb(),
2829
2872
  issuer: oauthDeps(req).issuer,
2873
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2830
2874
  manifestPath,
2831
2875
  connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2832
2876
  agentOrigin,
@@ -2983,6 +3027,10 @@ export function hubFetch(
2983
3027
  const agentGrantsDeps: AgentGrantsDeps = {
2984
3028
  db: getDb(),
2985
3029
  hubOrigin: oauthDeps(req).issuer,
3030
+ // hub#516 parity: validate the module's host-admin bearer `iss`
3031
+ // against the hub's known-origin set (PUT /admin/grants is the only
3032
+ // bearer-gated route here; the POST /approve|/revoke are cookie-authed).
3033
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2986
3034
  storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
2987
3035
  flowsStorePath:
2988
3036
  deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
@@ -3044,6 +3092,24 @@ export function hubFetch(
3044
3092
  return handleAdminLock(req, subpath, { db: getDb() });
3045
3093
  }
3046
3094
 
3095
+ // JSON self-service account surfaces for the admin SPA "My account" page
3096
+ // (hub#85): password change + 2FA enroll/confirm/disable. Self-only
3097
+ // (acts on `session.userId`, never a client-supplied id) — ANY signed-in
3098
+ // user, not just the first admin. Same cookie + CSRF + same-origin
3099
+ // posture as /api/admin-lock above (NOT the host-admin Bearer posture —
3100
+ // a user managing their own credentials needs no admin scope). The
3101
+ // server-rendered /account/2fa + /account/change-password pages stay for
3102
+ // the no-JS / friend-facing path; these are the JSON twins.
3103
+ if (pathname.startsWith("/api/account/")) {
3104
+ if (!getDb) return dbNotConfigured();
3105
+ {
3106
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
3107
+ if (rejected) return rejected;
3108
+ }
3109
+ const subpath = pathname.slice("/api/account".length);
3110
+ return handleApiAccount(req, subpath, { db: getDb() });
3111
+ }
3112
+
3047
3113
  // SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
3048
3114
  // endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
3049
3115
  // so it gets its own route. Checked BEFORE the `/api/hub` exact match
@@ -3057,6 +3123,7 @@ export function hubFetch(
3057
3123
  return handleHubUpgrade(req, {
3058
3124
  db: getDb(),
3059
3125
  issuer: oauthDeps(req).issuer,
3126
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3060
3127
  configDir: CONFIG_DIR,
3061
3128
  });
3062
3129
  }
@@ -3065,6 +3132,7 @@ export function hubFetch(
3065
3132
  return handleHubUpgradeStatus(req, {
3066
3133
  db: getDb(),
3067
3134
  issuer: oauthDeps(req).issuer,
3135
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3068
3136
  configDir: CONFIG_DIR,
3069
3137
  });
3070
3138
  }
@@ -3077,6 +3145,7 @@ export function hubFetch(
3077
3145
  return handleApiHub(req, {
3078
3146
  db: getDb(),
3079
3147
  issuer: oauthDeps(req).issuer,
3148
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3080
3149
  });
3081
3150
  }
3082
3151
 
@@ -3112,6 +3181,7 @@ export function hubFetch(
3112
3181
  return handleApiModulesChannel(req, {
3113
3182
  db: getDb(),
3114
3183
  issuer: oauthDeps(req).issuer,
3184
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3115
3185
  });
3116
3186
  }
3117
3187
 
@@ -3125,11 +3195,25 @@ export function hubFetch(
3125
3195
  return handleApiSettingsHubOrigin(req, {
3126
3196
  db,
3127
3197
  issuer: oauthDeps(req).issuer,
3198
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3128
3199
  resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
3129
3200
  resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
3130
3201
  });
3131
3202
  }
3132
3203
 
3204
+ // Bare-`/` redirect target (configurable; default `/admin`). Admin SPA /
3205
+ // CLI reads + writes the operator-set landing page. Same Bearer/scope
3206
+ // posture as hub-origin; the open-redirect guard lives in the handler +
3207
+ // resolver.
3208
+ if (pathname === "/api/settings/root-redirect") {
3209
+ if (!getDb) return dbNotConfigured();
3210
+ return handleApiSettingsRootRedirect(req, {
3211
+ db: getDb(),
3212
+ issuer: oauthDeps(req).issuer,
3213
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3214
+ });
3215
+ }
3216
+
3133
3217
  // Module operation poll surface — pre-empts the /api/modules/:short/*
3134
3218
  // routes below so `/api/modules/operations/<uuid>` doesn't accidentally
3135
3219
  // match a parseModulesPath("/operations") and fall through.
@@ -3229,6 +3313,7 @@ export function hubFetch(
3229
3313
  return handleApiMintToken(req, {
3230
3314
  db: getDb(),
3231
3315
  issuer: oauthDeps(req).issuer,
3316
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3232
3317
  knownVaultNames: mintKnownVaultNames,
3233
3318
  });
3234
3319
  }
@@ -3238,6 +3323,7 @@ export function hubFetch(
3238
3323
  return handleApiRevokeToken(req, {
3239
3324
  db: getDb(),
3240
3325
  issuer: oauthDeps(req).issuer,
3326
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3241
3327
  });
3242
3328
  }
3243
3329
 
@@ -3246,6 +3332,7 @@ export function hubFetch(
3246
3332
  return handleApiTokens(req, {
3247
3333
  db: getDb(),
3248
3334
  issuer: oauthDeps(req).issuer,
3335
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3249
3336
  });
3250
3337
  }
3251
3338
 
@@ -3254,6 +3341,7 @@ export function hubFetch(
3254
3341
  return handleListGrants(req, {
3255
3342
  db: getDb(),
3256
3343
  issuer: oauthDeps(req).issuer,
3344
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3257
3345
  });
3258
3346
  }
3259
3347
 
@@ -3266,6 +3354,7 @@ export function hubFetch(
3266
3354
  return handleRevokeGrant(req, clientId, {
3267
3355
  db: getDb(),
3268
3356
  issuer: oauthDeps(req).issuer,
3357
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3269
3358
  });
3270
3359
  }
3271
3360
 
@@ -3288,6 +3377,7 @@ export function hubFetch(
3288
3377
  return handleApproveClient(req, clientId, {
3289
3378
  db: getDb(),
3290
3379
  issuer: oauthDeps(req).issuer,
3380
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3291
3381
  });
3292
3382
  }
3293
3383
  const clientId = decodeURIComponent(tail);
@@ -3297,6 +3387,7 @@ export function hubFetch(
3297
3387
  return handleGetClient(req, clientId, {
3298
3388
  db: getDb(),
3299
3389
  issuer: oauthDeps(req).issuer,
3390
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3300
3391
  });
3301
3392
  }
3302
3393
 
@@ -3312,6 +3403,7 @@ export function hubFetch(
3312
3403
  const usersDeps = {
3313
3404
  db: getDb(),
3314
3405
  issuer: oauthDeps(req).issuer,
3406
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3315
3407
  manifestPath,
3316
3408
  };
3317
3409
  if (req.method === "GET") return handleListUsers(req, usersDeps);
@@ -3323,6 +3415,7 @@ export function hubFetch(
3323
3415
  return handleListVaults(req, {
3324
3416
  db: getDb(),
3325
3417
  issuer: oauthDeps(req).issuer,
3418
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3326
3419
  manifestPath,
3327
3420
  });
3328
3421
  }
@@ -3342,6 +3435,7 @@ export function hubFetch(
3342
3435
  return handleResetUserPassword(req, id, {
3343
3436
  db: getDb(),
3344
3437
  issuer: oauthDeps(req).issuer,
3438
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3345
3439
  manifestPath,
3346
3440
  });
3347
3441
  }
@@ -3360,6 +3454,7 @@ export function hubFetch(
3360
3454
  return handleUpdateUserVaults(req, id, {
3361
3455
  db: getDb(),
3362
3456
  issuer: oauthDeps(req).issuer,
3457
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3363
3458
  manifestPath,
3364
3459
  });
3365
3460
  }
@@ -3373,6 +3468,7 @@ export function hubFetch(
3373
3468
  return handleDeleteUser(req, id, {
3374
3469
  db: getDb(),
3375
3470
  issuer: oauthDeps(req).issuer,
3471
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3376
3472
  manifestPath,
3377
3473
  });
3378
3474
  }
@@ -3382,7 +3478,12 @@ export function hubFetch(
3382
3478
  // lists (status-annotated), DELETE /:id revokes by sha256 hash.
3383
3479
  if (pathname === "/api/invites") {
3384
3480
  if (!getDb) return dbNotConfigured();
3385
- const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
3481
+ const invitesDeps = {
3482
+ db: getDb(),
3483
+ issuer: oauthDeps(req).issuer,
3484
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3485
+ manifestPath,
3486
+ };
3386
3487
  if (req.method === "GET") return handleListInvites(req, invitesDeps);
3387
3488
  if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
3388
3489
  return new Response("method not allowed", { status: 405 });
@@ -3396,6 +3497,7 @@ export function hubFetch(
3396
3497
  return handleRevokeInvite(req, id, {
3397
3498
  db: getDb(),
3398
3499
  issuer: oauthDeps(req).issuer,
3500
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3399
3501
  manifestPath,
3400
3502
  });
3401
3503
  }
@@ -3408,6 +3510,7 @@ export function hubFetch(
3408
3510
  return handleListVaultCaps(req, {
3409
3511
  db: getDb(),
3410
3512
  issuer: oauthDeps(req).issuer,
3513
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3411
3514
  manifestPath,
3412
3515
  });
3413
3516
  }
@@ -3420,6 +3523,7 @@ export function hubFetch(
3420
3523
  return handleSetVaultCap(req, name, {
3421
3524
  db: getDb(),
3422
3525
  issuer: oauthDeps(req).issuer,
3526
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3423
3527
  manifestPath,
3424
3528
  });
3425
3529
  }
@@ -3824,13 +3928,57 @@ async function decorateWithChrome(
3824
3928
  if (setCookie && out !== res) {
3825
3929
  const headers = new Headers(out.headers);
3826
3930
  headers.append("set-cookie", setCookie);
3827
- return new Response(out.body, {
3828
- status: out.status,
3829
- statusText: out.statusText,
3830
- headers,
3831
- });
3931
+ return withProxySecurityHeaders(
3932
+ new Response(out.body, {
3933
+ status: out.status,
3934
+ statusText: out.statusText,
3935
+ headers,
3936
+ }),
3937
+ );
3832
3938
  }
3833
- return out;
3939
+ // hub#643: every exit runs through the security-header step, which self-
3940
+ // gates on content-type — so a non-HTML pass-through (`out === res`, e.g. a
3941
+ // 502 proxy error or a JSON/asset body) is returned unchanged, preserving
3942
+ // the pre-existing behavior for those responses.
3943
+ return withProxySecurityHeaders(out);
3944
+ }
3945
+
3946
+ /**
3947
+ * hub#643 (Tier-1): stamp non-script security headers on proxied `text/html`
3948
+ * pages — the per-vault `/vault/<name>/*` proxy and the generic
3949
+ * services-mount `/<mount>/*` proxy both flow through `decorateWithChrome`,
3950
+ * so this is the single chokepoint that covers a module / surface page.
3951
+ *
3952
+ * - `X-Content-Type-Options: nosniff` — stops content-type sniffing.
3953
+ * - `Content-Security-Policy: frame-ancestors 'self'; object-src 'none';
3954
+ * base-uri 'self'` — clickjacking (external framing) + plugin + base-tag
3955
+ * hardening.
3956
+ *
3957
+ * Deliberately NO `script-src`: a strict script-src would white-screen
3958
+ * self-built GitHub-hosted surfaces (the primary surface story) and
3959
+ * inline-script module pages. The opt-in strict script-src CSP is Tier-2,
3960
+ * explicitly deferred (hub#643 stays open).
3961
+ *
3962
+ * Header-only: we never buffer the body. Only `text/html` responses are
3963
+ * decorated, so JSON / `.js` / CSS / image assets proxied through the same
3964
+ * path are left untouched. Existing headers are preserved (a fresh Headers
3965
+ * copy is mutated); we set (not append) so a re-decorated response can't
3966
+ * accumulate duplicates.
3967
+ */
3968
+ function withProxySecurityHeaders(res: Response): Response {
3969
+ const contentType = res.headers.get("content-type") ?? "";
3970
+ if (!contentType.toLowerCase().includes("text/html")) return res;
3971
+ const headers = new Headers(res.headers);
3972
+ headers.set("x-content-type-options", "nosniff");
3973
+ headers.set(
3974
+ "content-security-policy",
3975
+ "frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
3976
+ );
3977
+ return new Response(res.body, {
3978
+ status: res.status,
3979
+ statusText: res.statusText,
3980
+ headers,
3981
+ });
3834
3982
  }
3835
3983
 
3836
3984
  if (import.meta.main) {
@@ -106,7 +106,23 @@ export type HubSettingKey =
106
106
  // Idle timeout for the admin screen-lock, in seconds. Optional override of
107
107
  // the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
108
108
  // stringified integer; absent / unparseable falls back to the default.
109
- | "admin_lock_idle_seconds";
109
+ | "admin_lock_idle_seconds"
110
+ // hub: operator-settable target for the bare-`/` 302. Lets an operator
111
+ // point their hub's root at a surface (e.g. a team reading-room surface)
112
+ // instead of the default `/admin`. Stored as a SAME-ORIGIN relative path
113
+ // (must start with a single `/`, never `//` / `/\` / a scheme — see
114
+ // `isSafeRedirectPath`); validated on write (admin PUT + CLI) AND re-checked
115
+ // on read so a hand-edited sqlite row can never produce an open redirect.
116
+ //
117
+ // Precedence on each request (resolveRootRedirect): this row, then
118
+ // `PARACHUTE_HUB_ROOT_REDIRECT` env, then the `/admin` default. DB-first
119
+ // (unlike `module_install_channel`'s env-first) so an operator can flip the
120
+ // landing page from the admin SPA / CLI without a redeploy — the headline
121
+ // use case (custom-domain hub fronting a team surface). The fresh-hub
122
+ // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
+ // not-yet-set-up hub still lands on setup, not a surface that can't work
124
+ // yet.
125
+ | "root_redirect";
110
126
 
111
127
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
112
128
 
@@ -431,3 +447,149 @@ export function setNotesRedirectDisabled(db: Database, value: boolean): void {
431
447
  deleteSetting(db, "notes_redirect_disabled");
432
448
  }
433
449
  }
450
+
451
+ // --- domain helpers: configurable bare-`/` redirect target ----------------
452
+
453
+ /** Env override for the bare-`/` redirect target. Below the DB row, above the default. */
454
+ export const PARACHUTE_HUB_ROOT_REDIRECT_ENV = "PARACHUTE_HUB_ROOT_REDIRECT";
455
+
456
+ /** Fallback when neither DB row nor env is set — the admin shell (unchanged behavior). */
457
+ export const DEFAULT_ROOT_REDIRECT = "/admin";
458
+
459
+ /**
460
+ * Open-redirect guard for the configurable bare-`/` redirect target.
461
+ *
462
+ * The resolved value lands verbatim in a `Location:` header on the `/` 302,
463
+ * so an off-origin value would be a textbook open redirect. To be accepted it
464
+ * must be a SAME-ORIGIN relative path:
465
+ *
466
+ * - starts with a single `/` (a site-relative path). This alone rejects
467
+ * `https://evil.com`, `javascript:…`, and bare hostnames.
468
+ * - second char is NOT `/` (a protocol-relative `//evil.com` sends the
469
+ * browser to another origin) and NOT `\` (browsers normalize the
470
+ * backslash, so `/\evil.com` resolves like `//evil.com`).
471
+ * - contains no ASCII control chars or whitespace — a CR/LF would enable
472
+ * header injection, and tab/newline are stripped by some browsers which
473
+ * could re-expose a hidden `//` authority.
474
+ * - resolves same-origin against a placeholder base (belt-and-suspenders:
475
+ * `new URL(value, base).origin === base`) — catches any scheme/authority
476
+ * shape the prefix checks missed.
477
+ * - does NOT resolve to pathname `/` — that would re-enter this very route
478
+ * and 302-loop forever (`/`, `/?x`, `/#y` all rejected).
479
+ *
480
+ * A query string / fragment on a real path is allowed (stays same-origin).
481
+ * Returns false for non-strings, empty, and every off-origin shape.
482
+ */
483
+ export function isSafeRedirectPath(value: unknown): value is string {
484
+ if (typeof value !== "string" || value.length === 0) return false;
485
+ if (value[0] !== "/") return false;
486
+ if (value[1] === "/" || value[1] === "\\") return false;
487
+ // Reject whitespace (\t \n \r space + Unicode separators U+2028/U+2029) and
488
+ // ASCII control chars. A CR/LF would enable header injection; stripped
489
+ // whitespace could re-expose a hidden `//` authority. `\s` covers the
490
+ // whitespace family (incl. Unicode); the charCode scan covers the remaining
491
+ // non-whitespace control chars (0x00-0x1f, 0x7f) without a control-char
492
+ // regex literal.
493
+ if (/\s/u.test(value)) return false;
494
+ for (let i = 0; i < value.length; i++) {
495
+ const c = value.charCodeAt(i);
496
+ if (c < 0x20 || c === 0x7f) return false;
497
+ }
498
+ try {
499
+ const base = "http://parachute.invalid";
500
+ const resolved = new URL(value, base);
501
+ if (resolved.origin !== base) return false;
502
+ // pathname "/" would match the bare-`/` route again -> infinite redirect.
503
+ if (resolved.pathname === "/") return false;
504
+ } catch {
505
+ return false;
506
+ }
507
+ return true;
508
+ }
509
+
510
+ /**
511
+ * Read the operator-set bare-`/` redirect target from hub_settings. Returns
512
+ * the raw stored value (or `null` when absent) WITHOUT re-validating — callers
513
+ * that need a safe value go through `resolveRootRedirect`, which re-checks the
514
+ * guard. The raw read is what the admin GET surfaces so the operator sees
515
+ * exactly what's stored (even if a hand-edit made it unsafe → ignored on use).
516
+ */
517
+ export function getRootRedirect(db: Database): string | null {
518
+ return getSetting(db, "root_redirect") ?? null;
519
+ }
520
+
521
+ /**
522
+ * Write or clear the bare-`/` redirect target. Passing `null`/empty deletes
523
+ * the row, reverting to env / default precedence (mirrors `setHubOrigin`).
524
+ * The caller MUST have validated via `isSafeRedirectPath` — this trusts the
525
+ * input (typed-callsite contract); `resolveRootRedirect` re-guards on read as
526
+ * defense-in-depth regardless.
527
+ */
528
+ export function setRootRedirect(db: Database, value: string | null): void {
529
+ if (value === null || value === "") {
530
+ deleteSetting(db, "root_redirect");
531
+ return;
532
+ }
533
+ setSetting(db, "root_redirect", value);
534
+ }
535
+
536
+ /** Which precedence layer the resolved redirect came from. */
537
+ export type RootRedirectSource = "db" | "env" | "default";
538
+
539
+ export interface ResolvedRootRedirect {
540
+ /** The safe same-origin path the `/` 302 should target. */
541
+ value: string;
542
+ /** Which layer it came from (for admin-UI attribution). */
543
+ source: RootRedirectSource;
544
+ }
545
+
546
+ /**
547
+ * Resolve the bare-`/` redirect target with source attribution.
548
+ *
549
+ * Precedence: hub_settings.root_redirect → `PARACHUTE_HUB_ROOT_REDIRECT` env
550
+ * → `/admin` default. Every layer is re-validated through `isSafeRedirectPath`;
551
+ * an unsafe value at any layer is warned + skipped so the chain can never
552
+ * produce an open redirect (worst case falls all the way to `/admin`).
553
+ *
554
+ * `db` may be `null` (hub-server running without state) — the DB layer is then
555
+ * skipped and resolution starts from env. The `env` / `warn` knobs are test
556
+ * seams (production uses `process.env` + `console.warn`).
557
+ */
558
+ export function resolveRootRedirectDetailed(
559
+ db: Database | null,
560
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
561
+ ): ResolvedRootRedirect {
562
+ const env = opts.env ?? process.env;
563
+ const warn = opts.warn ?? ((msg: string) => console.warn(msg));
564
+
565
+ // 1. DB row (operator-set via the admin PUT / `parachute hub set-root-redirect`).
566
+ if (db) {
567
+ const fromDb = getSetting(db, "root_redirect");
568
+ if (fromDb !== undefined) {
569
+ if (isSafeRedirectPath(fromDb)) return { value: fromDb, source: "db" };
570
+ warn(
571
+ `[hub-settings] root_redirect="${fromDb}" in hub_settings is not a safe same-origin path — ignoring (falling through to env/default).`,
572
+ );
573
+ }
574
+ }
575
+
576
+ // 2. Env override.
577
+ const fromEnv = env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
578
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
579
+ if (isSafeRedirectPath(fromEnv)) return { value: fromEnv, source: "env" };
580
+ warn(
581
+ `[hub-settings] ${PARACHUTE_HUB_ROOT_REDIRECT_ENV}="${fromEnv}" is not a safe same-origin path — falling back to "${DEFAULT_ROOT_REDIRECT}".`,
582
+ );
583
+ }
584
+
585
+ // 3. Default — unchanged behavior.
586
+ return { value: DEFAULT_ROOT_REDIRECT, source: "default" };
587
+ }
588
+
589
+ /** Convenience: just the resolved path (see `resolveRootRedirectDetailed`). */
590
+ export function resolveRootRedirect(
591
+ db: Database | null,
592
+ opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
593
+ ): string {
594
+ return resolveRootRedirectDetailed(db, opts).value;
595
+ }
package/src/jwt-sign.ts CHANGED
@@ -483,11 +483,26 @@ export interface ValidatedAccessToken {
483
483
  * this hub advertises — the same check vault performs against its own
484
484
  * `PARACHUTE_HUB_ORIGIN`. Defense in depth: tokens forged or replayed from
485
485
  * a different issuer get rejected at validation as well as issuance.
486
+ *
487
+ * `expectedIssuer` accepts a single string OR a SET of allowed issuers
488
+ * (`readonly string[]`), handed straight to jose's `issuer` option (which
489
+ * accepts `string | string[]`): the `iss` claim must equal the string, or be
490
+ * a member of the set. The set form is for the hub's own self-issued
491
+ * credentials, whose `iss` may be ANY origin the hub legitimately answers on
492
+ * (loopback ∪ expose-state ∪ platform ∪ per-request issuer — see
493
+ * `buildHubBoundOrigins`), so an origin switch doesn't reject a credential
494
+ * minted under a still-valid prior origin. SECURITY: this is ONLY an additive
495
+ * membership relaxation on `iss`. jose verifies the JWS signature against the
496
+ * hub's own public key FIRST and UNCONDITIONALLY — only tokens this hub
497
+ * minted can verify — before the `iss` claim is ever compared to the set. The
498
+ * set must come only from `buildHubBoundOrigins` (the hub's own origins),
499
+ * never a raw request Host. An empty/omitted value skips the `iss` check
500
+ * (signature-only); a single string is byte-identical to the prior behavior.
486
501
  */
487
502
  export async function validateAccessToken(
488
503
  db: Database,
489
504
  token: string,
490
- expectedIssuer?: string,
505
+ expectedIssuer?: string | readonly string[],
491
506
  ): Promise<ValidatedAccessToken> {
492
507
  const header = decodeProtectedHeader(token);
493
508
  const kid = header.kid;
@@ -495,11 +510,15 @@ export async function validateAccessToken(
495
510
  const match = getAllPublicKeys(db).find((k) => k.kid === kid);
496
511
  if (!match) throw new Error(`validateAccessToken: unknown or expired kid ${kid}`);
497
512
  const pub = await importSPKI(match.publicKeyPem, SIGNING_ALGORITHM);
498
- const { payload } = await jwtVerify(
499
- token,
500
- pub,
501
- expectedIssuer ? { issuer: expectedIssuer } : undefined,
502
- );
513
+ // `undefined` no `iss` pin (signature-only, the internal-caller default).
514
+ // A string or a non-empty set is handed straight to jose, which checks
515
+ // membership AFTER the signature verify above. An empty array is passed
516
+ // through too and so fails closed (no `iss` can match) — same posture as
517
+ // `validateHostAdminToken`; callers offering an empty origin set get a
518
+ // rejection, not a silent widening.
519
+ const issuerOption =
520
+ expectedIssuer === undefined ? undefined : { issuer: expectedIssuer as string | string[] };
521
+ const { payload } = await jwtVerify(token, pub, issuerOption);
503
522
  // RFC 7009 revocation enforcement (#73). OAuth-issued tokens carry a
504
523
  // tokens row keyed by jti; if that row is marked revoked, the JWT is
505
524
  // dead even though its signature + expiry are still valid. Tokens that