@openparachute/hub 0.5.10 → 0.5.12-rc.2
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__/api-modules-ops.test.ts +283 -1
- package/src/__tests__/api-settings-hub-origin.test.ts +452 -0
- package/src/__tests__/bootstrap-token.test.ts +148 -0
- package/src/__tests__/hub-origin-resolution.test.ts +154 -0
- package/src/__tests__/hub-settings.test.ts +94 -0
- package/src/__tests__/oauth-ui.test.ts +117 -0
- package/src/__tests__/serve.test.ts +132 -1
- package/src/__tests__/setup-gate.test.ts +93 -0
- package/src/__tests__/setup-wizard.test.ts +392 -0
- package/src/api-modules-ops.ts +120 -1
- package/src/api-settings-hub-origin.ts +253 -0
- package/src/bootstrap-token.ts +153 -0
- package/src/commands/serve.ts +65 -1
- package/src/hub-server.ts +136 -18
- package/src/hub-settings.ts +53 -1
- package/src/oauth-ui.ts +45 -3
- package/src/setup-wizard.ts +178 -13
- package/src/well-known.ts +82 -1
- package/web/ui/dist/assets/index-BKFoB4gE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-XhxYXDT5.js +0 -61
package/src/hub-server.ts
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
|
|
49
49
|
* /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
|
|
50
50
|
* /api/modules/operations/:id (GET) → poll async op status
|
|
51
|
+
* /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
|
|
51
52
|
* /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
|
|
52
53
|
* /api/auth/revoke-token (POST) → revoke registry-row token by jti
|
|
53
54
|
* /api/auth/tokens (GET) → paginated registry list
|
|
@@ -125,6 +126,7 @@ import {
|
|
|
125
126
|
import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
|
|
126
127
|
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
127
128
|
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
129
|
+
import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
|
|
128
130
|
import { handleApiTokens } from "./api-tokens.ts";
|
|
129
131
|
import {
|
|
130
132
|
handleCreateUser,
|
|
@@ -138,6 +140,7 @@ import { ensureCsrfToken } from "./csrf.ts";
|
|
|
138
140
|
import { readExposeState } from "./expose-state.ts";
|
|
139
141
|
import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
|
|
140
142
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
143
|
+
import { getHubOrigin } from "./hub-settings.ts";
|
|
141
144
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
142
145
|
import { pemToJwk } from "./jwks.ts";
|
|
143
146
|
import {
|
|
@@ -289,6 +292,27 @@ export function findVaultUpstream(
|
|
|
289
292
|
return best;
|
|
290
293
|
}
|
|
291
294
|
|
|
295
|
+
/**
|
|
296
|
+
* True iff at least one vault module is registered in services.json. Drives
|
|
297
|
+
* the wizard-resume redirect on `/` and `/hub.html` (Issue 2 of the
|
|
298
|
+
* first-boot-path hardening bundle): an env-seeded admin with no vault
|
|
299
|
+
* still needs the wizard, so `/` should funnel them there rather than
|
|
300
|
+
* render the empty discovery portal.
|
|
301
|
+
*
|
|
302
|
+
* Reads services.json on every check (cheap — single ~KB parse) so a
|
|
303
|
+
* vault provisioned seconds ago un-gates the discovery page without a
|
|
304
|
+
* hub restart. Returns `false` on a malformed services.json — safer to
|
|
305
|
+
* surface the wizard than to 500 the operator's first page load.
|
|
306
|
+
*/
|
|
307
|
+
function hasVaultInstalled(manifestPath: string): boolean {
|
|
308
|
+
try {
|
|
309
|
+
const services = readManifest(manifestPath).services;
|
|
310
|
+
return services.some((s) => isVaultEntry(s));
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
292
316
|
/**
|
|
293
317
|
* The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
|
|
294
318
|
* every request reaches it via one of three trusted forwarders (or directly
|
|
@@ -866,6 +890,57 @@ function dbNotConfigured(): Response {
|
|
|
866
890
|
);
|
|
867
891
|
}
|
|
868
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Resolve the OAuth issuer URL for this request. Precedence, highest
|
|
895
|
+
* first (hub#298):
|
|
896
|
+
*
|
|
897
|
+
* 1. `hub_settings.hub_origin` — operator-set canonical URL from the
|
|
898
|
+
* admin SPA. Wins when present.
|
|
899
|
+
* 2. `configuredIssuer` — `--issuer` flag or `PARACHUTE_HUB_ORIGIN`
|
|
900
|
+
* env var captured at hub start. The deploy-time setting.
|
|
901
|
+
* 3. `new URL(req.url).origin` — the request's own origin. Local dev
|
|
902
|
+
* + Render-assigned subdomains land here when nothing's been
|
|
903
|
+
* configured.
|
|
904
|
+
*
|
|
905
|
+
* Per-request (not cached at hub start) so a PUT to
|
|
906
|
+
* `/api/settings/hub-origin` takes effect on the next request without a
|
|
907
|
+
* restart. The hub_settings read is a single small SQLite query — cheap
|
|
908
|
+
* relative to JWT signing on the same request path.
|
|
909
|
+
*
|
|
910
|
+
* `db` is optional because the wellknown / discovery surfaces are
|
|
911
|
+
* reachable on a hub with no DB configured (the dbNotConfigured 503
|
|
912
|
+
* gate sits behind these in the dispatcher). In that case we skip the
|
|
913
|
+
* settings layer and fall through to env/request precedence.
|
|
914
|
+
*/
|
|
915
|
+
export function resolveIssuer(
|
|
916
|
+
req: Request,
|
|
917
|
+
db: Database | undefined,
|
|
918
|
+
configuredIssuer: string | undefined,
|
|
919
|
+
): string {
|
|
920
|
+
if (db !== undefined) {
|
|
921
|
+
const stored = getHubOrigin(db);
|
|
922
|
+
if (stored) return stored;
|
|
923
|
+
}
|
|
924
|
+
if (configuredIssuer) return configuredIssuer;
|
|
925
|
+
return new URL(req.url).origin;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Where did the resolved issuer come from? Drives the source-label
|
|
930
|
+
* surfaced in the admin SPA so operators can tell which precedence
|
|
931
|
+
* layer they're on without inspecting the DB or env.
|
|
932
|
+
*/
|
|
933
|
+
export type IssuerSource = "settings" | "env" | "request";
|
|
934
|
+
|
|
935
|
+
export function resolveIssuerSource(
|
|
936
|
+
db: Database | undefined,
|
|
937
|
+
configuredIssuer: string | undefined,
|
|
938
|
+
): IssuerSource {
|
|
939
|
+
if (db !== undefined && getHubOrigin(db)) return "settings";
|
|
940
|
+
if (configuredIssuer) return "env";
|
|
941
|
+
return "request";
|
|
942
|
+
}
|
|
943
|
+
|
|
869
944
|
export function hubFetch(
|
|
870
945
|
wellKnownDir: string,
|
|
871
946
|
deps?: HubFetchDeps,
|
|
@@ -889,7 +964,7 @@ export function hubFetch(
|
|
|
889
964
|
});
|
|
890
965
|
|
|
891
966
|
const oauthDeps = (req: Request) => {
|
|
892
|
-
const issuer =
|
|
967
|
+
const issuer = resolveIssuer(req, getDb?.(), configuredIssuer);
|
|
893
968
|
return {
|
|
894
969
|
issuer,
|
|
895
970
|
// Per-request resolution (closes #245): expose-state.json can change
|
|
@@ -1061,23 +1136,43 @@ export function hubFetch(
|
|
|
1061
1136
|
return new Response("not found", { status: 404 });
|
|
1062
1137
|
}
|
|
1063
1138
|
|
|
1064
|
-
// Fresh-hub redirect:
|
|
1065
|
-
// page (`/`, `/hub.html`) funnels straight to
|
|
1066
|
-
//
|
|
1067
|
-
// "Signed in" affordance has no session to surface — and the
|
|
1068
|
-
// operator landing on `/` in a browser otherwise has to manually
|
|
1069
|
-
// type `/admin/setup` to escape. 302 (not 301) so the redirect
|
|
1070
|
-
// disappears the moment the wizard finishes.
|
|
1139
|
+
// Fresh-hub redirect: when the wizard still has work to do, the
|
|
1140
|
+
// discovery page (`/`, `/hub.html`) funnels straight to it. Two
|
|
1141
|
+
// wizard-mode conditions trigger the redirect:
|
|
1071
1142
|
//
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1143
|
+
// 1. No admin row exists (the original fresh-deploy case). The
|
|
1144
|
+
// static portal carries no usable signal — no installed
|
|
1145
|
+
// services to discover, no admin to sign in as.
|
|
1146
|
+
// 2. Admin exists but no vault is installed (env-seed deploys
|
|
1147
|
+
// where the operator baked admin into env vars but hasn't
|
|
1148
|
+
// walked the wizard's vault step). Pre-fix, env-seeded
|
|
1149
|
+
// operators bounced past the wizard entirely and had to
|
|
1150
|
+
// hand-find /admin/modules + /admin/vaults; surface
|
|
1151
|
+
// "let me finish the wizard" instead.
|
|
1152
|
+
//
|
|
1153
|
+
// The wizard's GET handler already picks the right step
|
|
1154
|
+
// (`deriveWizardState` resumes at vault step when admin exists +
|
|
1155
|
+
// no vault), so we just need the redirect to fire.
|
|
1156
|
+
//
|
|
1157
|
+
// 302 (not 301) so the redirect disappears the moment the wizard
|
|
1158
|
+
// finishes. Sits before the JSON-shaped 503 gate below because `/`
|
|
1159
|
+
// is an HTML surface — a JSON 503 there would render as raw text
|
|
1160
|
+
// in the operator's browser tab. The 503 gate handles API + admin
|
|
1161
|
+
// SPA + OAuth callers that branch on the structured body.
|
|
1162
|
+
if (getDb && (pathname === "/" || pathname === "/hub.html")) {
|
|
1163
|
+
const db = getDb();
|
|
1164
|
+
// Either condition triggers the wizard funnel:
|
|
1165
|
+
// - no admin row (the fresh-deploy case)
|
|
1166
|
+
// - admin row exists but no vault installed (env-seed case)
|
|
1167
|
+
// Short-circuit the manifest read when `noAdmin` is true; the
|
|
1168
|
+
// wizard's first step is admin creation regardless of vault state.
|
|
1169
|
+
const needsWizard = userCount(db) === 0 || !hasVaultInstalled(manifestPath);
|
|
1170
|
+
if (needsWizard) {
|
|
1171
|
+
return new Response(null, {
|
|
1172
|
+
status: 302,
|
|
1173
|
+
headers: { location: "/admin/setup" },
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1081
1176
|
}
|
|
1082
1177
|
|
|
1083
1178
|
// Pre-admin lockout. When the hub has booted with no admin row (the
|
|
@@ -1173,7 +1268,15 @@ export function hubFetch(
|
|
|
1173
1268
|
// the request's own origin (fine for direct loopback hits).
|
|
1174
1269
|
try {
|
|
1175
1270
|
const manifest = readManifest(manifestPath);
|
|
1176
|
-
|
|
1271
|
+
// Same precedence as the OAuth issuer (hub#298): hub_settings →
|
|
1272
|
+
// env → request origin. The well-known doc embeds this origin
|
|
1273
|
+
// in service URLs + the issuer metadata link, so it must follow
|
|
1274
|
+
// the same chain — otherwise a public-domain operator who set
|
|
1275
|
+
// `hub_origin` would still see the Render-assigned URL on
|
|
1276
|
+
// `/.well-known/parachute.json` while their JWTs carry the
|
|
1277
|
+
// canonical URL, and discovery clients would split-brain on
|
|
1278
|
+
// which one to trust.
|
|
1279
|
+
const canonicalOrigin = resolveIssuer(req, getDb?.(), configuredIssuer);
|
|
1177
1280
|
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
1178
1281
|
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
1179
1282
|
loadManagementUrls(manifest.services, readManifestFn),
|
|
@@ -1398,6 +1501,21 @@ export function hubFetch(
|
|
|
1398
1501
|
});
|
|
1399
1502
|
}
|
|
1400
1503
|
|
|
1504
|
+
// Canonical hub URL (hub#298). Admin SPA reads + writes the
|
|
1505
|
+
// operator-set issuer override. The handler computes the resolved
|
|
1506
|
+
// issuer + source here so it can surface them in the GET payload
|
|
1507
|
+
// without re-walking the precedence chain inside the handler.
|
|
1508
|
+
if (pathname === "/api/settings/hub-origin") {
|
|
1509
|
+
if (!getDb) return dbNotConfigured();
|
|
1510
|
+
const db = getDb();
|
|
1511
|
+
return handleApiSettingsHubOrigin(req, {
|
|
1512
|
+
db,
|
|
1513
|
+
issuer: oauthDeps(req).issuer,
|
|
1514
|
+
resolvedIssuer: resolveIssuer(req, db, configuredIssuer),
|
|
1515
|
+
resolvedSource: resolveIssuerSource(db, configuredIssuer),
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1401
1519
|
// Module operation poll surface — pre-empts the /api/modules/:short/*
|
|
1402
1520
|
// routes below so `/api/modules/operations/<uuid>` doesn't accidentally
|
|
1403
1521
|
// match a parseModulesPath("/operations") and fall through.
|
package/src/hub-settings.ts
CHANGED
|
@@ -52,7 +52,21 @@ export type HubSettingKey =
|
|
|
52
52
|
// channel); after the first seed the row is source of truth and the
|
|
53
53
|
// env var is ignored — admin must use the SPA toggle (or
|
|
54
54
|
// `PUT /api/modules/channel`) to change channel.
|
|
55
|
-
| "module_install_channel"
|
|
55
|
+
| "module_install_channel"
|
|
56
|
+
// hub#298: operator-settable canonical hub URL. The value (when set)
|
|
57
|
+
// is the OAuth issuer claim stamped into every JWT minted by hub.
|
|
58
|
+
// Precedence on each request: this row, then `PARACHUTE_HUB_ORIGIN`
|
|
59
|
+
// env, then `new URL(req.url).origin` as the local-dev fallback.
|
|
60
|
+
//
|
|
61
|
+
// Storing the canonical origin in hub_settings (rather than relying
|
|
62
|
+
// solely on the env var) lets a Render operator attach a custom
|
|
63
|
+
// domain after first boot + flip the issuer URL from the admin SPA
|
|
64
|
+
// without restarting the container. Tokens minted before the change
|
|
65
|
+
// carry the old `iss` claim; tokens minted after carry the new one.
|
|
66
|
+
// Operators must accept that flipping the canonical hub URL
|
|
67
|
+
// invalidates any tokens already in circulation (issuer mismatch on
|
|
68
|
+
// verification) — surfaced in the admin SPA's helper copy.
|
|
69
|
+
| "hub_origin";
|
|
56
70
|
|
|
57
71
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
58
72
|
|
|
@@ -257,3 +271,41 @@ export function getModuleInstallChannel(
|
|
|
257
271
|
export function setModuleInstallChannel(db: Database, channel: ModuleInstallChannel): void {
|
|
258
272
|
setSetting(db, "module_install_channel", channel);
|
|
259
273
|
}
|
|
274
|
+
|
|
275
|
+
// --- domain helpers: canonical hub origin --------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read the operator-set canonical hub origin from hub_settings. Returns
|
|
279
|
+
* `null` when no row is present — callers fall through to env / request-
|
|
280
|
+
* origin precedence in that case (see `resolveIssuer` in hub-server).
|
|
281
|
+
*
|
|
282
|
+
* Unlike `module_install_channel` this helper does NOT seed from env on
|
|
283
|
+
* first read. The env var (`PARACHUTE_HUB_ORIGIN`) remains a separate
|
|
284
|
+
* precedence layer below this one — operators who set the env var still
|
|
285
|
+
* see "from env" attribution in the admin SPA. Auto-seeding would
|
|
286
|
+
* collapse that layer into the row + lose the source attribution.
|
|
287
|
+
*/
|
|
288
|
+
export function getHubOrigin(db: Database): string | null {
|
|
289
|
+
const value = getSetting(db, "hub_origin");
|
|
290
|
+
return value ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Write or clear the canonical hub origin. Passing `null` deletes the
|
|
295
|
+
* row, reverting to env / request-origin precedence on subsequent
|
|
296
|
+
* requests. The caller must have validated the URL shape — the
|
|
297
|
+
* function trusts the input and writes verbatim (mirrors
|
|
298
|
+
* `setModuleInstallChannel`'s "typed-callsite is the contract" stance).
|
|
299
|
+
*
|
|
300
|
+
* Empty-string is treated as null (the value would never be a useful
|
|
301
|
+
* issuer) to avoid storing a row that no codepath would honor — the
|
|
302
|
+
* resolveIssuer fallback chain would skip it as falsy and the source
|
|
303
|
+
* label would lie about where the value came from.
|
|
304
|
+
*/
|
|
305
|
+
export function setHubOrigin(db: Database, value: string | null): void {
|
|
306
|
+
if (value === null || value === "") {
|
|
307
|
+
deleteSetting(db, "hub_origin");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
setSetting(db, "hub_origin", value);
|
|
311
|
+
}
|
package/src/oauth-ui.ts
CHANGED
|
@@ -359,10 +359,30 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
|
359
359
|
approveForm,
|
|
360
360
|
} = props;
|
|
361
361
|
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
362
|
+
// Substitute unnamed `vault:<verb>` rows with the wildcard display form
|
|
363
|
+
// (`vault:*:<verb>`) so the operator sees the shape that will appear in
|
|
364
|
+
// the token after consent narrows the scope to a specific vault. At
|
|
365
|
+
// approve time no vault has been picked yet — the SPA's
|
|
366
|
+
// `/admin/approve-client/<id>` view uses the same wildcard treatment
|
|
367
|
+
// (see `resolveScopeForDisplay` in `web/ui/src/routes/ApproveClient.tsx`).
|
|
368
|
+
const displayedScopes = requestedScopes.map((s) => substituteVaultDisplay(s, "*"));
|
|
362
369
|
const scopeRows =
|
|
363
|
-
|
|
370
|
+
displayedScopes.length === 0
|
|
364
371
|
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
365
|
-
:
|
|
372
|
+
: displayedScopes.map(renderScopeRow).join("\n");
|
|
373
|
+
// Wildcard explanation: surface the "the asterisk means the vault is
|
|
374
|
+
// picked later" hint below the scope list when at least one row carries
|
|
375
|
+
// `vault:*:<verb>`. Mirrors the SPA's inline note on
|
|
376
|
+
// `/admin/approve-client/<id>`. Omitted when no scope renders with `*`
|
|
377
|
+
// (all scopes are either non-vault or already-named).
|
|
378
|
+
const wildcardNote = displayedScopes.some((s) => /^vault:\*:(read|write)$/.test(s))
|
|
379
|
+
? `
|
|
380
|
+
<p class="scope-wildcard-note">
|
|
381
|
+
<code>*</code> — a specific vault is selected during sign-in via the consent
|
|
382
|
+
picker (or the user's assigned vault for multi-user setups). The
|
|
383
|
+
<code>*</code> shows the unbound shape.
|
|
384
|
+
</p>`
|
|
385
|
+
: "";
|
|
366
386
|
// Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
|
|
367
387
|
// `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
|
|
368
388
|
// alongside scopes so a multi-vault operator can tell which vault they're
|
|
@@ -414,7 +434,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
|
414
434
|
</section>
|
|
415
435
|
<section class="scopes">
|
|
416
436
|
<h2 class="scopes-title">Permissions requested</h2>
|
|
417
|
-
<ul class="scope-list">${scopeRows}</ul
|
|
437
|
+
<ul class="scope-list">${scopeRows}</ul>${wildcardNote}
|
|
418
438
|
</section>
|
|
419
439
|
${formSection}
|
|
420
440
|
</div>`;
|
|
@@ -507,6 +527,13 @@ function renderUnauthenticatedApproveCtas(hubOrigin: string, clientId: string):
|
|
|
507
527
|
* the admin consent screen when the picker hasn't been touched yet, and
|
|
508
528
|
* on the operator approval page where the vault is selected at the
|
|
509
529
|
* per-user sign-in step, not at approve time.
|
|
530
|
+
* - `displayVault === "*"` → render with a literal asterisk placeholder
|
|
531
|
+
* (`vault:*:verb`). Used on the server-rendered approve-pending page
|
|
532
|
+
* and the SPA's `/admin/approve-client/<id>` view: at approve time the
|
|
533
|
+
* vault isn't bound yet (no consent picker has run), so the asterisk
|
|
534
|
+
* signals "wildcard — a specific vault is selected later in the flow."
|
|
535
|
+
* Callers that render this form should also surface the inline
|
|
536
|
+
* explanation below the scope list (see `renderApprovePending`).
|
|
510
537
|
* - `displayVault === "name"` → render as `vault:name:verb` literally.
|
|
511
538
|
*
|
|
512
539
|
* Non-vault scopes pass through untouched. Already-named `vault:<x>:<verb>`
|
|
@@ -1143,6 +1170,21 @@ const STYLES = `
|
|
|
1143
1170
|
color: ${PALETTE.fgDim};
|
|
1144
1171
|
font-style: italic;
|
|
1145
1172
|
}
|
|
1173
|
+
.scope-wildcard-note {
|
|
1174
|
+
margin: 0.6rem 0 0;
|
|
1175
|
+
font-size: 0.82rem;
|
|
1176
|
+
color: ${PALETTE.fgDim};
|
|
1177
|
+
font-style: italic;
|
|
1178
|
+
line-height: 1.45;
|
|
1179
|
+
}
|
|
1180
|
+
.scope-wildcard-note code {
|
|
1181
|
+
font-family: ${FONT_MONO};
|
|
1182
|
+
font-style: normal;
|
|
1183
|
+
background: ${PALETTE.bgSoft};
|
|
1184
|
+
padding: 0.05rem 0.3rem;
|
|
1185
|
+
border-radius: 4px;
|
|
1186
|
+
color: ${PALETTE.fgMuted};
|
|
1187
|
+
}
|
|
1146
1188
|
.badge {
|
|
1147
1189
|
display: inline-block;
|
|
1148
1190
|
font-size: 0.7rem;
|
package/src/setup-wizard.ts
CHANGED
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
import type { Database } from "bun:sqlite";
|
|
41
41
|
import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
|
|
42
42
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
43
|
+
import {
|
|
44
|
+
BOOTSTRAP_TOKEN_PREFIX,
|
|
45
|
+
consumeBootstrapToken,
|
|
46
|
+
getBootstrapToken,
|
|
47
|
+
verifyBootstrapToken,
|
|
48
|
+
} from "./bootstrap-token.ts";
|
|
43
49
|
import {
|
|
44
50
|
CSRF_FIELD_NAME,
|
|
45
51
|
ensureCsrfToken,
|
|
@@ -254,12 +260,60 @@ export interface RenderAccountStepProps {
|
|
|
254
260
|
errorMessage?: string;
|
|
255
261
|
/** Pre-fill the username field after a validation failure. */
|
|
256
262
|
username?: string;
|
|
263
|
+
/**
|
|
264
|
+
* Whether the bootstrap-token field should render and be required.
|
|
265
|
+
* True under `parachute serve` wizard mode (no admin, no env-seed) —
|
|
266
|
+
* `commands/serve.ts` mints + logs the token on boot and the form
|
|
267
|
+
* requires it to claim the admin row. False on the on-box CLI surface
|
|
268
|
+
* (the operator already has shell access; gating the form behind a
|
|
269
|
+
* token they'd also need to read from logs adds friction with no
|
|
270
|
+
* security gain).
|
|
271
|
+
*
|
|
272
|
+
* UX: when true, the token field is the FIRST field on the form so
|
|
273
|
+
* an operator who hasn't seen the log line stops here rather than
|
|
274
|
+
* filling username + password and bouncing off a 401.
|
|
275
|
+
*/
|
|
276
|
+
requireBootstrapToken?: boolean;
|
|
277
|
+
/**
|
|
278
|
+
* Pre-fill the bootstrap-token field after a validation failure on a
|
|
279
|
+
* field OTHER than the token itself. We never echo a wrong token back
|
|
280
|
+
* — the form re-renders with an empty token field so the operator has
|
|
281
|
+
* to re-look-up the correct value.
|
|
282
|
+
*/
|
|
283
|
+
bootstrapToken?: string;
|
|
257
284
|
}
|
|
258
285
|
|
|
259
286
|
export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
260
|
-
const { csrfToken, errorMessage, username } = props;
|
|
287
|
+
const { csrfToken, errorMessage, username, requireBootstrapToken, bootstrapToken } = props;
|
|
261
288
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
262
289
|
const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
|
|
290
|
+
const tokenAttr = bootstrapToken ? ` value="${escapeAttr(bootstrapToken)}"` : "";
|
|
291
|
+
// Bootstrap-token field comes FIRST when required. An operator who
|
|
292
|
+
// missed the log line is stopped here rather than after filling
|
|
293
|
+
// username + password.
|
|
294
|
+
const bootstrapField = requireBootstrapToken
|
|
295
|
+
? `
|
|
296
|
+
<label class="field">
|
|
297
|
+
<span class="field-label">Bootstrap token</span>
|
|
298
|
+
<input type="text" name="bootstrap_token" autocomplete="off"
|
|
299
|
+
autofocus required minlength="20" maxlength="200"
|
|
300
|
+
spellcheck="false" autocapitalize="off"
|
|
301
|
+
placeholder="${escapeAttr(BOOTSTRAP_TOKEN_PREFIX)}…"${tokenAttr} />
|
|
302
|
+
<span class="field-hint">Find this in your hub's startup logs.
|
|
303
|
+
Look for the <code>${escapeHtml(BOOTSTRAP_TOKEN_PREFIX)}</code> line.</span>
|
|
304
|
+
</label>`
|
|
305
|
+
: "";
|
|
306
|
+
// When the token is required we drop `autofocus` off the username field
|
|
307
|
+
// so it doesn't fight the token field's focus.
|
|
308
|
+
const usernameAutofocus = requireBootstrapToken ? "" : " autofocus";
|
|
309
|
+
const tokenCallout = requireBootstrapToken
|
|
310
|
+
? `<aside class="bootstrap-callout">
|
|
311
|
+
<strong>One-time setup credential.</strong> This hub was deployed without
|
|
312
|
+
baked-in admin credentials, so it generated a one-time bootstrap token
|
|
313
|
+
on startup. Paste it below to claim the admin account. The token
|
|
314
|
+
expires once the admin is created (or when the hub restarts).
|
|
315
|
+
</aside>`
|
|
316
|
+
: "";
|
|
263
317
|
const body = `
|
|
264
318
|
<div class="card">
|
|
265
319
|
<div class="card-header">
|
|
@@ -278,13 +332,15 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
|
278
332
|
<p>After this you'll name your first vault. The hub will install it
|
|
279
333
|
and issue a token your Claude Code MCP client can use.</p>
|
|
280
334
|
</section>
|
|
335
|
+
${tokenCallout}
|
|
281
336
|
${error}
|
|
282
337
|
<form method="POST" action="/admin/setup/account" class="auth-form">
|
|
283
338
|
${renderCsrfHiddenInput(csrfToken)}
|
|
339
|
+
${bootstrapField}
|
|
284
340
|
<label class="field">
|
|
285
341
|
<span class="field-label">Username</span>
|
|
286
|
-
<input type="text" name="username" autocomplete="username"
|
|
287
|
-
|
|
342
|
+
<input type="text" name="username" autocomplete="username"${usernameAutofocus}
|
|
343
|
+
required minlength="2" maxlength="64"
|
|
288
344
|
pattern="[A-Za-z0-9_.-]+" title="letters, digits, _ . - (2–64 chars)"
|
|
289
345
|
${usernameAttr} />
|
|
290
346
|
<span class="field-hint">letters, digits, <code>_</code>, <code>.</code>, <code>-</code></span>
|
|
@@ -307,7 +363,8 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
|
307
363
|
<p>Set <code>PARACHUTE_INITIAL_ADMIN_USERNAME</code> and
|
|
308
364
|
<code>PARACHUTE_INITIAL_ADMIN_PASSWORD</code> on the container and
|
|
309
365
|
restart. The hub will create the admin row on next boot and skip this
|
|
310
|
-
wizard
|
|
366
|
+
wizard (no bootstrap token needed — the env vars themselves are the
|
|
367
|
+
claim).</p>
|
|
311
368
|
</details>
|
|
312
369
|
</div>`;
|
|
313
370
|
return baseDocument("Set up your Parachute hub — account", body);
|
|
@@ -1049,11 +1106,23 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1049
1106
|
});
|
|
1050
1107
|
}
|
|
1051
1108
|
|
|
1052
|
-
// Step 1+2 (no admin yet).
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1109
|
+
// Step 1+2 (no admin yet). Render with the bootstrap-token field iff a
|
|
1110
|
+
// token is currently active — `commands/serve.ts` only mints one on
|
|
1111
|
+
// wizard-mode boot (no env-seed), so the field's presence is a 1:1
|
|
1112
|
+
// signal of "the operator needs a token to claim this hub." On the
|
|
1113
|
+
// on-box CLI surface and on env-seed-followed-by-deleted-admin paths,
|
|
1114
|
+
// the token is absent and the form renders the historical shape.
|
|
1115
|
+
const requireToken = getBootstrapToken() !== undefined;
|
|
1116
|
+
return new Response(
|
|
1117
|
+
renderAccountStep({
|
|
1118
|
+
csrfToken: csrf.token,
|
|
1119
|
+
requireBootstrapToken: requireToken,
|
|
1120
|
+
}),
|
|
1121
|
+
{
|
|
1122
|
+
status: 200,
|
|
1123
|
+
headers: extraHeaders,
|
|
1124
|
+
},
|
|
1125
|
+
);
|
|
1057
1126
|
}
|
|
1058
1127
|
|
|
1059
1128
|
/**
|
|
@@ -1076,17 +1145,65 @@ export async function handleSetupAccountPost(
|
|
|
1076
1145
|
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1077
1146
|
}
|
|
1078
1147
|
// Already-bootstrapped: bounce. The wizard's GET state will resolve to
|
|
1079
|
-
// step 3 or step 4 on the next request.
|
|
1148
|
+
// step 3 or step 4 on the next request. We return 410 Gone for the
|
|
1149
|
+
// case where a bootstrap token was active this boot AND has already
|
|
1150
|
+
// been consumed by a prior POST — distinguishes "you missed your
|
|
1151
|
+
// window" from "this hub never had a wizard-mode boot" so a racing
|
|
1152
|
+
// attacker sees a hard-stop rather than a soft redirect.
|
|
1153
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
1154
|
+
const requireToken = getBootstrapToken() !== undefined;
|
|
1080
1155
|
if (userCount(deps.db) > 0) {
|
|
1081
|
-
|
|
1156
|
+
if (!requireToken) {
|
|
1157
|
+
return redirect("/admin/setup");
|
|
1158
|
+
}
|
|
1159
|
+
// Defense in depth: a token was active but an admin already exists.
|
|
1160
|
+
// Treat as consumed.
|
|
1161
|
+
return new Response(renderClaimAlreadyHappenedPage(), {
|
|
1162
|
+
status: 410,
|
|
1163
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
// Bootstrap-token gate. Only enforced when wizard mode is active —
|
|
1167
|
+
// env-seed admins never reach this path (they're admin-exists by
|
|
1168
|
+
// boot time), CLI mode never mints a token (the on-box operator
|
|
1169
|
+
// already has shell auth). Wrong token → 401 + form re-render with
|
|
1170
|
+
// an empty token field; right token → fall through.
|
|
1171
|
+
if (requireToken) {
|
|
1172
|
+
const suppliedToken = String(form.get("bootstrap_token") ?? "").trim();
|
|
1173
|
+
if (!verifyBootstrapToken(suppliedToken)) {
|
|
1174
|
+
const username = String(form.get("username") ?? "").trim();
|
|
1175
|
+
return htmlResponse(
|
|
1176
|
+
renderAccountStep({
|
|
1177
|
+
csrfToken,
|
|
1178
|
+
username,
|
|
1179
|
+
requireBootstrapToken: true,
|
|
1180
|
+
// Deliberately do NOT echo the wrong supplied token back —
|
|
1181
|
+
// forces re-look-up rather than tab-completing a typo.
|
|
1182
|
+
errorMessage:
|
|
1183
|
+
"Wrong bootstrap token. Re-check your hub's startup logs for the " +
|
|
1184
|
+
"`parachute-bootstrap-…` line and try again.",
|
|
1185
|
+
}),
|
|
1186
|
+
401,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1082
1189
|
}
|
|
1083
1190
|
const username = String(form.get("username") ?? "").trim();
|
|
1084
1191
|
const password = String(form.get("password") ?? "");
|
|
1085
1192
|
const confirm = String(form.get("password_confirm") ?? "");
|
|
1086
|
-
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
1087
1193
|
const fieldErr = validateAccountFields({ username, password, confirm });
|
|
1088
1194
|
if (fieldErr) {
|
|
1089
|
-
return htmlResponse(
|
|
1195
|
+
return htmlResponse(
|
|
1196
|
+
renderAccountStep({
|
|
1197
|
+
csrfToken,
|
|
1198
|
+
username,
|
|
1199
|
+
// Re-render with the token field present iff still required (it
|
|
1200
|
+
// was valid this attempt — the field-error came from username/
|
|
1201
|
+
// password). Empty value so the operator pastes again.
|
|
1202
|
+
requireBootstrapToken: requireToken,
|
|
1203
|
+
errorMessage: fieldErr,
|
|
1204
|
+
}),
|
|
1205
|
+
400,
|
|
1206
|
+
);
|
|
1090
1207
|
}
|
|
1091
1208
|
try {
|
|
1092
1209
|
// Wizard-admin chose their password through this very form; skip the
|
|
@@ -1095,6 +1212,14 @@ export async function handleSetupAccountPost(
|
|
|
1095
1212
|
// (the wizard never asks the first admin to pin themselves to a
|
|
1096
1213
|
// single vault; that's a non-admin user pattern).
|
|
1097
1214
|
const user = await createUser(deps.db, username, password, { passwordChanged: true });
|
|
1215
|
+
// Consume the bootstrap token AFTER the admin row is committed.
|
|
1216
|
+
// Doing it before would let a `createUser` exception (UNIQUE-collision,
|
|
1217
|
+
// disk full, anything) leave the token un-consumed but the admin row
|
|
1218
|
+
// partially written — and the operator without a way to retry.
|
|
1219
|
+
// Doing it after means a successful claim invalidates the token for
|
|
1220
|
+
// any racer who saw it over the operator's shoulder during the
|
|
1221
|
+
// window between log-print and form-submit.
|
|
1222
|
+
if (requireToken) consumeBootstrapToken();
|
|
1098
1223
|
const session = createSession(deps.db, { userId: user.id });
|
|
1099
1224
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1100
1225
|
secure: isHttpsRequest(req),
|
|
@@ -1116,6 +1241,7 @@ export async function handleSetupAccountPost(
|
|
|
1116
1241
|
renderAccountStep({
|
|
1117
1242
|
csrfToken,
|
|
1118
1243
|
username,
|
|
1244
|
+
requireBootstrapToken: requireToken,
|
|
1119
1245
|
errorMessage: "Failed to create account. The username may already be taken.",
|
|
1120
1246
|
}),
|
|
1121
1247
|
400,
|
|
@@ -1123,6 +1249,34 @@ export async function handleSetupAccountPost(
|
|
|
1123
1249
|
}
|
|
1124
1250
|
}
|
|
1125
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* Static error page surfaced when an `/admin/setup/account` POST arrives
|
|
1254
|
+
* after the bootstrap token has already been consumed by a successful
|
|
1255
|
+
* admin claim. Returned with HTTP 410 Gone (vs. the 302 redirect a
|
|
1256
|
+
* stale-tab without a token gets) so a scripted attacker reading the
|
|
1257
|
+
* status code sees an unmistakable "you missed the window" signal.
|
|
1258
|
+
*
|
|
1259
|
+
* No retry CTA — the wizard is past its account step; pointing the
|
|
1260
|
+
* operator at /login is the right answer.
|
|
1261
|
+
*/
|
|
1262
|
+
function renderClaimAlreadyHappenedPage(): string {
|
|
1263
|
+
const body = `
|
|
1264
|
+
<div class="card">
|
|
1265
|
+
${header("account")}
|
|
1266
|
+
<h1 class="error-title">Admin already claimed</h1>
|
|
1267
|
+
<p class="subtitle">This hub's bootstrap token was already used to
|
|
1268
|
+
create the admin account. The token is one-shot — it can't be
|
|
1269
|
+
reused to claim a second admin or rotate the existing one.</p>
|
|
1270
|
+
<p class="subtitle">If you're the legitimate operator, sign in at
|
|
1271
|
+
<a href="/login"><code>/login</code></a>. If you've lost the
|
|
1272
|
+
password, restart the hub (which mints a fresh token) and use
|
|
1273
|
+
<code>parachute auth set-password</code> from a shell with
|
|
1274
|
+
access to the hub's PARACHUTE_HOME.</p>
|
|
1275
|
+
<p><a class="btn btn-primary" href="/login">Go to sign-in</a></p>
|
|
1276
|
+
</div>`;
|
|
1277
|
+
return baseDocument("Admin already claimed", body);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1126
1280
|
/**
|
|
1127
1281
|
* POST `/admin/setup/vault`. Form-encoded.
|
|
1128
1282
|
*
|
|
@@ -1954,6 +2108,17 @@ const STYLES = `
|
|
|
1954
2108
|
}
|
|
1955
2109
|
.error-title { color: ${PALETTE.danger}; }
|
|
1956
2110
|
|
|
2111
|
+
.bootstrap-callout {
|
|
2112
|
+
background: ${PALETTE.warnSoft};
|
|
2113
|
+
border-left: 3px solid ${PALETTE.warn};
|
|
2114
|
+
border-radius: 0 6px 6px 0;
|
|
2115
|
+
padding: 0.7rem 0.9rem;
|
|
2116
|
+
margin: 0 0 1rem;
|
|
2117
|
+
font-size: 0.9rem;
|
|
2118
|
+
color: ${PALETTE.fg};
|
|
2119
|
+
}
|
|
2120
|
+
.bootstrap-callout strong { color: ${PALETTE.fg}; }
|
|
2121
|
+
|
|
1957
2122
|
.op-log {
|
|
1958
2123
|
background: ${PALETTE.bg};
|
|
1959
2124
|
border: 1px solid ${PALETTE.borderLight};
|