@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.
- package/package.json +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +276 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +25 -5
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- 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 =
|
|
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
|
}
|
package/src/api-mint-token.ts
CHANGED
|
@@ -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(
|
|
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");
|
package/src/api-modules.ts
CHANGED
|
@@ -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(
|
|
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
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/api-vault-caps.ts
CHANGED
|
@@ -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
|
}
|