@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -42,19 +42,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
42
42
|
import { join } from "node:path";
|
|
43
43
|
import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
|
|
44
44
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
45
|
-
import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
|
|
46
45
|
import {
|
|
47
46
|
BOOTSTRAP_TOKEN_PREFIX,
|
|
48
47
|
consumeBootstrapToken,
|
|
49
48
|
getBootstrapToken,
|
|
50
49
|
verifyBootstrapToken,
|
|
51
50
|
} from "./bootstrap-token.ts";
|
|
51
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
52
52
|
import {
|
|
53
53
|
CSRF_FIELD_NAME,
|
|
54
54
|
ensureCsrfToken,
|
|
55
55
|
renderCsrfHiddenInput,
|
|
56
56
|
verifyCsrfToken,
|
|
57
57
|
} from "./csrf.ts";
|
|
58
|
+
import { type ExposeState, readExposeState } from "./expose-state.ts";
|
|
58
59
|
import {
|
|
59
60
|
SETUP_EXPOSE_MODES,
|
|
60
61
|
type SetupExposeMode,
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
openFirstClientAutoApproveWindow,
|
|
65
66
|
setSetting,
|
|
66
67
|
} from "./hub-settings.ts";
|
|
68
|
+
import { signAccessToken } from "./jwt-sign.ts";
|
|
67
69
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
68
70
|
import { mintOperatorToken } from "./operator-token.ts";
|
|
69
71
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
@@ -106,6 +108,71 @@ function escapeAttr(s: string): string {
|
|
|
106
108
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The CLI wizard (hub#168 Cut 3) sends `Accept: application/json` on
|
|
113
|
+
* every GET; the browser sends `Accept: text/html, …`. We branch the GET
|
|
114
|
+
* handler's response shape on this header. Same DB / state-derivation
|
|
115
|
+
* path both ways — only the rendering forks.
|
|
116
|
+
*
|
|
117
|
+
* POSTs from the CLI wizard send `Content-Type: application/json`;
|
|
118
|
+
* browser POSTs send `application/x-www-form-urlencoded`. The POST
|
|
119
|
+
* handlers parse-into-the-same-shape with `readBodyFields` below.
|
|
120
|
+
*/
|
|
121
|
+
function wantsJsonResponse(req: Request): boolean {
|
|
122
|
+
const accept = req.headers.get("accept") ?? "";
|
|
123
|
+
return accept.includes("application/json");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Best-effort body parser shared by every wizard POST handler. Branches
|
|
128
|
+
* on Content-Type:
|
|
129
|
+
* * `application/json` → parses the body as JSON, projects each
|
|
130
|
+
* top-level field into a `Map<string, string>` (matches the
|
|
131
|
+
* FormData getter shape the rest of the handlers use).
|
|
132
|
+
* * Anything else → standard `req.formData()` (the historical browser
|
|
133
|
+
* path).
|
|
134
|
+
*
|
|
135
|
+
* Returns a tuple of `[fields, isJson]`. `isJson` lets the handler
|
|
136
|
+
* decide between a 303 redirect (browser) and a 200 JSON envelope
|
|
137
|
+
* (CLI). The fields-getter API is intentionally lossy on JSON arrays /
|
|
138
|
+
* nested objects — every wizard field today is a plain string, so the
|
|
139
|
+
* Map<string, string> shape is sufficient. If we ever need arrays here,
|
|
140
|
+
* extend with a `getAll(name)` shim.
|
|
141
|
+
*/
|
|
142
|
+
async function readBodyFields(req: Request): Promise<{
|
|
143
|
+
get: (name: string) => string | null;
|
|
144
|
+
isJson: boolean;
|
|
145
|
+
}> {
|
|
146
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
147
|
+
if (contentType.includes("application/json")) {
|
|
148
|
+
let parsed: Record<string, unknown> = {};
|
|
149
|
+
try {
|
|
150
|
+
parsed = (await req.json()) as Record<string, unknown>;
|
|
151
|
+
} catch {
|
|
152
|
+
// Malformed JSON falls through to an empty map — the handlers'
|
|
153
|
+
// existing field-validation surfaces the right error message.
|
|
154
|
+
parsed = {};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
isJson: true,
|
|
158
|
+
get: (name: string) => {
|
|
159
|
+
const v = parsed[name];
|
|
160
|
+
if (typeof v === "string") return v;
|
|
161
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
162
|
+
return null;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const form = await req.formData();
|
|
167
|
+
return {
|
|
168
|
+
isJson: false,
|
|
169
|
+
get: (name: string) => {
|
|
170
|
+
const v = form.get(name);
|
|
171
|
+
return typeof v === "string" ? v : null;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
109
176
|
// --- state derivation ----------------------------------------------------
|
|
110
177
|
|
|
111
178
|
/**
|
|
@@ -141,13 +208,41 @@ export interface DerivedWizardState {
|
|
|
141
208
|
*/
|
|
142
209
|
export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
|
|
143
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Map a live exposure layer (`expose-state.json`) onto the wizard's
|
|
213
|
+
* `setup_expose_mode`. The two enums overlap exactly on the layers the
|
|
214
|
+
* exposure file can carry: `ExposeLayer` is `tailnet | public`, both of
|
|
215
|
+
* which are valid `SetupExposeMode` values. (There's no `localhost`
|
|
216
|
+
* exposure layer — running nothing is the absence of a state file, which
|
|
217
|
+
* `readExposeState` reports as `undefined`, so a missing/unexposed hub
|
|
218
|
+
* never seeds and the wizard still asks.)
|
|
219
|
+
*
|
|
220
|
+
* Returns `undefined` when no exposure is live (or the reader throws on
|
|
221
|
+
* a malformed file — we swallow that and fall through to "still ask"
|
|
222
|
+
* rather than crashing the wizard GET).
|
|
223
|
+
*/
|
|
224
|
+
function exposeModeFromLiveState(read: () => ExposeState | undefined): SetupExposeMode | undefined {
|
|
225
|
+
let state: ExposeState | undefined;
|
|
226
|
+
try {
|
|
227
|
+
state = read();
|
|
228
|
+
} catch {
|
|
229
|
+
// A corrupt expose-state.json shouldn't brick the wizard. Treat it
|
|
230
|
+
// as "no live exposure" and let the operator answer the step.
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (!state) return undefined;
|
|
234
|
+
// `ExposeLayer` ⊆ `SetupExposeMode` ("tailnet" | "public").
|
|
235
|
+
return state.layer;
|
|
236
|
+
}
|
|
237
|
+
|
|
144
238
|
/**
|
|
145
239
|
* Read DB + services.json to decide which step the wizard should render.
|
|
146
240
|
* Idempotent — re-running after partial setup picks up where it left
|
|
147
241
|
* off. Mostly read-only, with one specific write: on Render (or any
|
|
148
|
-
* platform `detectAutoExposeMode` recognizes),
|
|
149
|
-
*
|
|
150
|
-
*
|
|
242
|
+
* platform `detectAutoExposeMode` recognizes), OR when a live tailscale
|
|
243
|
+
* exposure (`expose-state.json`) is already up, the first call auto-
|
|
244
|
+
* seeds `setup_expose_mode` so the wizard skips the expose step.
|
|
245
|
+
* Subsequent calls find the setting present and are read-only.
|
|
151
246
|
*/
|
|
152
247
|
export function deriveWizardState(deps: {
|
|
153
248
|
db: Database;
|
|
@@ -158,13 +253,28 @@ export function deriveWizardState(deps: {
|
|
|
158
253
|
* SetupWizardDeps.env.
|
|
159
254
|
*/
|
|
160
255
|
env?: Record<string, string | undefined>;
|
|
256
|
+
/**
|
|
257
|
+
* Optional injected reader for the live exposure state
|
|
258
|
+
* (`~/.parachute/expose-state.json`). Defaults to the real
|
|
259
|
+
* `readExposeState`. Mirrors the `init.ts` seam (`readExposeStateFn`)
|
|
260
|
+
* so tests can drive the "a tailnet layer is already live" branch
|
|
261
|
+
* without writing a real state file. When `setup_expose_mode` is
|
|
262
|
+
* unset, the live exposure layer auto-seeds the setting (see below).
|
|
263
|
+
*/
|
|
264
|
+
readExposeStateFn?: () => ExposeState | undefined;
|
|
161
265
|
}): DerivedWizardState {
|
|
162
266
|
const hasAdmin = userCount(deps.db) > 0;
|
|
163
267
|
// The wizard's first-vault provisioning uses the curated `vault` short,
|
|
164
268
|
// which maps to `parachute-vault` in services.json.
|
|
165
269
|
const vaultSpec = specFor(FIRST_VAULT_SHORT);
|
|
166
270
|
const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
|
|
167
|
-
|
|
271
|
+
// hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
|
|
272
|
+
// past the vault step even when no vault row exists. The operator
|
|
273
|
+
// explicitly chose Skip; the module is installed (Cut 1) but no
|
|
274
|
+
// instance was provisioned. Treat as "vault step is done" for the
|
|
275
|
+
// purposes of state-derivation so the wizard moves to expose.
|
|
276
|
+
const vaultSkipped = getSetting(deps.db, "setup_vault_skipped") === "true";
|
|
277
|
+
const hasVault = vaultEntry !== undefined || vaultSkipped;
|
|
168
278
|
// Expose-mode is the operator's "how will this hub be reached?" answer
|
|
169
279
|
// (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
|
|
170
280
|
// sets it; absence means we should still ask. EXCEPT — if we're
|
|
@@ -180,6 +290,23 @@ export function deriveWizardState(deps: {
|
|
|
180
290
|
) {
|
|
181
291
|
setSetting(deps.db, "setup_expose_mode", "public");
|
|
182
292
|
}
|
|
293
|
+
// hub#406 team-onboarding bug: `setup_expose_mode` (the wizard's
|
|
294
|
+
// answer) and `expose-state.json` (the live tailscale exposure) are
|
|
295
|
+
// orthogonal axes. An operator who ran `parachute expose tailnet`
|
|
296
|
+
// before opening the wizard has a live tailnet layer but no
|
|
297
|
+
// `setup_expose_mode` setting — so the wizard re-asked "how will this
|
|
298
|
+
// hub be reached?" even though tailnet was already up. Auto-seed the
|
|
299
|
+
// setting from the live exposure layer (tailnet→"tailnet",
|
|
300
|
+
// public→"public") so the answered-by-action case is treated as
|
|
301
|
+
// satisfied, mirroring the Render/Fly auto-seed above. Reading the
|
|
302
|
+
// live state is injected for testability (defaults to the real
|
|
303
|
+
// reader); a malformed/missing file falls through to "still ask."
|
|
304
|
+
if (getSetting(deps.db, "setup_expose_mode") === undefined) {
|
|
305
|
+
const seeded = exposeModeFromLiveState(deps.readExposeStateFn ?? readExposeState);
|
|
306
|
+
if (seeded !== undefined) {
|
|
307
|
+
setSetting(deps.db, "setup_expose_mode", seeded);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
183
310
|
const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
|
|
184
311
|
let step: WizardStep;
|
|
185
312
|
// Note: `"account"` is a visual-only step in the progress header —
|
|
@@ -242,6 +369,14 @@ export interface SetupWizardDeps {
|
|
|
242
369
|
* without mutating the real process env.
|
|
243
370
|
*/
|
|
244
371
|
env?: Record<string, string | undefined>;
|
|
372
|
+
/**
|
|
373
|
+
* Test seam: inject the live-exposure reader `deriveWizardState`
|
|
374
|
+
* consults to auto-seed `setup_expose_mode` from a live
|
|
375
|
+
* `parachute expose tailnet|public` (hub#406). Production omits this
|
|
376
|
+
* and the real `readExposeState` is used. Mirrors `init.ts`'s
|
|
377
|
+
* `readExposeStateFn` seam.
|
|
378
|
+
*/
|
|
379
|
+
readExposeStateFn?: () => ExposeState | undefined;
|
|
245
380
|
}
|
|
246
381
|
|
|
247
382
|
/**
|
|
@@ -261,7 +396,9 @@ export interface SetupWizardDeps {
|
|
|
261
396
|
* (RAILWAY_ENVIRONMENT), DigitalOcean App Platform (DIGITALOCEAN_APP_*),
|
|
262
397
|
* etc. Each only auto-detects when the platform clearly owns the public URL.
|
|
263
398
|
*/
|
|
264
|
-
export function detectAutoExposeMode(
|
|
399
|
+
export function detectAutoExposeMode(
|
|
400
|
+
env: Record<string, string | undefined>,
|
|
401
|
+
): "public" | undefined {
|
|
265
402
|
// Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
|
|
266
403
|
// any web service. `startsWith("https://")` is the precise shape; we
|
|
267
404
|
// also accept `http://` as a defensive fallback in case Render ever
|
|
@@ -372,7 +509,11 @@ export interface RenderAccountStepProps {
|
|
|
372
509
|
export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
373
510
|
const { csrfToken, errorMessage, username, requireBootstrapToken, bootstrapToken } = props;
|
|
374
511
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
375
|
-
|
|
512
|
+
// Pre-fill "owner" on a fresh render (no prior submission) so the web wizard's
|
|
513
|
+
// default matches the CLI paths (`set-password`, `setup-wizard`) + the
|
|
514
|
+
// operator.token convention. Operators can still type any name. On a
|
|
515
|
+
// validation-failure re-render we echo back what they typed instead.
|
|
516
|
+
const usernameAttr = ` value="${escapeAttr(username ?? "owner")}"`;
|
|
376
517
|
const tokenAttr = bootstrapToken ? ` value="${escapeAttr(bootstrapToken)}"` : "";
|
|
377
518
|
// Bootstrap-token field comes FIRST when required. An operator who
|
|
378
519
|
// missed the log line is stopped here rather than after filling
|
|
@@ -494,6 +635,14 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
494
635
|
const { csrfToken, errorMessage, operation, vaultName, cloudHost } = props;
|
|
495
636
|
if (operation) return renderVaultOpStep({ operation });
|
|
496
637
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
638
|
+
// hub#168 Cut 2: three-branch vault step. The browser form now sends
|
|
639
|
+
// `mode=create|import|skip` along with the existing vault_name. Defaults
|
|
640
|
+
// to create when nothing's selected (back-compat with pre-#168 form
|
|
641
|
+
// posts that didn't ship a mode field — still works through the same
|
|
642
|
+
// handler). The radio's `data-shows` attribute drives an inline
|
|
643
|
+
// <script> block that hides import-specific fields when create/skip
|
|
644
|
+
// is selected. No SPA bundle, no module deps — same posture as the
|
|
645
|
+
// existing scribe sub-form's mode-switching JS.
|
|
497
646
|
// hub#267: the typed name now flows end-to-end via
|
|
498
647
|
// `PARACHUTE_VAULT_NAME`. Vault#342 added the env var read on
|
|
499
648
|
// first-boot — hub spawns vault with the env var set and vault's
|
|
@@ -542,7 +691,25 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
542
691
|
${error}
|
|
543
692
|
<form method="POST" action="/admin/setup/vault" class="auth-form">
|
|
544
693
|
${renderCsrfHiddenInput(csrfToken)}
|
|
545
|
-
<
|
|
694
|
+
<fieldset class="vault-mode-block">
|
|
695
|
+
<legend class="field-label">How do you want to start?</legend>
|
|
696
|
+
<label class="vault-mode-option">
|
|
697
|
+
<input type="radio" name="mode" value="create" checked data-shows="name" />
|
|
698
|
+
<span class="vault-mode-title">Create a new vault</span>
|
|
699
|
+
<span class="vault-mode-desc">Start fresh. The wizard creates an empty vault under the name below.</span>
|
|
700
|
+
</label>
|
|
701
|
+
<label class="vault-mode-option">
|
|
702
|
+
<input type="radio" name="mode" value="import" data-shows="name,import" />
|
|
703
|
+
<span class="vault-mode-title">Import from a git repo</span>
|
|
704
|
+
<span class="vault-mode-desc">Clone a previously-exported vault from GitHub / GitLab / any HTTPS git remote.</span>
|
|
705
|
+
</label>
|
|
706
|
+
<label class="vault-mode-option">
|
|
707
|
+
<input type="radio" name="mode" value="skip" data-shows="" />
|
|
708
|
+
<span class="vault-mode-title">Skip — create a vault later</span>
|
|
709
|
+
<span class="vault-mode-desc">The vault module is installed; create or import a vault any time from the admin UI.</span>
|
|
710
|
+
</label>
|
|
711
|
+
</fieldset>
|
|
712
|
+
<label class="field vault-name-field">
|
|
546
713
|
<span class="field-label">Vault name</span>
|
|
547
714
|
<input type="text" name="vault_name"
|
|
548
715
|
autofocus minlength="2" maxlength="32"
|
|
@@ -552,9 +719,56 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
552
719
|
<span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
|
|
553
720
|
2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
|
|
554
721
|
</label>
|
|
722
|
+
<fieldset class="vault-import-block" style="display: none;">
|
|
723
|
+
<legend class="field-label">Import source</legend>
|
|
724
|
+
<label class="field">
|
|
725
|
+
<span class="field-label">Remote URL</span>
|
|
726
|
+
<input type="text" name="remote_url" spellcheck="false" autocomplete="off"
|
|
727
|
+
placeholder="https://github.com/you/your-vault.git" />
|
|
728
|
+
<span class="field-hint">HTTPS or SSH clone URL. The repo must be a Parachute vault export — i.e. it carries a <code>.parachute/vault.yaml</code> at the root.</span>
|
|
729
|
+
</label>
|
|
730
|
+
<label class="field">
|
|
731
|
+
<span class="field-label">Personal access token (optional)</span>
|
|
732
|
+
<input type="password" name="pat" autocomplete="off"
|
|
733
|
+
placeholder="ghp_… / glpat-… / etc." />
|
|
734
|
+
<span class="field-hint">Required for private repos. Used in-memory for this import only — not stored. Set up push credentials later from the vault's mirror settings.</span>
|
|
735
|
+
</label>
|
|
736
|
+
<label class="vault-mode-option">
|
|
737
|
+
<input type="radio" name="import_mode" value="merge" checked />
|
|
738
|
+
<span class="vault-mode-title">Merge into a fresh vault (default)</span>
|
|
739
|
+
<span class="vault-mode-desc">Recommended on a brand-new install — the vault starts empty, so merge is effectively "import everything."</span>
|
|
740
|
+
</label>
|
|
741
|
+
<label class="vault-mode-option">
|
|
742
|
+
<input type="radio" name="import_mode" value="replace" />
|
|
743
|
+
<span class="vault-mode-title">Replace (wipes any existing notes first)</span>
|
|
744
|
+
<span class="vault-mode-desc">Only useful if you re-ran the wizard on an existing vault. Otherwise picks the same shape as merge.</span>
|
|
745
|
+
</label>
|
|
746
|
+
</fieldset>
|
|
555
747
|
${renderScribeSubForm(cloudHost === true)}
|
|
556
|
-
<button type="submit" class="btn btn-primary">
|
|
748
|
+
<button type="submit" class="btn btn-primary">Continue</button>
|
|
557
749
|
</form>
|
|
750
|
+
<script>
|
|
751
|
+
(function () {
|
|
752
|
+
// Show/hide vault-name + import block based on the picked mode.
|
|
753
|
+
// The radio carries data-shows listing the visible block suffixes
|
|
754
|
+
// (name, import); the show/hide loop reads them and flips display
|
|
755
|
+
// on the matching block. Skip mode hides everything below the
|
|
756
|
+
// mode picker.
|
|
757
|
+
var radios = document.querySelectorAll('input[name="mode"]');
|
|
758
|
+
var nameField = document.querySelector('.vault-name-field');
|
|
759
|
+
var importBlock = document.querySelector('.vault-import-block');
|
|
760
|
+
function sync() {
|
|
761
|
+
var picked = document.querySelector('input[name="mode"]:checked');
|
|
762
|
+
var shows = picked ? (picked.dataset.shows || '') : '';
|
|
763
|
+
var nameVisible = shows.indexOf('name') !== -1;
|
|
764
|
+
var importVisible = shows.indexOf('import') !== -1;
|
|
765
|
+
if (nameField) nameField.style.display = nameVisible ? '' : 'none';
|
|
766
|
+
if (importBlock) importBlock.style.display = importVisible ? '' : 'none';
|
|
767
|
+
}
|
|
768
|
+
radios.forEach(function (r) { r.addEventListener('change', sync); });
|
|
769
|
+
sync();
|
|
770
|
+
})();
|
|
771
|
+
</script>
|
|
558
772
|
</div>`;
|
|
559
773
|
return baseDocument("Set up your Parachute hub — vault", body);
|
|
560
774
|
}
|
|
@@ -889,27 +1103,15 @@ export interface RenderDoneStepProps {
|
|
|
889
1103
|
* shape.
|
|
890
1104
|
*/
|
|
891
1105
|
installTiles?: readonly ModuleInstallTileState[];
|
|
892
|
-
/**
|
|
893
|
-
* Whether parachute-app is installed alongside the vault. Drives the
|
|
894
|
-
* "Start using your vault" lead tile (hub#342): when true, the tile
|
|
895
|
-
* links to `/surface/notes/` (the canonical user-facing surface — App
|
|
896
|
-
* auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
|
|
897
|
-
* false, it falls back to the vault's own admin UI at
|
|
898
|
-
* `/vault/<name>/admin/` so the operator still has a single obvious
|
|
899
|
-
* "start using parachute" target. Omitted = back-compat with tests
|
|
900
|
-
* that render the done step without dependency-checking; defaults to
|
|
901
|
-
* false (vault-admin fallback).
|
|
902
|
-
*/
|
|
903
|
-
appInstalled?: boolean;
|
|
904
1106
|
}
|
|
905
1107
|
|
|
906
1108
|
export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
907
|
-
const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles
|
|
1109
|
+
const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
|
|
908
1110
|
const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
|
|
909
1111
|
const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
|
|
910
1112
|
const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
|
|
911
1113
|
const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
|
|
912
|
-
const startTile = renderStartUsingTile(vaultName,
|
|
1114
|
+
const startTile = renderStartUsingTile(vaultName, hubOrigin);
|
|
913
1115
|
// The done-grid hosts the MCP-connect tile + the admin-UI fallback.
|
|
914
1116
|
// The install tiles sit above it as a "what's next?" surface (curated
|
|
915
1117
|
// catalog of modules an operator might want next). The "Start using
|
|
@@ -1097,14 +1299,18 @@ function renderMcpTile(
|
|
|
1097
1299
|
<h2>Connect Claude Code (MCP)</h2>
|
|
1098
1300
|
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
1099
1301
|
<pre>${escapeHtml(bareCmd)}</pre>
|
|
1100
|
-
<p class="fine">
|
|
1101
|
-
|
|
1102
|
-
|
|
1302
|
+
<p class="fine">No token needed — the command triggers browser OAuth on
|
|
1303
|
+
first use (you sign in to this hub and approve access). For headless
|
|
1304
|
+
clients that can't do the browser flow, mint a hub token at
|
|
1305
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a> (or with
|
|
1306
|
+
<code>parachute auth mint-token</code>) and append
|
|
1307
|
+
<code>--header "Authorization: Bearer <token>"</code>.</p>
|
|
1103
1308
|
</div>`;
|
|
1104
1309
|
}
|
|
1105
1310
|
|
|
1106
1311
|
/**
|
|
1107
|
-
* The "Start using your vault" lead tile on the done step (hub#342
|
|
1312
|
+
* The "Start using your vault" lead tile on the done step (hub#342,
|
|
1313
|
+
* Aaron 2026-05-27 simplification).
|
|
1108
1314
|
*
|
|
1109
1315
|
* Closes Aaron's "no clear way to go from setting up parachute to
|
|
1110
1316
|
* actually using parachute" friction. Sits above the MCP / install
|
|
@@ -1112,48 +1318,20 @@ function renderMcpTile(
|
|
|
1112
1318
|
* everything else on the done screen is operator-flavored (MCP
|
|
1113
1319
|
* command, admin UI, additional module installs).
|
|
1114
1320
|
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
* admin UI at `/vault/<name>/admin/`. The copy explains that
|
|
1121
|
-
* installing App + Notes is the recommended next step for a
|
|
1122
|
-
* content-browsing surface, and points at the install tile below.
|
|
1123
|
-
*
|
|
1124
|
-
* Either way, the operator has ONE obvious click target that says
|
|
1125
|
-
* "start using parachute" — not three competing tiles where the
|
|
1126
|
-
* "real" entry point is buried under the MCP command pre-hub#342.
|
|
1127
|
-
*/
|
|
1128
|
-
/**
|
|
1129
|
-
* Lead "Start using your vault" tile. Points at the canonical
|
|
1130
|
-
* notes.parachute.computer hosted PWA as the primary CTA — with the
|
|
1131
|
-
* operator's own hub URL pre-filled via `?url=` so the connect screen
|
|
1132
|
-
* auto-populates + auto-focuses (notes-ui AddVault route, see
|
|
1133
|
-
* parachute-app/packages/notes-ui/src/app/routes/AddVault.tsx).
|
|
1134
|
-
*
|
|
1135
|
-
* Aaron 2026-05-27 directive: "skipping the local surface install for
|
|
1136
|
-
* most operators is good ... showing notes.parachute.computer more
|
|
1137
|
-
* prominently is a good idea." The notes.parachute.computer PWA is the
|
|
1138
|
-
* canonical user-facing UI; operators no longer need to install the
|
|
1139
|
-
* Surface module locally to use Notes. They still can (local install
|
|
1140
|
-
* works the same way), but the wizard doesn't push them toward it as
|
|
1141
|
-
* the default.
|
|
1142
|
-
*
|
|
1321
|
+
* Points at the canonical notes.parachute.computer hosted PWA as the
|
|
1322
|
+
* primary CTA — with the operator's own hub URL pre-filled via
|
|
1323
|
+
* `?url=` so the connect screen auto-populates + auto-focuses
|
|
1324
|
+
* (notes-ui AddVault route, see
|
|
1325
|
+
* parachute-surface/packages/notes-ui/src/app/routes/AddVault.tsx).
|
|
1143
1326
|
* Secondary CTA: "Open vault admin" (the vault's own admin UI on this
|
|
1144
1327
|
* hub) for operators who want to look at raw vault state.
|
|
1145
1328
|
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
* differs based on it.
|
|
1329
|
+
* Previously varied by whether `parachute-surface` was installed
|
|
1330
|
+
* locally — pointing at `/surface/notes/` in that case. Dropped
|
|
1331
|
+
* 2026-05-27: hub+vault+scribe is the focus; notes.parachute.computer
|
|
1332
|
+
* is canonical regardless of local surface install state.
|
|
1151
1333
|
*/
|
|
1152
|
-
function renderStartUsingTile(
|
|
1153
|
-
vaultName: string,
|
|
1154
|
-
appInstalled: boolean,
|
|
1155
|
-
hubOrigin: string,
|
|
1156
|
-
): string {
|
|
1334
|
+
function renderStartUsingTile(vaultName: string, hubOrigin: string): string {
|
|
1157
1335
|
const safeVault = escapeHtml(vaultName);
|
|
1158
1336
|
// Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
|
|
1159
1337
|
// a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
|
|
@@ -1161,17 +1339,7 @@ function renderStartUsingTile(
|
|
|
1161
1339
|
// The `?url=` query param is consumed by notes-ui's AddVault route
|
|
1162
1340
|
// (packages/notes-ui/src/app/routes/AddVault.tsx) — it pre-fills the
|
|
1163
1341
|
// vault URL input + auto-focuses Submit.
|
|
1164
|
-
const vaultUrlForAdd = encodeURIComponent(
|
|
1165
|
-
`${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`,
|
|
1166
|
-
);
|
|
1167
|
-
// For appInstalled=false case (Surface NOT installed locally),
|
|
1168
|
-
// notes.parachute.computer is the recommended path. For appInstalled=true,
|
|
1169
|
-
// we mention the local option as a secondary affordance.
|
|
1170
|
-
const localNotesFallback = appInstalled
|
|
1171
|
-
? `<p class="start-using-secondary">
|
|
1172
|
-
<a href="/surface/notes/">Or use Notes installed locally on this hub →</a>
|
|
1173
|
-
</p>`
|
|
1174
|
-
: "";
|
|
1342
|
+
const vaultUrlForAdd = encodeURIComponent(`${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`);
|
|
1175
1343
|
return `<section class="start-using" data-testid="start-using-tile">
|
|
1176
1344
|
<h2>Start using your vault</h2>
|
|
1177
1345
|
<p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
|
|
@@ -1180,7 +1348,6 @@ function renderStartUsingTile(
|
|
|
1180
1348
|
<p class="start-using-secondary">
|
|
1181
1349
|
<a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
|
|
1182
1350
|
</p>
|
|
1183
|
-
${localNotesFallback}
|
|
1184
1351
|
</section>`;
|
|
1185
1352
|
}
|
|
1186
1353
|
|
|
@@ -1323,13 +1490,12 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
|
1323
1490
|
* surface decision.
|
|
1324
1491
|
*/
|
|
1325
1492
|
const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
|
|
1326
|
-
|
|
1327
|
-
notes
|
|
1328
|
-
//
|
|
1329
|
-
// (
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
// Add the entry here once those modules ship their admin UI.
|
|
1493
|
+
// Empty: vault has its own lead "Start using" tile (the
|
|
1494
|
+
// notes.parachute.computer CTA), so it doesn't appear here. Scribe
|
|
1495
|
+
// doesn't ship an admin SPA at /scribe/admin/ that's useful for
|
|
1496
|
+
// first-time operators (the page exists but it's config-management;
|
|
1497
|
+
// not "use it"). Re-add per-module entries here if/when a module
|
|
1498
|
+
// ships a user-facing landing surface worth pointing at.
|
|
1333
1499
|
};
|
|
1334
1500
|
|
|
1335
1501
|
/**
|
|
@@ -1406,6 +1572,29 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1406
1572
|
const url = new URL(req.url);
|
|
1407
1573
|
const state = deriveWizardState(deps);
|
|
1408
1574
|
const csrf = ensureCsrfToken(req);
|
|
1575
|
+
const wantsJson = wantsJsonResponse(req);
|
|
1576
|
+
// CLI wizard surface (hub#168 Cut 3): the GET endpoint doubles as a
|
|
1577
|
+
// state-probe API. Same state-derivation, same DB read; only the
|
|
1578
|
+
// response shape forks on Accept. Returning the JSON envelope before
|
|
1579
|
+
// the HTML rendering branches means the CLI gets the answer it needs
|
|
1580
|
+
// without the wizard having to render a 30KB HTML page per poll.
|
|
1581
|
+
if (wantsJson) {
|
|
1582
|
+
const requireToken = getBootstrapToken() !== undefined;
|
|
1583
|
+
const envelope = {
|
|
1584
|
+
step: state.step,
|
|
1585
|
+
hasAdmin: state.hasAdmin,
|
|
1586
|
+
hasVault: state.hasVault,
|
|
1587
|
+
hasExposeMode: state.hasExposeMode,
|
|
1588
|
+
requireBootstrapToken: requireToken,
|
|
1589
|
+
csrfToken: csrf.token,
|
|
1590
|
+
};
|
|
1591
|
+
const jsonHeaders: Record<string, string> = {
|
|
1592
|
+
"content-type": "application/json; charset=utf-8",
|
|
1593
|
+
"cache-control": "no-store",
|
|
1594
|
+
};
|
|
1595
|
+
if (csrf.setCookie) jsonHeaders["set-cookie"] = csrf.setCookie;
|
|
1596
|
+
return new Response(JSON.stringify(envelope), { status: 200, headers: jsonHeaders });
|
|
1597
|
+
}
|
|
1409
1598
|
const extraHeaders: Record<string, string> = {
|
|
1410
1599
|
"content-type": "text/html; charset=utf-8",
|
|
1411
1600
|
};
|
|
@@ -1482,16 +1671,18 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1482
1671
|
// Module install tiles (hub#272 Item B). One per curated module
|
|
1483
1672
|
// other than vault (which the wizard already provisioned).
|
|
1484
1673
|
const installTiles = buildInstallTiles(url, deps);
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
|
|
1674
|
+
// The lead "Start using your vault" tile points at
|
|
1675
|
+
// notes.parachute.computer/add — always, regardless of any
|
|
1676
|
+
// local module install state. Prior versions of this code
|
|
1677
|
+
// checked `isModuleInstalled("surface", ...)` to switch to a
|
|
1678
|
+
// local `/surface/notes/` link, but the launch focus is
|
|
1679
|
+
// hub+vault+scribe and notes.parachute.computer is the
|
|
1680
|
+
// canonical Notes UI (Aaron-directed 2026-05-27). Dropped the
|
|
1681
|
+
// local-fallback branch.
|
|
1490
1682
|
const doneProps: RenderDoneStepProps = {
|
|
1491
1683
|
vaultName,
|
|
1492
1684
|
hubOrigin: deps.issuer,
|
|
1493
1685
|
installTiles,
|
|
1494
|
-
appInstalled,
|
|
1495
1686
|
};
|
|
1496
1687
|
if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
|
|
1497
1688
|
if (mintedToken) doneProps.mintedToken = mintedToken;
|
|
@@ -1590,9 +1781,19 @@ export async function handleSetupAccountPost(
|
|
|
1590
1781
|
req: Request,
|
|
1591
1782
|
deps: SetupWizardDeps,
|
|
1592
1783
|
): Promise<Response> {
|
|
1593
|
-
const form = await req
|
|
1784
|
+
const form = await readBodyFields(req);
|
|
1785
|
+
// JSON callers (CLI wizard, hub#168 Cut 3) generally don't have a
|
|
1786
|
+
// pre-existing CSRF cookie because the GET that returned the JSON
|
|
1787
|
+
// envelope just set one — the CLI's fetch is the first request and
|
|
1788
|
+
// the verifyCsrfToken's double-submit check needs the cookie + body
|
|
1789
|
+
// value to match. The wizard's GET surface sets the cookie; the CLI
|
|
1790
|
+
// reads it back from `Set-Cookie` and threads it on subsequent POSTs,
|
|
1791
|
+
// matching the browser behavior. CSRF verification is shared.
|
|
1594
1792
|
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1595
1793
|
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1794
|
+
if (form.isJson) {
|
|
1795
|
+
return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
|
|
1796
|
+
}
|
|
1596
1797
|
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1597
1798
|
}
|
|
1598
1799
|
// Already-bootstrapped: bounce. The wizard's GET state will resolve to
|
|
@@ -1605,10 +1806,16 @@ export async function handleSetupAccountPost(
|
|
|
1605
1806
|
const requireToken = getBootstrapToken() !== undefined;
|
|
1606
1807
|
if (userCount(deps.db) > 0) {
|
|
1607
1808
|
if (!requireToken) {
|
|
1809
|
+
if (form.isJson) {
|
|
1810
|
+
return jsonOkResponse({ step: "vault", message: "admin already exists" });
|
|
1811
|
+
}
|
|
1608
1812
|
return redirect("/admin/setup");
|
|
1609
1813
|
}
|
|
1610
1814
|
// Defense in depth: a token was active but an admin already exists.
|
|
1611
1815
|
// Treat as consumed.
|
|
1816
|
+
if (form.isJson) {
|
|
1817
|
+
return jsonErrorResponse(410, "Admin already claimed", "Bootstrap token was already used.");
|
|
1818
|
+
}
|
|
1612
1819
|
return new Response(renderClaimAlreadyHappenedPage(), {
|
|
1613
1820
|
status: 410,
|
|
1614
1821
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
@@ -1622,6 +1829,13 @@ export async function handleSetupAccountPost(
|
|
|
1622
1829
|
if (requireToken) {
|
|
1623
1830
|
const suppliedToken = String(form.get("bootstrap_token") ?? "").trim();
|
|
1624
1831
|
if (!verifyBootstrapToken(suppliedToken)) {
|
|
1832
|
+
if (form.isJson) {
|
|
1833
|
+
return jsonErrorResponse(
|
|
1834
|
+
401,
|
|
1835
|
+
"Bootstrap token rejected",
|
|
1836
|
+
"Re-check the `parachute-bootstrap-…` line in your hub's startup logs.",
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1625
1839
|
const username = String(form.get("username") ?? "").trim();
|
|
1626
1840
|
return htmlResponse(
|
|
1627
1841
|
renderAccountStep({
|
|
@@ -1643,6 +1857,9 @@ export async function handleSetupAccountPost(
|
|
|
1643
1857
|
const confirm = String(form.get("password_confirm") ?? "");
|
|
1644
1858
|
const fieldErr = validateAccountFields({ username, password, confirm });
|
|
1645
1859
|
if (fieldErr) {
|
|
1860
|
+
if (form.isJson) {
|
|
1861
|
+
return jsonErrorResponse(400, "Invalid account fields", fieldErr);
|
|
1862
|
+
}
|
|
1646
1863
|
return htmlResponse(
|
|
1647
1864
|
renderAccountStep({
|
|
1648
1865
|
csrfToken,
|
|
@@ -1675,6 +1892,9 @@ export async function handleSetupAccountPost(
|
|
|
1675
1892
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1676
1893
|
secure: isHttpsRequest(req),
|
|
1677
1894
|
});
|
|
1895
|
+
if (form.isJson) {
|
|
1896
|
+
return jsonOkResponse({ step: "vault", message: "admin created" }, { "set-cookie": cookie });
|
|
1897
|
+
}
|
|
1678
1898
|
return redirect("/admin/setup", { "set-cookie": cookie });
|
|
1679
1899
|
} catch (err) {
|
|
1680
1900
|
// Log the raw error server-side for the operator's debugging, but
|
|
@@ -1688,6 +1908,13 @@ export async function handleSetupAccountPost(
|
|
|
1688
1908
|
// shell.
|
|
1689
1909
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1690
1910
|
console.warn(`[setup-wizard] createUser failed for "${username}": ${msg}`);
|
|
1911
|
+
if (form.isJson) {
|
|
1912
|
+
return jsonErrorResponse(
|
|
1913
|
+
400,
|
|
1914
|
+
"Account creation failed",
|
|
1915
|
+
"Failed to create account. The username may already be taken.",
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1691
1918
|
return htmlResponse(
|
|
1692
1919
|
renderAccountStep({
|
|
1693
1920
|
csrfToken,
|
|
@@ -1729,7 +1956,26 @@ function renderClaimAlreadyHappenedPage(): string {
|
|
|
1729
1956
|
}
|
|
1730
1957
|
|
|
1731
1958
|
/**
|
|
1732
|
-
* POST `/admin/setup/vault`.
|
|
1959
|
+
* POST `/admin/setup/vault`. Accepts `application/x-www-form-urlencoded`
|
|
1960
|
+
* (browser) and `application/json` (CLI wizard).
|
|
1961
|
+
*
|
|
1962
|
+
* Three modes (hub#168 Cut 2 — Aaron's 2026-05-28 directive): `mode`
|
|
1963
|
+
* field is the discriminant.
|
|
1964
|
+
* * `create` (default if absent — back-compat with the pre-hub#168
|
|
1965
|
+
* browser flow that didn't send `mode`): provision a new vault under
|
|
1966
|
+
* the typed name.
|
|
1967
|
+
* * `import`: provision an empty vault under the typed name, then
|
|
1968
|
+
* POST to vault's `/vault/<name>/.parachute/mirror/import` endpoint
|
|
1969
|
+
* with the supplied remote URL + optional PAT. Surfaces import
|
|
1970
|
+
* progress through the same op-poll machinery used by the create
|
|
1971
|
+
* path.
|
|
1972
|
+
* * `skip`: don't create or import anything. The wizard advances to
|
|
1973
|
+
* the expose step. The "vault module installed" signal is still
|
|
1974
|
+
* true (init.ts pre-installed it under hub#168 Cut 1), but no
|
|
1975
|
+
* instance exists — `deriveWizardState`'s `hasVault` reflects the
|
|
1976
|
+
* services.json shape, which `skip` leaves untouched. To make the
|
|
1977
|
+
* wizard *advance past* the vault step on skip we persist a
|
|
1978
|
+
* `setup_vault_skipped` flag that `deriveWizardState` consults.
|
|
1733
1979
|
*
|
|
1734
1980
|
* Gated by the admin session cookie set at step 2 — a stale tab without
|
|
1735
1981
|
* the cookie won't accidentally try to provision a vault. The session is
|
|
@@ -1737,31 +1983,33 @@ function renderClaimAlreadyHappenedPage(): string {
|
|
|
1737
1983
|
* same one driving step 3 (they're necessarily the only user in
|
|
1738
1984
|
* single-user mode).
|
|
1739
1985
|
*
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
* gated on session + on "no vault exists yet," so a separate
|
|
1744
|
-
* bearer-mint dance would be pure ceremony.
|
|
1745
|
-
*
|
|
1746
|
-
* Returns a 303-redirect to `/admin/setup?op=<id>` so the wizard's
|
|
1747
|
-
* polling GET shape kicks in. The actual `bun add` runs in the
|
|
1748
|
-
* background; failures surface in the op log.
|
|
1986
|
+
* Browser shape: returns 303 to `/admin/setup?op=<id>` (create/import) or
|
|
1987
|
+
* `/admin/setup` (skip).
|
|
1988
|
+
* CLI shape: returns 200 JSON `{ op_id?, step }`.
|
|
1749
1989
|
*/
|
|
1750
1990
|
export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps): Promise<Response> {
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
}
|
|
1758
|
-
const form = await req.formData();
|
|
1991
|
+
// Note: supervisor gate moved BELOW the mode check (hub#168 Cut 2) so
|
|
1992
|
+
// that `mode=skip` doesn't fail on the CLI hub surface (which doesn't
|
|
1993
|
+
// wire a supervisor — operators install vault via `parachute install
|
|
1994
|
+
// vault` on the on-box CLI path; the wizard's role there is the
|
|
1995
|
+
// account + skip + expose decisions only).
|
|
1996
|
+
const form = await readBodyFields(req);
|
|
1759
1997
|
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1760
1998
|
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1999
|
+
if (form.isJson) {
|
|
2000
|
+
return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
|
|
2001
|
+
}
|
|
1761
2002
|
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1762
2003
|
}
|
|
1763
2004
|
const session = findActiveSession(deps.db, req);
|
|
1764
2005
|
if (!session) {
|
|
2006
|
+
if (form.isJson) {
|
|
2007
|
+
return jsonErrorResponse(
|
|
2008
|
+
401,
|
|
2009
|
+
"No admin session",
|
|
2010
|
+
"Sign in to continue setup. The session cookie was set on step 2.",
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
1765
2013
|
return badRequestPage(
|
|
1766
2014
|
"No admin session",
|
|
1767
2015
|
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
@@ -1769,7 +2017,66 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1769
2017
|
}
|
|
1770
2018
|
// Already done — short-circuit to the done step.
|
|
1771
2019
|
const state = deriveWizardState(deps);
|
|
1772
|
-
if (state.hasVault)
|
|
2020
|
+
if (state.hasVault) {
|
|
2021
|
+
if (form.isJson) {
|
|
2022
|
+
return jsonOkResponse({ step: "expose", message: "vault already provisioned" });
|
|
2023
|
+
}
|
|
2024
|
+
return redirect("/admin/setup?just_finished=1");
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Mode discriminant (hub#168 Cut 2). Default is "create" for back-
|
|
2028
|
+
// compat with the existing browser form — it doesn't send `mode`.
|
|
2029
|
+
const rawMode = String(form.get("mode") ?? "create").trim();
|
|
2030
|
+
if (rawMode !== "create" && rawMode !== "import" && rawMode !== "skip") {
|
|
2031
|
+
if (form.isJson) {
|
|
2032
|
+
return jsonErrorResponse(
|
|
2033
|
+
400,
|
|
2034
|
+
"Invalid vault mode",
|
|
2035
|
+
`mode must be one of create, import, skip (got "${rawMode}")`,
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
return badRequestPage("Invalid vault mode", "mode must be one of create, import, skip.");
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// Skip path (hub#168 Cut 2): module is already installed (init.ts
|
|
2042
|
+
// ran `install vault --no-create`); we just persist a flag that
|
|
2043
|
+
// `deriveWizardState` consults to skip the vault step on subsequent
|
|
2044
|
+
// GETs. No supervisor work, no op_id — runs without the supervisor.
|
|
2045
|
+
if (rawMode === "skip") {
|
|
2046
|
+
setSetting(deps.db, "setup_vault_skipped", "true");
|
|
2047
|
+
if (form.isJson) {
|
|
2048
|
+
return jsonOkResponse({ step: "expose", message: "vault step skipped" });
|
|
2049
|
+
}
|
|
2050
|
+
return redirect("/admin/setup");
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Operator picked create or import — if they previously skipped (in
|
|
2054
|
+
// another tab / via back button), the skip flag would still claim
|
|
2055
|
+
// "vault step done" even after the vault row appears. Clear it
|
|
2056
|
+
// defensively so `deriveWizardState` consults the real vault entry
|
|
2057
|
+
// going forward.
|
|
2058
|
+
deleteSetting(deps.db, "setup_vault_skipped");
|
|
2059
|
+
|
|
2060
|
+
// Create / import paths need the supervisor — they spawn vault and
|
|
2061
|
+
// (for import) call vault's mirror endpoint. The CLI hub surface
|
|
2062
|
+
// doesn't wire a supervisor; operators are expected to use
|
|
2063
|
+
// `parachute install vault` directly there. Container/serve-mode
|
|
2064
|
+
// hub has one.
|
|
2065
|
+
if (!deps.supervisor) {
|
|
2066
|
+
if (form.isJson) {
|
|
2067
|
+
return jsonErrorResponse(
|
|
2068
|
+
503,
|
|
2069
|
+
"Module supervisor unavailable",
|
|
2070
|
+
"The wizard's create/import paths need container-mode `parachute serve` to spawn vault. " +
|
|
2071
|
+
"On the on-box CLI surface, run `parachute install vault` first, then re-run the wizard with --vault-mode skip.",
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
return badRequestPage(
|
|
2075
|
+
"Module supervisor unavailable",
|
|
2076
|
+
"The first-boot wizard needs container-mode `parachute serve` to install modules. " +
|
|
2077
|
+
"On the on-box CLI surface, run `parachute install vault` directly.",
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
1773
2080
|
|
|
1774
2081
|
// hub#267: the operator-typed vault name is now threaded all the way
|
|
1775
2082
|
// through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
|
|
@@ -1784,6 +2091,9 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1784
2091
|
} else {
|
|
1785
2092
|
const v = validateVaultName(rawName);
|
|
1786
2093
|
if (!v.ok) {
|
|
2094
|
+
if (form.isJson) {
|
|
2095
|
+
return jsonErrorResponse(400, "Invalid vault name", v.error);
|
|
2096
|
+
}
|
|
1787
2097
|
return htmlResponse(
|
|
1788
2098
|
renderVaultStep({
|
|
1789
2099
|
csrfToken: csrfTokenStr,
|
|
@@ -1795,6 +2105,51 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1795
2105
|
}
|
|
1796
2106
|
vaultName = v.name;
|
|
1797
2107
|
}
|
|
2108
|
+
|
|
2109
|
+
// Import path (hub#168 Cut 2): collect the remote URL + optional PAT
|
|
2110
|
+
// + replace flag up front so a malformed input fails fast before we
|
|
2111
|
+
// spawn the vault. The actual import POST to vault's
|
|
2112
|
+
// `/vault/<name>/.parachute/mirror/import` happens AFTER vault has
|
|
2113
|
+
// come up under the supervisor; the params are captured by closure
|
|
2114
|
+
// into the post-install `.then()` (see `importToRun` below).
|
|
2115
|
+
let importParams: { remoteUrl: string; pat?: string; mode: "merge" | "replace" } | undefined;
|
|
2116
|
+
if (rawMode === "import") {
|
|
2117
|
+
const remoteUrl = String(form.get("remote_url") ?? "").trim();
|
|
2118
|
+
if (remoteUrl === "") {
|
|
2119
|
+
if (form.isJson) {
|
|
2120
|
+
return jsonErrorResponse(
|
|
2121
|
+
400,
|
|
2122
|
+
"Remote URL required",
|
|
2123
|
+
'remote_url must be a non-empty HTTPS or SSH clone URL when mode="import".',
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
return htmlResponse(
|
|
2127
|
+
renderVaultStep({
|
|
2128
|
+
csrfToken: csrfTokenStr,
|
|
2129
|
+
vaultName: rawName,
|
|
2130
|
+
errorMessage: "Remote URL is required to import a vault. Paste a git clone URL.",
|
|
2131
|
+
}),
|
|
2132
|
+
400,
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
const importMode = String(form.get("import_mode") ?? "merge").trim();
|
|
2136
|
+
if (importMode !== "merge" && importMode !== "replace") {
|
|
2137
|
+
const err = `import_mode must be "merge" or "replace" (got "${importMode}").`;
|
|
2138
|
+
if (form.isJson) {
|
|
2139
|
+
return jsonErrorResponse(400, "Invalid import_mode", err);
|
|
2140
|
+
}
|
|
2141
|
+
return htmlResponse(
|
|
2142
|
+
renderVaultStep({ csrfToken: csrfTokenStr, vaultName: rawName, errorMessage: err }),
|
|
2143
|
+
400,
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
const pat = String(form.get("pat") ?? "").trim();
|
|
2147
|
+
importParams = {
|
|
2148
|
+
remoteUrl,
|
|
2149
|
+
mode: importMode,
|
|
2150
|
+
...(pat ? { pat } : {}),
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
1798
2153
|
// Persist for the done-step renderer. Vault overwrites services.json
|
|
1799
2154
|
// on its first authoritative boot, but until that completes the wizard
|
|
1800
2155
|
// needs a stable source of truth for the typed name — both for the
|
|
@@ -1827,8 +2182,14 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1827
2182
|
{ status: "succeeded" },
|
|
1828
2183
|
`${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
|
|
1829
2184
|
);
|
|
2185
|
+
if (form.isJson) {
|
|
2186
|
+
return jsonOkResponse({ op_id: op.id, step: "vault", message: "vault already supervised" });
|
|
2187
|
+
}
|
|
1830
2188
|
return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
|
|
1831
2189
|
}
|
|
2190
|
+
if (form.isJson) {
|
|
2191
|
+
return jsonOkResponse({ step: "vault", message: "vault already supervised" });
|
|
2192
|
+
}
|
|
1832
2193
|
return redirect("/admin/setup");
|
|
1833
2194
|
}
|
|
1834
2195
|
|
|
@@ -1851,6 +2212,17 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1851
2212
|
if (vaultName !== DEFAULT_VAULT_NAME) {
|
|
1852
2213
|
spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
|
|
1853
2214
|
}
|
|
2215
|
+
// Capture importParams + deps in the runInstall promise chain — when
|
|
2216
|
+
// mode === "import", run the vault-side `/.parachute/mirror/import`
|
|
2217
|
+
// POST as a follow-up step once the supervised vault has come up
|
|
2218
|
+
// and confirmed healthy. The hub-side op_id stays the same so the
|
|
2219
|
+
// CLI / browser sees a single progress stream; we just append more
|
|
2220
|
+
// log lines while the import runs. On import error, the op is
|
|
2221
|
+
// marked failed so the caller surfaces a usable message.
|
|
2222
|
+
const importToRun = importParams;
|
|
2223
|
+
const vaultIssuer = deps.issuer;
|
|
2224
|
+
const importerUserId = session.userId;
|
|
2225
|
+
const vaultPort = vaultSpec.seedEntry?.().port ?? 1940;
|
|
1854
2226
|
void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
|
|
1855
2227
|
db: deps.db,
|
|
1856
2228
|
issuer: deps.issuer,
|
|
@@ -1861,10 +2233,61 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1861
2233
|
...(deps.run ? { run: deps.run } : {}),
|
|
1862
2234
|
...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
|
|
1863
2235
|
...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
|
|
1864
|
-
})
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2236
|
+
})
|
|
2237
|
+
.then(async () => {
|
|
2238
|
+
if (!importToRun) return;
|
|
2239
|
+
const opState = registry.get(op.id);
|
|
2240
|
+
if (!opState || opState.status !== "succeeded") return;
|
|
2241
|
+
// Import is a follow-up step: mark op back to running, POST to
|
|
2242
|
+
// vault, surface the result in the op log.
|
|
2243
|
+
registry.update(
|
|
2244
|
+
op.id,
|
|
2245
|
+
{ status: "running" },
|
|
2246
|
+
`vault up — starting import from ${importToRun.remoteUrl} (mode=${importToRun.mode})`,
|
|
2247
|
+
);
|
|
2248
|
+
try {
|
|
2249
|
+
// Mint a short-lived per-vault admin Bearer for the import POST.
|
|
2250
|
+
// Vault validates audience `vault.<name>` + scope `vault:<name>:admin`
|
|
2251
|
+
// (see admin-vault-admin-token.ts for the canonical shape — same
|
|
2252
|
+
// contract the SPA Manage link uses). The token only needs to
|
|
2253
|
+
// live until vault accepts the HTTP request (the clone itself
|
|
2254
|
+
// happens inside vault after the auth check passes); 5 min is
|
|
2255
|
+
// a generous safety net covering the supervisor's boot-grace
|
|
2256
|
+
// retries on a sluggish host. Deliberate divergence from the
|
|
2257
|
+
// SPA's 10-min TTL because this token is one-shot, not refreshed.
|
|
2258
|
+
const minted = await signAccessToken(deps.db, {
|
|
2259
|
+
sub: importerUserId,
|
|
2260
|
+
scopes: [`vault:${vaultName}:admin`],
|
|
2261
|
+
audience: `vault.${vaultName}`,
|
|
2262
|
+
clientId: "parachute-hub-setup-wizard",
|
|
2263
|
+
issuer: vaultIssuer,
|
|
2264
|
+
ttlSeconds: 5 * 60,
|
|
2265
|
+
vaultScope: [vaultName],
|
|
2266
|
+
});
|
|
2267
|
+
const result = await postVaultImportImpl({
|
|
2268
|
+
vaultName,
|
|
2269
|
+
vaultPort,
|
|
2270
|
+
bearerToken: minted.token,
|
|
2271
|
+
remoteUrl: importToRun.remoteUrl,
|
|
2272
|
+
mode: importToRun.mode,
|
|
2273
|
+
...(importToRun.pat ? { pat: importToRun.pat } : {}),
|
|
2274
|
+
});
|
|
2275
|
+
registry.update(
|
|
2276
|
+
op.id,
|
|
2277
|
+
{ status: "succeeded" },
|
|
2278
|
+
`import succeeded — notes_imported=${result.notes_imported ?? 0}, tags_imported=${
|
|
2279
|
+
result.tags_imported ?? 0
|
|
2280
|
+
}, attachments_imported=${result.attachments_imported ?? 0}`,
|
|
2281
|
+
);
|
|
2282
|
+
} catch (err) {
|
|
2283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2284
|
+
registry.update(op.id, { status: "failed", error: msg }, `import failed: ${msg}`);
|
|
2285
|
+
}
|
|
2286
|
+
})
|
|
2287
|
+
.catch((err) => {
|
|
2288
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2289
|
+
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
2290
|
+
});
|
|
1868
2291
|
} else {
|
|
1869
2292
|
// No registry wired (test-only path; production always passes one).
|
|
1870
2293
|
// Log a visible warning so future mis-wirings are debuggable —
|
|
@@ -1941,9 +2364,105 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
1941
2364
|
const redirectUrl = scribeOpId
|
|
1942
2365
|
? `/admin/setup?op=${encodeURIComponent(op.id)}&op_scribe=${encodeURIComponent(scribeOpId)}`
|
|
1943
2366
|
: `/admin/setup?op=${encodeURIComponent(op.id)}`;
|
|
2367
|
+
if (form.isJson) {
|
|
2368
|
+
return jsonOkResponse({
|
|
2369
|
+
op_id: op.id,
|
|
2370
|
+
...(scribeOpId ? { scribe_op_id: scribeOpId } : {}),
|
|
2371
|
+
step: "vault",
|
|
2372
|
+
mode: rawMode,
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
1944
2375
|
return redirect(redirectUrl);
|
|
1945
2376
|
}
|
|
1946
2377
|
|
|
2378
|
+
/**
|
|
2379
|
+
* POST the wizard-collected import params to vault's
|
|
2380
|
+
* `/vault/<name>/.parachute/mirror/import` endpoint. The caller mints
|
|
2381
|
+
* the per-vault admin Bearer (see `signAccessToken` use in the
|
|
2382
|
+
* `runInstall().then(...)` block above) and passes it in; vault gates
|
|
2383
|
+
* the endpoint on `vault:<name>:admin` upstream. Returns vault's
|
|
2384
|
+
* structured response or throws with a usable message.
|
|
2385
|
+
*
|
|
2386
|
+
* Lives in setup-wizard.ts (not as a vault-internal helper) because
|
|
2387
|
+
* vault doesn't import hub-internal code; the import POST is naturally
|
|
2388
|
+
* the wizard's job — it's the only caller until vault ships its own
|
|
2389
|
+
* admin SPA flow. Shape mirrors vault#390's contract:
|
|
2390
|
+
* POST /vault/<name>/.parachute/mirror/import
|
|
2391
|
+
* { remote_url, mode: "merge"|"replace", credentials: {kind, token}|null }
|
|
2392
|
+
* 200 { notes_imported, tags_imported, attachments_imported, warnings }
|
|
2393
|
+
*
|
|
2394
|
+
* Exported (with the `Impl` suffix) so tests can inject a stub fetcher
|
|
2395
|
+
* and assert the Authorization header without standing up a real vault.
|
|
2396
|
+
*/
|
|
2397
|
+
export async function postVaultImportImpl(args: {
|
|
2398
|
+
vaultName: string;
|
|
2399
|
+
vaultPort: number;
|
|
2400
|
+
bearerToken: string;
|
|
2401
|
+
remoteUrl: string;
|
|
2402
|
+
mode: "merge" | "replace";
|
|
2403
|
+
pat?: string;
|
|
2404
|
+
fetcher?: typeof fetch;
|
|
2405
|
+
}): Promise<{
|
|
2406
|
+
notes_imported?: number;
|
|
2407
|
+
tags_imported?: number;
|
|
2408
|
+
attachments_imported?: number;
|
|
2409
|
+
warnings?: readonly string[];
|
|
2410
|
+
}> {
|
|
2411
|
+
const fetcher = args.fetcher ?? fetch;
|
|
2412
|
+
// Vault listens on its supervised port — talk directly to 127.0.0.1
|
|
2413
|
+
// rather than going through hub's path-routing proxy. Cuts one
|
|
2414
|
+
// network hop and avoids the operator-session/CSRF dance.
|
|
2415
|
+
const url = `http://127.0.0.1:${args.vaultPort}/vault/${encodeURIComponent(args.vaultName)}/.parachute/mirror/import`;
|
|
2416
|
+
const body: Record<string, unknown> = {
|
|
2417
|
+
remote_url: args.remoteUrl,
|
|
2418
|
+
mode: args.mode,
|
|
2419
|
+
};
|
|
2420
|
+
if (args.pat) {
|
|
2421
|
+
body.credentials = { kind: "pat", token: args.pat };
|
|
2422
|
+
} else {
|
|
2423
|
+
body.credentials = null;
|
|
2424
|
+
}
|
|
2425
|
+
// Best-effort retry — the supervisor's `start` returns before vault
|
|
2426
|
+
// accepts traffic; a tiny grace window covers the boot lag without
|
|
2427
|
+
// a tight poll loop. Three attempts spaced 1s apart.
|
|
2428
|
+
let lastErr: Error | undefined;
|
|
2429
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2430
|
+
try {
|
|
2431
|
+
const res = await fetcher(url, {
|
|
2432
|
+
method: "POST",
|
|
2433
|
+
headers: {
|
|
2434
|
+
"content-type": "application/json",
|
|
2435
|
+
// Vault's `authenticateVaultRequest` rejects 401 before scope
|
|
2436
|
+
// check when no Bearer is present. The token must carry
|
|
2437
|
+
// `vault:<name>:admin` + audience `vault.<name>` — minted at
|
|
2438
|
+
// the call site via `signAccessToken` so this function stays
|
|
2439
|
+
// pure (no db / userId capture).
|
|
2440
|
+
authorization: `Bearer ${args.bearerToken}`,
|
|
2441
|
+
},
|
|
2442
|
+
body: JSON.stringify(body),
|
|
2443
|
+
});
|
|
2444
|
+
if (res.status === 200) {
|
|
2445
|
+
return (await res.json()) as Awaited<ReturnType<typeof postVaultImportImpl>>;
|
|
2446
|
+
}
|
|
2447
|
+
// Vault returns structured JSON errors per mirror-routes.ts:
|
|
2448
|
+
// 400 (validation), 409 (concurrent), 502 (clone failed), 500.
|
|
2449
|
+
const errBody = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
|
|
2450
|
+
throw new Error(
|
|
2451
|
+
`vault import returned ${res.status}: ${errBody.message ?? errBody.error ?? "unknown"}`,
|
|
2452
|
+
);
|
|
2453
|
+
} catch (err) {
|
|
2454
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
2455
|
+
// ECONNREFUSED / fetch failure → vault hasn't bound yet. Retry.
|
|
2456
|
+
if (lastErr.message.includes("ECONNREFUSED") || lastErr.message.includes("Failed to fetch")) {
|
|
2457
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
throw lastErr;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
throw lastErr ?? new Error("vault import: exhausted retries");
|
|
2464
|
+
}
|
|
2465
|
+
|
|
1947
2466
|
/**
|
|
1948
2467
|
* Write a minimal scribe config that selects the operator's chosen
|
|
1949
2468
|
* transcribe + cleanup providers + API keys (when applicable).
|
|
@@ -2075,13 +2594,19 @@ export async function handleSetupExposePost(
|
|
|
2075
2594
|
req: Request,
|
|
2076
2595
|
deps: SetupWizardDeps,
|
|
2077
2596
|
): Promise<Response> {
|
|
2078
|
-
const form = await req
|
|
2597
|
+
const form = await readBodyFields(req);
|
|
2079
2598
|
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
2080
2599
|
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
2600
|
+
if (form.isJson) {
|
|
2601
|
+
return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
|
|
2602
|
+
}
|
|
2081
2603
|
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
2082
2604
|
}
|
|
2083
2605
|
const session = findActiveSession(deps.db, req);
|
|
2084
2606
|
if (!session) {
|
|
2607
|
+
if (form.isJson) {
|
|
2608
|
+
return jsonErrorResponse(401, "No admin session", "Sign in to continue setup.");
|
|
2609
|
+
}
|
|
2085
2610
|
return badRequestPage(
|
|
2086
2611
|
"No admin session",
|
|
2087
2612
|
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
@@ -2091,10 +2616,20 @@ export async function handleSetupExposePost(
|
|
|
2091
2616
|
// the wizard's GET shape catches this case too, but a direct POST
|
|
2092
2617
|
// (curl, tab race) shouldn't double-fire the auto-approve window.
|
|
2093
2618
|
if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
|
|
2619
|
+
if (form.isJson) {
|
|
2620
|
+
return jsonOkResponse({ step: "done", message: "expose mode already set" });
|
|
2621
|
+
}
|
|
2094
2622
|
return redirect("/admin/setup?just_finished=1");
|
|
2095
2623
|
}
|
|
2096
2624
|
const rawMode = form.get("expose_mode");
|
|
2097
2625
|
if (!isSetupExposeMode(rawMode)) {
|
|
2626
|
+
if (form.isJson) {
|
|
2627
|
+
return jsonErrorResponse(
|
|
2628
|
+
400,
|
|
2629
|
+
"Invalid expose_mode",
|
|
2630
|
+
`Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2098
2633
|
return htmlResponse(
|
|
2099
2634
|
renderExposeStep({
|
|
2100
2635
|
csrfToken: typeof formCsrf === "string" ? formCsrf : "",
|
|
@@ -2120,12 +2655,14 @@ export async function handleSetupExposePost(
|
|
|
2120
2655
|
// registry so revocation via the admin UI works as usual. Failures
|
|
2121
2656
|
// are non-fatal: the done page falls back to the un-headered MCP
|
|
2122
2657
|
// command + a "mint manually at /admin/tokens" hint.
|
|
2658
|
+
let mintedTokenForJson: string | undefined;
|
|
2123
2659
|
try {
|
|
2124
2660
|
const minted = await mintOperatorToken(deps.db, session.userId, {
|
|
2125
2661
|
issuer: deps.issuer,
|
|
2126
2662
|
scopeSet: "admin",
|
|
2127
2663
|
});
|
|
2128
2664
|
setSetting(deps.db, "setup_minted_token", minted.token);
|
|
2665
|
+
mintedTokenForJson = minted.token;
|
|
2129
2666
|
console.log(
|
|
2130
2667
|
`[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
|
|
2131
2668
|
);
|
|
@@ -2133,6 +2670,13 @@ export async function handleSetupExposePost(
|
|
|
2133
2670
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2134
2671
|
console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
|
|
2135
2672
|
}
|
|
2673
|
+
if (form.isJson) {
|
|
2674
|
+
return jsonOkResponse({
|
|
2675
|
+
step: "done",
|
|
2676
|
+
message: "expose mode set",
|
|
2677
|
+
...(mintedTokenForJson ? { minted_token: mintedTokenForJson } : {}),
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2136
2680
|
return redirect("/admin/setup?just_finished=1");
|
|
2137
2681
|
}
|
|
2138
2682
|
|
|
@@ -2152,11 +2696,6 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
|
|
|
2152
2696
|
displayName: string;
|
|
2153
2697
|
tagline: string;
|
|
2154
2698
|
}> = [
|
|
2155
|
-
{
|
|
2156
|
-
short: "surface",
|
|
2157
|
-
displayName: "Surface",
|
|
2158
|
-
tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
|
|
2159
|
-
},
|
|
2160
2699
|
{
|
|
2161
2700
|
short: "scribe",
|
|
2162
2701
|
displayName: "Scribe",
|
|
@@ -2323,11 +2862,10 @@ function validateAccountFields(input: {
|
|
|
2323
2862
|
|
|
2324
2863
|
/**
|
|
2325
2864
|
* Whether a given curated module is currently installed (has a row in
|
|
2326
|
-
* services.json keyed by its canonical `manifestName`). Used by
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
* shared with `buildInstallTiles`.
|
|
2865
|
+
* services.json keyed by its canonical `manifestName`). Used by
|
|
2866
|
+
* `buildInstallTiles` to decide whether an install-tile row renders
|
|
2867
|
+
* the "install" form or the "already installed" state. Cheap manifest
|
|
2868
|
+
* read (no network).
|
|
2331
2869
|
*/
|
|
2332
2870
|
function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
|
|
2333
2871
|
const manifest = readManifestLenient(manifestPath);
|
|
@@ -2373,6 +2911,32 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
|
|
|
2373
2911
|
return new Response(null, { status: 303, headers: { location, ...extra } });
|
|
2374
2912
|
}
|
|
2375
2913
|
|
|
2914
|
+
/**
|
|
2915
|
+
* Structured JSON-200 helper for the CLI wizard surface (hub#168 Cut 3).
|
|
2916
|
+
* Mirrors the browser-redirect responses' header shape (extra cookies
|
|
2917
|
+
* pass through) without the 303 status that would force the CLI's
|
|
2918
|
+
* `fetch` to chase a non-existent location.
|
|
2919
|
+
*/
|
|
2920
|
+
function jsonOkResponse(body: unknown, extra: Record<string, string> = {}): Response {
|
|
2921
|
+
return new Response(JSON.stringify(body), {
|
|
2922
|
+
status: 200,
|
|
2923
|
+
headers: { "content-type": "application/json; charset=utf-8", ...extra },
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
/**
|
|
2928
|
+
* Structured JSON-error helper for the CLI wizard surface (hub#168 Cut 3).
|
|
2929
|
+
* The browser path renders a full HTML error page; the CLI wants a
|
|
2930
|
+
* machine-parseable envelope with the same fields the rendered page
|
|
2931
|
+
* shows. Status code is the same as the HTML branch (400/401/410/etc).
|
|
2932
|
+
*/
|
|
2933
|
+
function jsonErrorResponse(status: number, title: string, message: string): Response {
|
|
2934
|
+
return new Response(JSON.stringify({ error: title, message, status }), {
|
|
2935
|
+
status,
|
|
2936
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2376
2940
|
function badRequestPage(title: string, message: string): Response {
|
|
2377
2941
|
return htmlResponse(renderBadRequestPage(title, message), 400);
|
|
2378
2942
|
}
|
|
@@ -2863,6 +3427,55 @@ const STYLES = `
|
|
|
2863
3427
|
}
|
|
2864
3428
|
.done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
|
|
2865
3429
|
|
|
3430
|
+
/* Vault-mode picker (hub#168 Cut 2). Three-option radio block at the
|
|
3431
|
+
top of the vault step, plus a collapsible import-only sub-form
|
|
3432
|
+
below. Shape mirrors .expose-option for visual consistency. */
|
|
3433
|
+
.vault-mode-block, .vault-import-block {
|
|
3434
|
+
border: 1px solid ${PALETTE.border};
|
|
3435
|
+
border-radius: 8px;
|
|
3436
|
+
padding: 0.75rem 0.9rem;
|
|
3437
|
+
margin: 0.4rem 0;
|
|
3438
|
+
background: ${PALETTE.cardBg};
|
|
3439
|
+
display: flex;
|
|
3440
|
+
flex-direction: column;
|
|
3441
|
+
gap: 0.6rem;
|
|
3442
|
+
}
|
|
3443
|
+
.vault-mode-block legend, .vault-import-block legend {
|
|
3444
|
+
padding: 0 0.4rem;
|
|
3445
|
+
font-size: 0.85rem;
|
|
3446
|
+
color: ${PALETTE.fgMuted};
|
|
3447
|
+
font-family: ${FONT_MONO};
|
|
3448
|
+
}
|
|
3449
|
+
.vault-mode-option {
|
|
3450
|
+
display: flex;
|
|
3451
|
+
align-items: flex-start;
|
|
3452
|
+
gap: 0.6rem;
|
|
3453
|
+
padding: 0.6rem 0.8rem;
|
|
3454
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
3455
|
+
border-radius: 6px;
|
|
3456
|
+
cursor: pointer;
|
|
3457
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
3458
|
+
}
|
|
3459
|
+
.vault-mode-option:hover { border-color: ${PALETTE.accent}; }
|
|
3460
|
+
.vault-mode-option input[type=radio] {
|
|
3461
|
+
margin-top: 0.25rem;
|
|
3462
|
+
accent-color: ${PALETTE.accent};
|
|
3463
|
+
flex-shrink: 0;
|
|
3464
|
+
}
|
|
3465
|
+
.vault-mode-title {
|
|
3466
|
+
font-weight: 600;
|
|
3467
|
+
color: ${PALETTE.fg};
|
|
3468
|
+
font-size: 0.95rem;
|
|
3469
|
+
margin-left: 0.3rem;
|
|
3470
|
+
}
|
|
3471
|
+
.vault-mode-desc {
|
|
3472
|
+
color: ${PALETTE.fgMuted};
|
|
3473
|
+
font-size: 0.85rem;
|
|
3474
|
+
line-height: 1.45;
|
|
3475
|
+
flex-basis: 100%;
|
|
3476
|
+
margin-left: 1.7rem;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
2866
3479
|
/* expose step (hub#268 Item 2). Vertical stack of radio cards;
|
|
2867
3480
|
each label is the full clickable hit target. */
|
|
2868
3481
|
.expose-form { gap: 0.65rem; }
|