@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
package/src/api-me.ts CHANGED
@@ -14,7 +14,12 @@
14
14
  * Response shape:
15
15
  *
16
16
  * { hasSession: false }
17
- * { hasSession: true, user: { id, displayName }, csrf: "<token>" }
17
+ * { hasSession: true, user: { id, displayName }, csrf: "<token>",
18
+ * two_factor_enabled: boolean }
19
+ *
20
+ * `two_factor_enabled` (hub#85) lets the SPA's "My account" page render the
21
+ * 2FA status without a separate read. It reflects `users.totp_secret` being
22
+ * set for the signed-in user.
18
23
  *
19
24
  * `displayName` is the user's `username` today — there's no separate
20
25
  * display-name field on the User shape. Surfaced under a different key
@@ -39,6 +44,7 @@
39
44
  import type { Database } from "bun:sqlite";
40
45
  import { ensureCsrfToken } from "./csrf.ts";
41
46
  import { findActiveSession } from "./sessions.ts";
47
+ import { isTotpEnrolled } from "./two-factor-store.ts";
42
48
  import { getUserById } from "./users.ts";
43
49
 
44
50
  export interface ApiMeDeps {
@@ -59,7 +65,9 @@ interface SignedInUser {
59
65
  * that mixes states — e.g. `{ hasSession: false, user: staleUser }`
60
66
  * fails at the type-check, not just at code-review.
61
67
  */
62
- type ApiMeResponse = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
68
+ type ApiMeResponse =
69
+ | { hasSession: false }
70
+ | { hasSession: true; user: SignedInUser; csrf: string; two_factor_enabled: boolean };
63
71
 
64
72
  export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
65
73
  if (req.method !== "GET") {
@@ -99,6 +107,7 @@ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
99
107
  displayName: user.username,
100
108
  },
101
109
  csrf: csrf.token,
110
+ two_factor_enabled: isTotpEnrolled(deps.db, user.id),
102
111
  };
103
112
  return new Response(JSON.stringify(body), { status: 200, headers });
104
113
  }
@@ -87,6 +87,17 @@ export interface ApiMintTokenDeps {
87
87
  db: Database;
88
88
  /** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
89
89
  issuer: string;
90
+ /**
91
+ * SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
92
+ * platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
93
+ * caller's bearer `iss` is validated against THIS set rather than the single
94
+ * `issuer`, so a credential minted under a still-valid prior origin keeps
95
+ * minting across an origin switch (hub#516 parity — the live "mint refused"
96
+ * after `set-origin`). Minted tokens still carry the single canonical
97
+ * `issuer` as their `iss`. Absent → falls back to `[issuer]` (the prior
98
+ * strict per-request behavior; tests/non-HTTP callers unaffected).
99
+ */
100
+ knownIssuers?: readonly string[];
90
101
  /**
91
102
  * Names of vault instances currently registered in services.json (item D /
92
103
  * hub#450). When provided, a `vault:<name>:admin` mint whose `<name>` is not
@@ -133,7 +144,11 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
133
144
  let bearerSub: string;
134
145
  let bearerScopes: string[];
135
146
  try {
136
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
147
+ const validated = await validateAccessToken(
148
+ deps.db,
149
+ bearer,
150
+ deps.knownIssuers ?? [deps.issuer],
151
+ );
137
152
  const sub = validated.payload.sub;
138
153
  if (typeof sub !== "string" || sub.length === 0) {
139
154
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
@@ -42,6 +42,9 @@
42
42
  */
43
43
 
44
44
  import type { Database } from "bun:sqlite";
45
+ import { readFileSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import { compareVersions } from "./commands/upgrade.ts";
45
48
  import { validateHostAdminToken } from "./host-admin-token-validation.ts";
46
49
  import {
47
50
  type ModuleInstallChannel,
@@ -178,6 +181,40 @@ export interface ApiModulesDeps {
178
181
  * (hub#342 — drives the admin SPA Modules page's "Open" button).
179
182
  */
180
183
  readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
184
+ /**
185
+ * Read the LIVE installed version of a module from its install dir's
186
+ * `package.json` `version` (hub#243). The services.json `version` field is a
187
+ * CACHE the module writes on its own boot — on the bun-linked dev path it
188
+ * goes stale the moment the checkout is rebuilt to a newer version without a
189
+ * restart that re-stamps services.json. The admin Modules view reads
190
+ * `installed_version` straight from that cache, so it can show a stale version
191
+ * (the live symptom: services.json said 0.5.4-rc.15 while the linked checkout
192
+ * was already 0.6.4-rc.15). When this resolves a version, it WINS over the
193
+ * services.json cache for the `installed_version` field so the operator sees
194
+ * what's actually on disk. Returns null on any failure (no install dir,
195
+ * missing/unreadable package.json, no version) → fall back to the
196
+ * services.json cache, which is the only source for a not-yet-booted module.
197
+ *
198
+ * Production reads `<installDir>/package.json`; tests inject a fake.
199
+ */
200
+ readInstalledVersion?: (installDir: string) => string | null;
201
+ }
202
+
203
+ /**
204
+ * Default `readInstalledVersion`. Reads `<installDir>/package.json`'s `version`
205
+ * — the authoritative live version of a module's code on disk, which the
206
+ * bun-linked dev path keeps current while the services.json cache can lag
207
+ * (hub#243). Returns null on any failure so the caller falls back to the
208
+ * services.json `version`.
209
+ */
210
+ export function defaultReadInstalledVersion(installDir: string): string | null {
211
+ try {
212
+ const raw = readFileSync(join(installDir, "package.json"), "utf8");
213
+ const parsed = JSON.parse(raw) as { version?: unknown };
214
+ return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : null;
215
+ } catch {
216
+ return null;
217
+ }
181
218
  }
182
219
 
183
220
  /**
@@ -239,6 +276,24 @@ interface ModuleWireShape {
239
276
  installed: boolean;
240
277
  installed_version: string | null;
241
278
  latest_version: string | null;
279
+ /**
280
+ * Whether the channel-resolved `latest_version` is a REAL upgrade — i.e.
281
+ * STRICTLY NEWER than `installed_version` under semver ordering (hub#243).
282
+ * Computed server-side via `compareVersions` so the rc/prerelease ordering is
283
+ * correct and there's ONE tested source of truth (the SPA used to derive this
284
+ * client-side with a string `!==`, which framed a *downgrade* as an upgrade:
285
+ * an rc operator on `0.6.4-rc.15` was offered an "upgrade" to the older
286
+ * `@latest` `0.6.3` purely because the two strings differed).
287
+ *
288
+ * `false` when: not installed · no `latest_version` (probe failed) · target ≤
289
+ * installed (same or older — incl. the rc→older-stable downgrade) · either
290
+ * version is unparseable (`compareVersions` returns null → fail-closed: don't
291
+ * offer a move we can't verify is forward). `true` ONLY when the target
292
+ * parses AND sorts strictly above installed — so `0.6.4-rc.15` → stable
293
+ * `0.6.4` IS an upgrade (stable > its own rc per semver §11.4.3), but
294
+ * `0.6.4-rc.15` → `0.6.3` is NOT.
295
+ */
296
+ upgrade_available: boolean;
242
297
  supervisor_status: ModuleState["status"] | null;
243
298
  pid: number | null;
244
299
  /**
@@ -451,6 +506,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
451
506
  // Lenient read so a single bad row written by a buggy module install
452
507
  // (e.g. app@0.2.0-rc.4) doesn't take down /api/modules — see hub#406.
453
508
  const manifest = readManifestLenient(deps.manifestPath);
509
+ // Live on-disk version reader (hub#243) — see `defaultReadInstalledVersion`.
510
+ const readInstalledVersion = deps.readInstalledVersion ?? defaultReadInstalledVersion;
454
511
  const installedByShort = new Map<
455
512
  string,
456
513
  {
@@ -476,6 +533,18 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
476
533
  uis?: Record<string, UiSubUnit>;
477
534
  mountPath?: string;
478
535
  } = { version: entry.version };
536
+ // Prefer the LIVE on-disk version over the services.json cache when we can
537
+ // read it (hub#243). `entry.version` is a cache the module stamps on its own
538
+ // boot; on the bun-linked dev path it lags after a rebuild-without-restart,
539
+ // so the admin view shows a stale "current" (the live symptom: cache said
540
+ // 0.5.4-rc.15 while the linked checkout was already 0.6.4-rc.15). The
541
+ // module's `<installDir>/package.json` `version` is authoritative for the
542
+ // code actually on disk. Falls back to the cache when there's no installDir
543
+ // or the package.json can't be read (e.g. a not-yet-booted seed row).
544
+ if (entry.installDir !== undefined) {
545
+ const live = readInstalledVersion(entry.installDir);
546
+ if (live !== null) value.version = live;
547
+ }
479
548
  if (entry.installDir !== undefined) value.installDir = entry.installDir;
480
549
  if (entry.uis !== undefined) value.uis = entry.uis;
481
550
  // First non-`.parachute` path is the module's user-facing mount
@@ -622,6 +691,10 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
622
691
  installed: installed !== undefined,
623
692
  installed_version: installed?.version ?? null,
624
693
  latest_version: latestByShort.get(short) ?? null,
694
+ upgrade_available: isUpgradeAvailable(
695
+ installed?.version ?? null,
696
+ latestByShort.get(short) ?? null,
697
+ ),
625
698
  supervisor_status: state?.status ?? null,
626
699
  pid: state?.pid ?? null,
627
700
  supervisor_start_error: state?.startError ?? null,
@@ -644,6 +717,9 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
644
717
 
645
718
  // Every supervised module's run-state — curated AND non-curated (hub#539).
646
719
  // Built from the same supervisor.list() snapshot already in `stateByShort`.
720
+ // `installed_version` here inherits the live-on-disk override already applied
721
+ // to `installedByShort[].version` in the manifest loop above (hub#243), so the
722
+ // `supervised` array reports the same corrected version as the `modules` rows.
647
723
  const supervised: SupervisedSnapshotWire[] = Array.from(stateByShort.values()).map((s) => ({
648
724
  short: s.short,
649
725
  installed: installedByShort.has(s.short),
@@ -683,6 +759,15 @@ export const API_MODULES_CHANNEL_REQUIRED_SCOPE = "parachute:host:admin";
683
759
  export interface ApiModulesChannelDeps {
684
760
  db: Database;
685
761
  issuer: string;
762
+ /**
763
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
764
+ * per-request `issuer`), built via `buildHubBoundOrigins` — same posture as
765
+ * {@link ApiModulesDeps.knownIssuers}. The bearer's `iss` is validated
766
+ * against THIS set rather than the single `issuer`, so the operator token
767
+ * (public `iss` after `expose`) is accepted on loopback. Absent → falls back
768
+ * to `[issuer]` (the prior strict per-request behavior).
769
+ */
770
+ knownIssuers?: readonly string[];
686
771
  }
687
772
 
688
773
  export async function handleApiModulesChannel(
@@ -705,7 +790,11 @@ export async function handleApiModulesChannel(
705
790
 
706
791
  // Bearer validation + scope check.
707
792
  try {
708
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
793
+ const validated = await validateAccessToken(
794
+ deps.db,
795
+ bearer,
796
+ deps.knownIssuers ?? [deps.issuer],
797
+ );
709
798
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
710
799
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
711
800
  }
@@ -842,6 +931,35 @@ function toUisWireShape(uis: Record<string, UiSubUnit> | undefined): UiSubUnitWi
842
931
  }));
843
932
  }
844
933
 
934
+ /**
935
+ * Whether `latest` is a REAL upgrade over `installed` — strictly newer under
936
+ * semver ordering (hub#243). The single tested source of truth for the
937
+ * "upgrade available?" decision, shared by the wire shape's `upgrade_available`
938
+ * field and (transitively) the admin SPA's Upgrade button.
939
+ *
940
+ * Returns `false` unless BOTH versions parse AND `latest` sorts strictly above
941
+ * `installed`:
942
+ * - no installed version (not installed) → false.
943
+ * - no latest version (probe failed / unknown package) → false.
944
+ * - `compareVersions` can't parse either side → false (FAIL-CLOSED: never
945
+ * offer a move we can't prove is forward).
946
+ * - target ≤ installed → false. This is the load-bearing downgrade guard:
947
+ * an rc operator on `0.6.4-rc.15` whose channel resolves `latest_version`
948
+ * to the OLDER `@latest` `0.6.3` is NOT offered an "upgrade" (the live bug
949
+ * — a downgrade framed as an upgrade by the old string `!==`).
950
+ *
951
+ * Reuses `commands/upgrade.ts:compareVersions`, the same comparator the CLI
952
+ * `parachute upgrade` downgrade guard uses, so the SPA offer and the CLI guard
953
+ * can't disagree about rc/prerelease ordering. Note `0.6.4-rc.15` → stable
954
+ * `0.6.4` IS strictly-newer (stable > its own rc per semver §11.4.3), so a real
955
+ * rc→its-stable promotion still surfaces as an upgrade.
956
+ */
957
+ export function isUpgradeAvailable(installed: string | null, latest: string | null): boolean {
958
+ if (installed === null || latest === null) return false;
959
+ const cmp = compareVersions(latest, installed);
960
+ return cmp !== null && cmp > 0;
961
+ }
962
+
845
963
  /**
846
964
  * Reset the in-memory `latest_version` cache. Tests call this between
847
965
  * runs to prevent state leakage across test cases; production never
@@ -73,6 +73,15 @@ export interface ApiRevokeTokenDeps {
73
73
  db: Database;
74
74
  /** Hub origin — used to validate the bearer's `iss`. */
75
75
  issuer: string;
76
+ /**
77
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
78
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
79
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
80
+ * credential minted under a still-valid prior origin keeps working across an
81
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
82
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
83
+ */
84
+ knownIssuers?: readonly string[];
76
85
  /** Test seam for time. */
77
86
  now?: () => Date;
78
87
  }
@@ -102,7 +111,11 @@ export async function handleApiRevokeToken(
102
111
  // 2. Bearer validation (signature, issuer, expiry, hub-side revocation).
103
112
  let bearerScopes: string[];
104
113
  try {
105
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
114
+ const validated = await validateAccessToken(
115
+ deps.db,
116
+ bearer,
117
+ deps.knownIssuers ?? [deps.issuer],
118
+ );
106
119
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
107
120
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
108
121
  }
@@ -47,6 +47,15 @@ export const API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE = "parachute:host:admin";
47
47
  export interface ApiSettingsHubOriginDeps {
48
48
  db: Database;
49
49
  issuer: string;
50
+ /**
51
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
52
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
53
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
54
+ * credential minted under a still-valid prior origin keeps working across an
55
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
56
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
57
+ */
58
+ knownIssuers?: readonly string[];
50
59
  /**
51
60
  * The currently-resolved issuer + its source layer. Computed by the
52
61
  * dispatcher (which has the request + `configuredIssuer` already in
@@ -186,7 +195,11 @@ export async function handleApiSettingsHubOrigin(
186
195
 
187
196
  // Bearer validation + scope check.
188
197
  try {
189
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
198
+ const validated = await validateAccessToken(
199
+ deps.db,
200
+ bearer,
201
+ deps.knownIssuers ?? [deps.issuer],
202
+ );
190
203
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
191
204
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
192
205
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `GET|PUT /api/settings/root-redirect` — operator-settable target for the
3
+ * bare-`/` 302.
4
+ *
5
+ * The hub's root (`/`) redirects to `/admin` by default. This endpoint lets an
6
+ * operator point it at a surface instead (e.g. a custom-domain hub fronting a
7
+ * team reading-room surface) without redeploying. The stored value resolves
8
+ * tier-1 in `resolveRootRedirect` (hub-settings.ts):
9
+ *
10
+ * 1. hub_settings.root_redirect (this endpoint writes here)
11
+ * 2. PARACHUTE_HUB_ROOT_REDIRECT env
12
+ * 3. `/admin` default (unchanged behavior)
13
+ *
14
+ * The endpoint surfaces both the stored value *and* the resolved value + source
15
+ * so the SPA can render "current: /surface/x (from env)" while the input shows
16
+ * the empty stored row — same separation rationale as `/api/settings/hub-origin`.
17
+ *
18
+ * OPEN-REDIRECT SAFETY is the highest-stakes part: the resolved value lands in a
19
+ * `Location:` header, so an off-origin value would be a textbook open redirect.
20
+ * PUT validation (and the read-time resolver) require a SAME-ORIGIN relative
21
+ * path via `isSafeRedirectPath` — must start with a single `/`, never `//` /
22
+ * `/\` / a scheme, no control chars / whitespace, and must not resolve back to
23
+ * `/` (redirect loop). Anything else is rejected (PUT 400 / resolver fallback to
24
+ * `/admin`).
25
+ *
26
+ * Bearer-gated on `parachute:host:admin`, mirroring `handleApiSettingsHubOrigin`
27
+ * — same Bearer parsing, scope-check posture, and error vocabulary.
28
+ */
29
+
30
+ import type { Database } from "bun:sqlite";
31
+ import {
32
+ type RootRedirectSource,
33
+ getRootRedirect,
34
+ isSafeRedirectPath,
35
+ resolveRootRedirectDetailed,
36
+ setRootRedirect,
37
+ } from "./hub-settings.ts";
38
+ import { validateAccessToken } from "./jwt-sign.ts";
39
+
40
+ /** Scope required on the bearer token to call either endpoint. */
41
+ export const API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE = "parachute:host:admin";
42
+
43
+ export interface ApiSettingsRootRedirectDeps {
44
+ db: Database;
45
+ /** Issuer the bearer token must validate against (the hub's resolved issuer). */
46
+ issuer: string;
47
+ /**
48
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
49
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
50
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
51
+ * credential minted under a still-valid prior origin keeps working across an
52
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
53
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
54
+ */
55
+ knownIssuers?: readonly string[];
56
+ /**
57
+ * Env seam for the resolver's env layer. Defaults to `process.env`. Threaded
58
+ * so the dispatcher (and tests) can resolve `PARACHUTE_HUB_ROOT_REDIRECT`
59
+ * deterministically.
60
+ */
61
+ env?: NodeJS.ProcessEnv;
62
+ }
63
+
64
+ interface GetResponseBody {
65
+ /** Raw stored value from hub_settings.root_redirect, or null. */
66
+ root_redirect: string | null;
67
+ /** Resolved target applied to the bare-`/` 302 (precedence-aware, guarded). */
68
+ resolved: string;
69
+ /** Which precedence layer the resolved value came from. */
70
+ source: RootRedirectSource;
71
+ }
72
+
73
+ interface PutResponseBody {
74
+ /** Echo of the now-stored value (null if cleared). */
75
+ root_redirect: string | null;
76
+ }
77
+
78
+ /**
79
+ * Validation outcome. The "normalized" branch is what gets passed to
80
+ * setRootRedirect — string (a safe path) or null (clear the row).
81
+ */
82
+ type ValidateOutcome = { ok: true; normalized: string | null } | { ok: false; description: string };
83
+
84
+ /**
85
+ * Validate the body's `root_redirect` field. Accepts:
86
+ * - `null` (or empty string) → clear the stored value, revert to env/default.
87
+ * - A safe SAME-ORIGIN relative path per `isSafeRedirectPath`.
88
+ * Everything else → 400 with an operator-friendly description.
89
+ */
90
+ export function validateRootRedirect(value: unknown): ValidateOutcome {
91
+ if (value === null) return { ok: true, normalized: null };
92
+ if (typeof value !== "string") {
93
+ return {
94
+ ok: false,
95
+ description: `root_redirect must be a string or null (got ${typeof value})`,
96
+ };
97
+ }
98
+ // Empty string is the canonical "clear" shape — store as null (mirrors
99
+ // setHubOrigin's footgun guard; an empty Location would be meaningless).
100
+ if (value.length === 0) return { ok: true, normalized: null };
101
+ if (!isSafeRedirectPath(value)) {
102
+ return {
103
+ ok: false,
104
+ description:
105
+ "root_redirect must be a same-origin relative path (start with a single `/`, no `//`/`/\\`/scheme, no whitespace, and not `/` itself)",
106
+ };
107
+ }
108
+ return { ok: true, normalized: value };
109
+ }
110
+
111
+ export async function handleApiSettingsRootRedirect(
112
+ req: Request,
113
+ deps: ApiSettingsRootRedirectDeps,
114
+ ): Promise<Response> {
115
+ if (req.method !== "GET" && req.method !== "PUT") {
116
+ return jsonError(405, "method_not_allowed", "use GET or PUT");
117
+ }
118
+
119
+ // Bearer presence + parsing — identical shape to api-settings-hub-origin
120
+ // for consistency across hub-internal admin endpoints.
121
+ const auth = req.headers.get("authorization");
122
+ if (!auth || !auth.startsWith("Bearer ")) {
123
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
124
+ }
125
+ const bearer = auth.slice("Bearer ".length).trim();
126
+ if (!bearer) {
127
+ return jsonError(401, "unauthenticated", "empty bearer token");
128
+ }
129
+
130
+ // Bearer validation + scope check.
131
+ try {
132
+ const validated = await validateAccessToken(
133
+ deps.db,
134
+ bearer,
135
+ deps.knownIssuers ?? [deps.issuer],
136
+ );
137
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
138
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
139
+ }
140
+ const scopes =
141
+ typeof validated.payload.scope === "string"
142
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
143
+ : [];
144
+ if (!scopes.includes(API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE)) {
145
+ return jsonError(
146
+ 403,
147
+ "insufficient_scope",
148
+ `bearer token lacks ${API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE}`,
149
+ );
150
+ }
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
154
+ }
155
+
156
+ if (req.method === "GET") {
157
+ const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
158
+ const body: GetResponseBody = {
159
+ root_redirect: getRootRedirect(deps.db),
160
+ resolved: resolved.value,
161
+ source: resolved.source,
162
+ };
163
+ return new Response(JSON.stringify(body), {
164
+ status: 200,
165
+ headers: { "content-type": "application/json" },
166
+ });
167
+ }
168
+
169
+ // PUT — parse + validate body.
170
+ let parsed: unknown;
171
+ try {
172
+ parsed = await req.json();
173
+ } catch {
174
+ return jsonError(400, "invalid_request", "request body must be JSON");
175
+ }
176
+ if (typeof parsed !== "object" || parsed === null) {
177
+ return jsonError(400, "invalid_request", "request body must be a JSON object");
178
+ }
179
+ if (!("root_redirect" in parsed)) {
180
+ return jsonError(400, "invalid_request", "request body must include a `root_redirect` field");
181
+ }
182
+ const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
183
+ if (!result.ok) {
184
+ return jsonError(400, "invalid_root_redirect", result.description);
185
+ }
186
+
187
+ setRootRedirect(deps.db, result.normalized);
188
+
189
+ const body: PutResponseBody = { root_redirect: result.normalized };
190
+ return new Response(JSON.stringify(body), {
191
+ status: 200,
192
+ headers: { "content-type": "application/json" },
193
+ });
194
+ }
195
+
196
+ function jsonError(status: number, code: string, description: string): Response {
197
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
198
+ status,
199
+ headers: { "content-type": "application/json" },
200
+ });
201
+ }
package/src/api-tokens.ts CHANGED
@@ -67,6 +67,15 @@ export interface ApiTokensDeps {
67
67
  db: Database;
68
68
  /** Hub origin — used to validate the bearer's `iss`. */
69
69
  issuer: string;
70
+ /**
71
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
72
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
73
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
74
+ * credential minted under a still-valid prior origin keeps working across an
75
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
76
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
77
+ */
78
+ knownIssuers?: readonly string[];
70
79
  }
71
80
 
72
81
  interface TokenWireShape {
@@ -115,7 +124,11 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
115
124
  // 2. Bearer validation.
116
125
  let bearerScopes: string[];
117
126
  try {
118
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
127
+ const validated = await validateAccessToken(
128
+ deps.db,
129
+ bearer,
130
+ deps.knownIssuers ?? [deps.issuer],
131
+ );
119
132
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
120
133
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
121
134
  }
package/src/api-users.ts CHANGED
@@ -69,6 +69,15 @@ export interface ApiUsersDeps {
69
69
  db: Database;
70
70
  /** Hub origin — JWT `iss` validation. */
71
71
  issuer: string;
72
+ /**
73
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
74
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
75
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
76
+ * credential minted under a still-valid prior origin keeps working across an
77
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
78
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
79
+ */
80
+ knownIssuers?: readonly string[];
72
81
  /** Override services.json path. Defaults to `~/.parachute/services.json`. */
73
82
  manifestPath?: string;
74
83
  }
@@ -118,7 +127,7 @@ export async function handleListUsers(req: Request, deps: ApiUsersDeps): Promise
118
127
  return jsonError(405, "method_not_allowed", "use GET");
119
128
  }
120
129
  try {
121
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
130
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
122
131
  } catch (err) {
123
132
  return adminAuthErrorResponse(err as AdminAuthError);
124
133
  }
@@ -262,7 +271,7 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
262
271
  return jsonError(405, "method_not_allowed", "use POST");
263
272
  }
264
273
  try {
265
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
274
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
266
275
  } catch (err) {
267
276
  return adminAuthErrorResponse(err as AdminAuthError);
268
277
  }
@@ -358,7 +367,7 @@ export async function handleDeleteUser(
358
367
  return jsonError(405, "method_not_allowed", "use DELETE");
359
368
  }
360
369
  try {
361
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
370
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
362
371
  } catch (err) {
363
372
  return adminAuthErrorResponse(err as AdminAuthError);
364
373
  }
@@ -441,7 +450,7 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
441
450
  return jsonError(405, "method_not_allowed", "use GET");
442
451
  }
443
452
  try {
444
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
453
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
445
454
  } catch (err) {
446
455
  return adminAuthErrorResponse(err as AdminAuthError);
447
456
  }
@@ -571,7 +580,7 @@ export async function handleUpdateUserVaults(
571
580
  return jsonError(405, "method_not_allowed", "use PATCH");
572
581
  }
573
582
  try {
574
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
583
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
575
584
  } catch (err) {
576
585
  return adminAuthErrorResponse(err as AdminAuthError);
577
586
  }
@@ -760,7 +769,7 @@ export async function handleResetUserPassword(
760
769
  return jsonError(405, "method_not_allowed", "use POST");
761
770
  }
762
771
  try {
763
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
772
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
764
773
  } catch (err) {
765
774
  return adminAuthErrorResponse(err as AdminAuthError);
766
775
  }
@@ -39,6 +39,15 @@ export interface ApiVaultCapsDeps {
39
39
  db: Database;
40
40
  /** Hub origin — JWT `iss` validation. */
41
41
  issuer: string;
42
+ /**
43
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
44
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
45
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
46
+ * credential minted under a still-valid prior origin keeps working across an
47
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
48
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
49
+ */
50
+ knownIssuers?: readonly string[];
42
51
  /** Override services.json path. Defaults to `~/.parachute/services.json`. */
43
52
  manifestPath?: string;
44
53
  }
@@ -71,7 +80,7 @@ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps):
71
80
  return jsonError(405, "method_not_allowed", "use GET");
72
81
  }
73
82
  try {
74
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
83
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
75
84
  } catch (err) {
76
85
  return adminAuthErrorResponse(err as AdminAuthError);
77
86
  }
@@ -174,7 +183,7 @@ export async function handleSetVaultCap(
174
183
  return jsonError(405, "method_not_allowed", "use PUT");
175
184
  }
176
185
  try {
177
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
186
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
178
187
  } catch (err) {
179
188
  return adminAuthErrorResponse(err as AdminAuthError);
180
189
  }