@openparachute/hub 0.5.7 → 0.5.10-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,2009 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-boot setup wizard at `/admin/setup` (hub#259).
|
|
3
|
+
*
|
|
4
|
+
* Server-rendered, three-step form that walks a fresh operator through:
|
|
5
|
+
*
|
|
6
|
+
* 1. Welcome — what they're about to set up (admin account + first vault).
|
|
7
|
+
* 2. Account — username + password → POST `/admin/setup/account`.
|
|
8
|
+
* Creates the admin row via `createUser`, sets a `parachute_hub_session`
|
|
9
|
+
* cookie + a `parachute_hub_csrf` cookie, redirects back to
|
|
10
|
+
* `/admin/setup`.
|
|
11
|
+
* 3. Vault — pick a name (default `default`) → POST `/admin/setup/vault`.
|
|
12
|
+
* Gated by the just-minted admin session cookie. Drives the same
|
|
13
|
+
* `runInstall` path the `/api/modules/:short/install` API uses, just
|
|
14
|
+
* without re-fabricating an HTTP request + bearer. Returns + redirects
|
|
15
|
+
* to `/admin/setup?op=<id>` so the same wizard page polls the
|
|
16
|
+
* operation registry.
|
|
17
|
+
* 4. Done — links to the admin SPA, MCP install hints, "what's next."
|
|
18
|
+
*
|
|
19
|
+
* The wizard is server-rendered (no SPA bundle, no JS). Step 3's progress
|
|
20
|
+
* poll is a `<meta http-equiv="refresh" content="2">` — works without JS
|
|
21
|
+
* and is fine for a 30-second one-shot install on first boot.
|
|
22
|
+
*
|
|
23
|
+
* Idempotency: the rendered step is derived from DB + services.json on
|
|
24
|
+
* every GET. If a user already exists but no vault, the wizard resumes
|
|
25
|
+
* at step 3. If both exist, it resumes at step 4. Once both exist + a
|
|
26
|
+
* full minute has elapsed since the user was created, subsequent GETs
|
|
27
|
+
* 301 to `/login` (the canonical post-setup entry).
|
|
28
|
+
*
|
|
29
|
+
* No email collection (the brief). Magic-link recovery is a later phase.
|
|
30
|
+
* No 2FA in this wizard either — adds it later; the launch posture is
|
|
31
|
+
* "username + password is fine for a fresh hub."
|
|
32
|
+
*
|
|
33
|
+
* History: replaces the static placeholder `renderSetupPlaceholder` from
|
|
34
|
+
* the hub#258 setup-gate scaffold. The env-var seed path
|
|
35
|
+
* (`PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD`)
|
|
36
|
+
* still works for container operators who prefer to bake the admin into
|
|
37
|
+
* the boot path; documented as an alternative on the welcome screen.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { Database } from "bun:sqlite";
|
|
41
|
+
import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
|
|
42
|
+
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
43
|
+
import {
|
|
44
|
+
CSRF_FIELD_NAME,
|
|
45
|
+
ensureCsrfToken,
|
|
46
|
+
renderCsrfHiddenInput,
|
|
47
|
+
verifyCsrfToken,
|
|
48
|
+
} from "./csrf.ts";
|
|
49
|
+
import {
|
|
50
|
+
SETUP_EXPOSE_MODES,
|
|
51
|
+
type SetupExposeMode,
|
|
52
|
+
deleteSetting,
|
|
53
|
+
getSetting,
|
|
54
|
+
isSetupExposeMode,
|
|
55
|
+
openFirstClientAutoApproveWindow,
|
|
56
|
+
setSetting,
|
|
57
|
+
} from "./hub-settings.ts";
|
|
58
|
+
import { escapeHtml } from "./oauth-ui.ts";
|
|
59
|
+
import { mintOperatorToken } from "./operator-token.ts";
|
|
60
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
61
|
+
import { findService, readManifest } from "./services-manifest.ts";
|
|
62
|
+
import {
|
|
63
|
+
SESSION_TTL_MS,
|
|
64
|
+
buildSessionCookie,
|
|
65
|
+
createSession,
|
|
66
|
+
findActiveSession,
|
|
67
|
+
} from "./sessions.ts";
|
|
68
|
+
import type { Supervisor } from "./supervisor.ts";
|
|
69
|
+
import { createUser, userCount } from "./users.ts";
|
|
70
|
+
import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
|
|
71
|
+
|
|
72
|
+
// --- shared chrome --------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const PALETTE = {
|
|
75
|
+
bg: "#faf8f4",
|
|
76
|
+
fg: "#2c2a26",
|
|
77
|
+
fgMuted: "#6b6860",
|
|
78
|
+
fgDim: "#9a9690",
|
|
79
|
+
accent: "#4a7c59",
|
|
80
|
+
accentHover: "#3d6849",
|
|
81
|
+
accentSoft: "rgba(74, 124, 89, 0.08)",
|
|
82
|
+
border: "#e4e0d8",
|
|
83
|
+
borderLight: "#ece9e2",
|
|
84
|
+
cardBg: "#ffffff",
|
|
85
|
+
danger: "#a3392b",
|
|
86
|
+
dangerSoft: "rgba(163, 57, 43, 0.08)",
|
|
87
|
+
success: "#3d6849",
|
|
88
|
+
warn: "#d4a017",
|
|
89
|
+
warnSoft: "#fff8e1",
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
const FONT_SERIF = `Georgia, "Times New Roman", serif`;
|
|
93
|
+
const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
|
|
94
|
+
const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
|
|
95
|
+
|
|
96
|
+
function escapeAttr(s: string): string {
|
|
97
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- state derivation ----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Wizard steps. `"account"` is a visual-only entry in the progress
|
|
104
|
+
* header — it shares a screen with `"welcome"` (the combined welcome +
|
|
105
|
+
* account form), and `deriveWizardState` never returns it: a welcome
|
|
106
|
+
* POST creates the admin and advances directly to `"vault"`. Kept in
|
|
107
|
+
* the union so the progress bar can render it as a distinct dot for
|
|
108
|
+
* display continuity.
|
|
109
|
+
*/
|
|
110
|
+
export type WizardStep = "welcome" | "account" | "vault" | "expose" | "done";
|
|
111
|
+
|
|
112
|
+
export interface DerivedWizardState {
|
|
113
|
+
/** Current step the wizard should render. */
|
|
114
|
+
step: WizardStep;
|
|
115
|
+
/** Whether at least one user row exists. */
|
|
116
|
+
hasAdmin: boolean;
|
|
117
|
+
/** Whether the first vault (curated) has been provisioned in services.json. */
|
|
118
|
+
hasVault: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Whether the operator has answered the "how will this hub be reached?"
|
|
121
|
+
* question (the expose step, hub#268 Item 2). When admin + vault both
|
|
122
|
+
* exist but the operator hasn't picked an expose mode yet, the wizard
|
|
123
|
+
* renders the expose step rather than the done screen.
|
|
124
|
+
*/
|
|
125
|
+
hasExposeMode: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Vault is the canonical first-vault target for the wizard. The brief
|
|
130
|
+
* specifies "first vault — pick a name (default: `default`)" and the
|
|
131
|
+
* curated module list is what install / supervisor speak.
|
|
132
|
+
*/
|
|
133
|
+
export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read DB + services.json to decide which step the wizard should render.
|
|
137
|
+
* Pure, idempotent — re-running the wizard after partial setup picks up
|
|
138
|
+
* where it left off.
|
|
139
|
+
*/
|
|
140
|
+
export function deriveWizardState(deps: {
|
|
141
|
+
db: Database;
|
|
142
|
+
manifestPath: string;
|
|
143
|
+
}): DerivedWizardState {
|
|
144
|
+
const hasAdmin = userCount(deps.db) > 0;
|
|
145
|
+
// The wizard's first-vault provisioning uses the curated `vault` short,
|
|
146
|
+
// which maps to `parachute-vault` in services.json.
|
|
147
|
+
const vaultSpec = specFor(FIRST_VAULT_SHORT);
|
|
148
|
+
const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
|
|
149
|
+
const hasVault = vaultEntry !== undefined;
|
|
150
|
+
// Expose-mode is the operator's "how will this hub be reached?" answer
|
|
151
|
+
// (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
|
|
152
|
+
// sets it; absence means we should still ask.
|
|
153
|
+
const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
|
|
154
|
+
let step: WizardStep;
|
|
155
|
+
// Note: `"account"` is a visual-only step in the progress header —
|
|
156
|
+
// welcome's POST creates the admin and advances directly to `"vault"`,
|
|
157
|
+
// so we never return `"account"` here.
|
|
158
|
+
if (!hasAdmin) step = "welcome";
|
|
159
|
+
else if (!hasVault) step = "vault";
|
|
160
|
+
else if (!hasExposeMode) step = "expose";
|
|
161
|
+
else step = "done";
|
|
162
|
+
return { step, hasAdmin, hasVault, hasExposeMode };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- handler types -------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export interface SetupWizardDeps {
|
|
168
|
+
db: Database;
|
|
169
|
+
manifestPath: string;
|
|
170
|
+
configDir: string;
|
|
171
|
+
/**
|
|
172
|
+
* Optional supervisor. Present under `parachute serve` (container
|
|
173
|
+
* mode); absent under the on-box CLI surface. The wizard refuses
|
|
174
|
+
* step-3 POSTs when absent — the operator is expected to use the CLI
|
|
175
|
+
* (`parachute install vault`) in that posture, not the web wizard.
|
|
176
|
+
*/
|
|
177
|
+
supervisor?: Supervisor;
|
|
178
|
+
/**
|
|
179
|
+
* Hub origin string for the JWT `iss` claim plumbed through to install
|
|
180
|
+
* ops. Defaults to the hub's own loopback issuer when unset (consistent
|
|
181
|
+
* with the rest of hub-server.ts when no PARACHUTE_HUB_ORIGIN is
|
|
182
|
+
* configured).
|
|
183
|
+
*/
|
|
184
|
+
issuer: string;
|
|
185
|
+
/**
|
|
186
|
+
* Test seam: inject an operations registry so the wizard's tests can
|
|
187
|
+
* observe its install op without colliding with the default
|
|
188
|
+
* process-singleton. Production omits this; both the API surface and
|
|
189
|
+
* the wizard then share the same default registry, which is correct —
|
|
190
|
+
* an `/api/modules/operations/:id` poll from the SPA can pick up an
|
|
191
|
+
* op created by the wizard if for some reason a stale tab is open.
|
|
192
|
+
*/
|
|
193
|
+
registry?: OperationsRegistry;
|
|
194
|
+
/** Test seam: stub `bun add` / `bun remove` runner. */
|
|
195
|
+
run?: (cmd: readonly string[]) => Promise<number>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- rendering -----------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function baseDocument(title: string, body: string, autoRefresh?: number): string {
|
|
201
|
+
const refresh = autoRefresh ? `<meta http-equiv="refresh" content="${autoRefresh}" />` : "";
|
|
202
|
+
return `<!doctype html>
|
|
203
|
+
<html lang="en">
|
|
204
|
+
<head>
|
|
205
|
+
<meta charset="utf-8" />
|
|
206
|
+
<title>${escapeHtml(title)}</title>
|
|
207
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
208
|
+
<meta name="referrer" content="no-referrer" />
|
|
209
|
+
${refresh}
|
|
210
|
+
<style>${STYLES}</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<main>
|
|
214
|
+
${body}
|
|
215
|
+
</main>
|
|
216
|
+
</body>
|
|
217
|
+
</html>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function header(currentStep: WizardStep): string {
|
|
221
|
+
const stepOrder: WizardStep[] = ["welcome", "account", "vault", "expose", "done"];
|
|
222
|
+
// Step 1 (welcome) + step 2 (account) collapse on the rendered page —
|
|
223
|
+
// we show them as a single combined form. The progress bar still names
|
|
224
|
+
// them separately so the operator sees the shape.
|
|
225
|
+
const labels: Record<WizardStep, string> = {
|
|
226
|
+
welcome: "Welcome",
|
|
227
|
+
account: "Account",
|
|
228
|
+
vault: "Vault",
|
|
229
|
+
expose: "Expose",
|
|
230
|
+
done: "Done",
|
|
231
|
+
};
|
|
232
|
+
const items = stepOrder
|
|
233
|
+
.map((s) => {
|
|
234
|
+
const current = s === currentStep;
|
|
235
|
+
const past = stepOrder.indexOf(s) < stepOrder.indexOf(currentStep);
|
|
236
|
+
const cls = current ? "step current" : past ? "step past" : "step";
|
|
237
|
+
const marker = past ? "✓" : `${stepOrder.indexOf(s) + 1}`;
|
|
238
|
+
return `<li class="${cls}"><span class="step-marker">${marker}</span><span class="step-label">${escapeHtml(labels[s])}</span></li>`;
|
|
239
|
+
})
|
|
240
|
+
.join("");
|
|
241
|
+
return `
|
|
242
|
+
<div class="brand">
|
|
243
|
+
<span class="brand-mark">⌬</span>
|
|
244
|
+
<span class="brand-name">Parachute</span>
|
|
245
|
+
<span class="brand-tag">first-boot setup</span>
|
|
246
|
+
</div>
|
|
247
|
+
<ol class="steps">${items}</ol>`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- step 1 + 2: welcome + account ---------------------------------------
|
|
251
|
+
|
|
252
|
+
export interface RenderAccountStepProps {
|
|
253
|
+
csrfToken: string;
|
|
254
|
+
errorMessage?: string;
|
|
255
|
+
/** Pre-fill the username field after a validation failure. */
|
|
256
|
+
username?: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
260
|
+
const { csrfToken, errorMessage, username } = props;
|
|
261
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
262
|
+
const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
|
|
263
|
+
const body = `
|
|
264
|
+
<div class="card">
|
|
265
|
+
<div class="card-header">
|
|
266
|
+
${header("welcome")}
|
|
267
|
+
<h1>Welcome to your Parachute hub</h1>
|
|
268
|
+
<p class="subtitle">Two quick steps and you'll have a working stack —
|
|
269
|
+
an admin account and your first vault. No email, no signup; this
|
|
270
|
+
all stays on your machine (or your container).</p>
|
|
271
|
+
</div>
|
|
272
|
+
<section class="explainer">
|
|
273
|
+
<h2>Why this step</h2>
|
|
274
|
+
<p>A Parachute hub needs one admin operator before anything else can
|
|
275
|
+
run — OAuth issuance, vault provisioning, the admin UI all need
|
|
276
|
+
an identity behind them.</p>
|
|
277
|
+
<h2>What's next</h2>
|
|
278
|
+
<p>After this you'll name your first vault. The hub will install it
|
|
279
|
+
and issue a token your Claude Code MCP client can use.</p>
|
|
280
|
+
</section>
|
|
281
|
+
${error}
|
|
282
|
+
<form method="POST" action="/admin/setup/account" class="auth-form">
|
|
283
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
284
|
+
<label class="field">
|
|
285
|
+
<span class="field-label">Username</span>
|
|
286
|
+
<input type="text" name="username" autocomplete="username"
|
|
287
|
+
autofocus required minlength="2" maxlength="64"
|
|
288
|
+
pattern="[A-Za-z0-9_.-]+" title="letters, digits, _ . - (2–64 chars)"
|
|
289
|
+
${usernameAttr} />
|
|
290
|
+
<span class="field-hint">letters, digits, <code>_</code>, <code>.</code>, <code>-</code></span>
|
|
291
|
+
</label>
|
|
292
|
+
<label class="field">
|
|
293
|
+
<span class="field-label">Password</span>
|
|
294
|
+
<input type="password" name="password" autocomplete="new-password"
|
|
295
|
+
required minlength="8" />
|
|
296
|
+
<span class="field-hint">at least 8 characters</span>
|
|
297
|
+
</label>
|
|
298
|
+
<label class="field">
|
|
299
|
+
<span class="field-label">Confirm password</span>
|
|
300
|
+
<input type="password" name="password_confirm" autocomplete="new-password"
|
|
301
|
+
required minlength="8" />
|
|
302
|
+
</label>
|
|
303
|
+
<button type="submit" class="btn btn-primary">Create admin & continue</button>
|
|
304
|
+
</form>
|
|
305
|
+
<details class="alt-path">
|
|
306
|
+
<summary>Prefer to seed via env vars?</summary>
|
|
307
|
+
<p>Set <code>PARACHUTE_INITIAL_ADMIN_USERNAME</code> and
|
|
308
|
+
<code>PARACHUTE_INITIAL_ADMIN_PASSWORD</code> on the container and
|
|
309
|
+
restart. The hub will create the admin row on next boot and skip this
|
|
310
|
+
wizard.</p>
|
|
311
|
+
</details>
|
|
312
|
+
</div>`;
|
|
313
|
+
return baseDocument("Set up your Parachute hub — account", body);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- step 3: vault -------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export interface RenderVaultStepProps {
|
|
319
|
+
csrfToken: string;
|
|
320
|
+
errorMessage?: string;
|
|
321
|
+
/** Pre-fill the vault name input after a validation failure. */
|
|
322
|
+
vaultName?: string;
|
|
323
|
+
/**
|
|
324
|
+
* When an install op is in progress, render the polling shape: no
|
|
325
|
+
* form, just the op log + auto-refresh.
|
|
326
|
+
*/
|
|
327
|
+
operation?: {
|
|
328
|
+
id: string;
|
|
329
|
+
status: "pending" | "running" | "succeeded" | "failed";
|
|
330
|
+
log: readonly string[];
|
|
331
|
+
error?: string;
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
336
|
+
const { csrfToken, errorMessage, operation, vaultName } = props;
|
|
337
|
+
if (operation) return renderVaultOpStep({ operation });
|
|
338
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
339
|
+
// hub#267: the typed name now flows end-to-end via
|
|
340
|
+
// `PARACHUTE_VAULT_NAME`. Vault#342 added the env var read on
|
|
341
|
+
// first-boot — hub spawns vault with the env var set and vault's
|
|
342
|
+
// `resolveFirstBootVaultName` picks it up. The wizard's job here is
|
|
343
|
+
// to ask + validate + persist the choice; the supervised vault child
|
|
344
|
+
// does the rest.
|
|
345
|
+
//
|
|
346
|
+
// Leaving the field blank falls back to `default` server-side —
|
|
347
|
+
// matches the prior shape so no-input + Submit still works for the
|
|
348
|
+
// "I don't care, just give me a vault" path.
|
|
349
|
+
const nameAttr = vaultName !== undefined ? ` value="${escapeAttr(vaultName)}"` : "";
|
|
350
|
+
const previewName = vaultName?.trim() ? escapeHtml(vaultName.trim()) : DEFAULT_VAULT_NAME;
|
|
351
|
+
const body = `
|
|
352
|
+
<div class="card">
|
|
353
|
+
<div class="card-header">
|
|
354
|
+
${header("vault")}
|
|
355
|
+
<h1>Create your first vault</h1>
|
|
356
|
+
<p class="subtitle">A vault is the per-workspace SQLite store + MCP
|
|
357
|
+
surface Claude reads and writes through. You can have many vaults
|
|
358
|
+
on one hub; this is just the first.</p>
|
|
359
|
+
</div>
|
|
360
|
+
<section class="explainer">
|
|
361
|
+
<h2>Why this step</h2>
|
|
362
|
+
<p>The wizard provisions a vault module at the path
|
|
363
|
+
<code>/vault/<name></code> and issues you an operator token —
|
|
364
|
+
the same shape <code>parachute install vault</code> produces from
|
|
365
|
+
the CLI. We're doing both in one click.</p>
|
|
366
|
+
<h2>What's next</h2>
|
|
367
|
+
<p>You'll land on a success screen with copy-paste MCP install
|
|
368
|
+
instructions for Claude Code and a link to the admin UI, where
|
|
369
|
+
you can rename or add additional vaults.</p>
|
|
370
|
+
</section>
|
|
371
|
+
<section class="preview">
|
|
372
|
+
<p class="preview-label">About to create</p>
|
|
373
|
+
<div class="preview-card">
|
|
374
|
+
<span class="preview-key">vault:</span>
|
|
375
|
+
<span class="preview-val" id="preview-vault-name">${previewName}</span>
|
|
376
|
+
<span class="preview-fine">— admin: you, MCP-ready for Claude Code</span>
|
|
377
|
+
</div>
|
|
378
|
+
<p class="preview-fine">
|
|
379
|
+
The name shows up in the MCP URL (<code>/vault/<name>/mcp</code>)
|
|
380
|
+
and on the admin UI. You can rename or add vaults later from
|
|
381
|
+
<code>/admin/vaults</code>.
|
|
382
|
+
</p>
|
|
383
|
+
</section>
|
|
384
|
+
${error}
|
|
385
|
+
<form method="POST" action="/admin/setup/vault" class="auth-form">
|
|
386
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
387
|
+
<label class="field">
|
|
388
|
+
<span class="field-label">Vault name</span>
|
|
389
|
+
<input type="text" name="vault_name"
|
|
390
|
+
autofocus minlength="2" maxlength="32"
|
|
391
|
+
pattern="[a-z0-9_-]+"
|
|
392
|
+
title="lowercase letters, digits, hyphens, underscores (2–32 chars)"
|
|
393
|
+
placeholder="${DEFAULT_VAULT_NAME}"${nameAttr} />
|
|
394
|
+
<span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
|
|
395
|
+
2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
|
|
396
|
+
</label>
|
|
397
|
+
<button type="submit" class="btn btn-primary">Create vault & finish</button>
|
|
398
|
+
</form>
|
|
399
|
+
</div>`;
|
|
400
|
+
return baseDocument("Set up your Parachute hub — vault", body);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function renderVaultOpStep(props: {
|
|
404
|
+
operation: NonNullable<RenderVaultStepProps["operation"]>;
|
|
405
|
+
}): string {
|
|
406
|
+
const { operation } = props;
|
|
407
|
+
const terminal = operation.status === "succeeded" || operation.status === "failed";
|
|
408
|
+
const logLines = operation.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
409
|
+
const errBanner = operation.error
|
|
410
|
+
? `<p class="error-banner">${escapeHtml(operation.error)}</p>`
|
|
411
|
+
: "";
|
|
412
|
+
// Auto-refresh every 2s until terminal. When succeeded we redirect via
|
|
413
|
+
// a tiny refresh-to-/admin/setup?just_finished=1 so the wizard
|
|
414
|
+
// re-renders the success screen one more time (with the MCP install
|
|
415
|
+
// command + vault name) before subsequent bare GETs 301 to /login.
|
|
416
|
+
// Without the `?just_finished=1` query, the success state derives as
|
|
417
|
+
// "complete" + GET 301s, and the operator never sees the done page.
|
|
418
|
+
// When failed we leave the operator on this screen so they can read
|
|
419
|
+
// the log.
|
|
420
|
+
const refresh = terminal ? undefined : 2;
|
|
421
|
+
const body = `
|
|
422
|
+
<div class="card">
|
|
423
|
+
<div class="card-header">
|
|
424
|
+
${header("vault")}
|
|
425
|
+
<h1>${operation.status === "succeeded" ? "Vault ready" : "Provisioning your vault…"}</h1>
|
|
426
|
+
<p class="subtitle">${
|
|
427
|
+
operation.status === "failed"
|
|
428
|
+
? "Something went wrong — see the log below."
|
|
429
|
+
: operation.status === "succeeded"
|
|
430
|
+
? "All set. Continuing to the success screen…"
|
|
431
|
+
: "This usually takes 10–60 seconds. The page refreshes itself."
|
|
432
|
+
}</p>
|
|
433
|
+
</div>
|
|
434
|
+
${errBanner}
|
|
435
|
+
<section class="op-log">
|
|
436
|
+
<p class="op-status op-${operation.status}">status: ${operation.status}</p>
|
|
437
|
+
<ol class="log-lines">${logLines}</ol>
|
|
438
|
+
</section>
|
|
439
|
+
${
|
|
440
|
+
operation.status === "succeeded"
|
|
441
|
+
? '<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1" />'
|
|
442
|
+
: ""
|
|
443
|
+
}
|
|
444
|
+
</div>`;
|
|
445
|
+
return baseDocument("Set up your Parachute hub — vault", body, refresh);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- step 4: expose ------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
export interface RenderExposeStepProps {
|
|
451
|
+
csrfToken: string;
|
|
452
|
+
errorMessage?: string;
|
|
453
|
+
/** Pre-select a radio when re-rendering after a validation error. */
|
|
454
|
+
selectedMode?: SetupExposeMode;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The expose step asks the operator how this hub will be reached. The
|
|
459
|
+
* wizard doesn't configure tailscale or DNS itself — the operator owns
|
|
460
|
+
* the actual networking step; the wizard's role is to ask the question,
|
|
461
|
+
* surface the right next-step instructions, and persist the choice so
|
|
462
|
+
* the done page (and the admin SPA later) shows the right URL shape.
|
|
463
|
+
*
|
|
464
|
+
* Three modes (hub#268 Item 2):
|
|
465
|
+
* * localhost — just this machine. No further action; the loopback
|
|
466
|
+
* URL is the canonical entry.
|
|
467
|
+
* * tailnet — Tailscale network. Show the `tailscale serve` command
|
|
468
|
+
* the operator runs themselves.
|
|
469
|
+
* * public — custom domain / reverse proxy. Show a brief explainer
|
|
470
|
+
* + link to the deploy docs.
|
|
471
|
+
*/
|
|
472
|
+
export function renderExposeStep(props: RenderExposeStepProps): string {
|
|
473
|
+
const { csrfToken, errorMessage, selectedMode } = props;
|
|
474
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
475
|
+
// The default selection (localhost) is the most common case + the
|
|
476
|
+
// safest fallback — picking it changes nothing operational. Tailnet +
|
|
477
|
+
// public require the operator to actually run something; surfacing
|
|
478
|
+
// them as alternatives is the whole point of this step.
|
|
479
|
+
const sel = (m: SetupExposeMode) => (selectedMode === m ? " checked" : "");
|
|
480
|
+
const defaultChecked = selectedMode === undefined ? " checked" : "";
|
|
481
|
+
const body = `
|
|
482
|
+
<div class="card">
|
|
483
|
+
<div class="card-header">
|
|
484
|
+
${header("expose")}
|
|
485
|
+
<h1>How will this hub be reached?</h1>
|
|
486
|
+
<p class="subtitle">Pick the network shape that matches your setup.
|
|
487
|
+
You can revisit this later from the admin UI — it just shapes the
|
|
488
|
+
URLs we surface on the next screen.</p>
|
|
489
|
+
</div>
|
|
490
|
+
${error}
|
|
491
|
+
<form method="POST" action="/admin/setup/expose" class="auth-form expose-form">
|
|
492
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
493
|
+
<label class="expose-option">
|
|
494
|
+
<input type="radio" name="expose_mode" value="localhost"${selectedMode ? sel("localhost") : defaultChecked} />
|
|
495
|
+
<div class="expose-option-body">
|
|
496
|
+
<span class="expose-option-title">Just this machine (localhost)</span>
|
|
497
|
+
<span class="expose-option-desc">Reach the hub at
|
|
498
|
+
<code>http://localhost:1939</code>. No further configuration
|
|
499
|
+
needed. This is the right answer for "I'm just trying it out"
|
|
500
|
+
and for "this machine is the only client."</span>
|
|
501
|
+
</div>
|
|
502
|
+
</label>
|
|
503
|
+
<label class="expose-option">
|
|
504
|
+
<input type="radio" name="expose_mode" value="tailnet"${sel("tailnet")} />
|
|
505
|
+
<div class="expose-option-body">
|
|
506
|
+
<span class="expose-option-title">My Tailscale network</span>
|
|
507
|
+
<span class="expose-option-desc">Share with your own devices over
|
|
508
|
+
a private tailnet. After finishing setup, run:</span>
|
|
509
|
+
<pre class="expose-option-cmd">tailscale serve --bg --https=1939 http://localhost:1939</pre>
|
|
510
|
+
<span class="expose-option-desc">The hub is then reachable at
|
|
511
|
+
your tailnet hostname (e.g.
|
|
512
|
+
<code>https://my-mac.tailnet-name.ts.net</code>) from any of
|
|
513
|
+
your logged-in devices.</span>
|
|
514
|
+
</div>
|
|
515
|
+
</label>
|
|
516
|
+
<label class="expose-option">
|
|
517
|
+
<input type="radio" name="expose_mode" value="public"${sel("public")} />
|
|
518
|
+
<div class="expose-option-body">
|
|
519
|
+
<span class="expose-option-title">Public URL (custom domain)</span>
|
|
520
|
+
<span class="expose-option-desc">Run the hub behind a reverse
|
|
521
|
+
proxy on a domain you own. See the
|
|
522
|
+
<a href="https://parachute.computer/docs/deploy" target="_blank" rel="noopener">deploy guide</a>
|
|
523
|
+
for nginx / Caddy / Cloudflare Tunnel examples + the env
|
|
524
|
+
vars (<code>PARACHUTE_HUB_ORIGIN</code>) the hub reads for
|
|
525
|
+
its own canonical URL.</span>
|
|
526
|
+
</div>
|
|
527
|
+
</label>
|
|
528
|
+
<button type="submit" class="btn btn-primary">Continue</button>
|
|
529
|
+
</form>
|
|
530
|
+
</div>`;
|
|
531
|
+
return baseDocument("Set up your Parachute hub — expose", body);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// --- step 5: done --------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Per-module install state surfaced on the done screen (hub#272 Item B).
|
|
538
|
+
* The renderer reads this to choose tile shape:
|
|
539
|
+
* * `idle` — no op yet, show the Install button + form
|
|
540
|
+
* * `running` / `pending` — op-poll panel + auto-refresh
|
|
541
|
+
* * `succeeded` — green check + "View in admin" link
|
|
542
|
+
* * `failed` — red banner + log + retry button
|
|
543
|
+
*
|
|
544
|
+
* Same op-id flows through the admin SPA's operation poll, so an
|
|
545
|
+
* operator can hop to `/admin/modules` mid-flight and watch from there
|
|
546
|
+
* without losing the op.
|
|
547
|
+
*/
|
|
548
|
+
export interface ModuleInstallTileState {
|
|
549
|
+
short: CuratedModuleShort;
|
|
550
|
+
displayName: string;
|
|
551
|
+
tagline: string;
|
|
552
|
+
/** True when a services.json entry already exists for this module (already installed). */
|
|
553
|
+
alreadyInstalled: boolean;
|
|
554
|
+
/** Live op snapshot from the registry, if `?op_<short>=<id>` was set. */
|
|
555
|
+
operation?: {
|
|
556
|
+
id: string;
|
|
557
|
+
status: "pending" | "running" | "succeeded" | "failed";
|
|
558
|
+
log: readonly string[];
|
|
559
|
+
error?: string;
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export interface RenderDoneStepProps {
|
|
564
|
+
vaultName: string;
|
|
565
|
+
/** Hub origin used in copy-pastable MCP install commands. */
|
|
566
|
+
hubOrigin: string;
|
|
567
|
+
/**
|
|
568
|
+
* Operator's expose-mode choice from step 4. Shapes the "Your hub is
|
|
569
|
+
* reachable at:" line + next-step instructions. Optional for back-compat
|
|
570
|
+
* with callers that render the done step without going through expose
|
|
571
|
+
* (e.g. tests of the wizard's older two-step flow).
|
|
572
|
+
*/
|
|
573
|
+
exposeMode?: SetupExposeMode;
|
|
574
|
+
/**
|
|
575
|
+
* Auto-minted operator token surfaced once on the done screen
|
|
576
|
+
* (hub#272 Item A). When present, the MCP install command renders
|
|
577
|
+
* with `--header "Authorization: Bearer <token>"` pre-filled and a
|
|
578
|
+
* one-click Copy button. Absent means the mint either failed or
|
|
579
|
+
* the operator already consumed the single-use surface — the tile
|
|
580
|
+
* falls back to the un-headered command + a "mint at /admin/tokens"
|
|
581
|
+
* hint.
|
|
582
|
+
*/
|
|
583
|
+
mintedToken?: string;
|
|
584
|
+
/**
|
|
585
|
+
* Optional per-module install tiles to render alongside the MCP
|
|
586
|
+
* command (hub#272 Item B). When omitted, the done step renders
|
|
587
|
+
* only the MCP tile + the admin-UI fallback link. Production wires
|
|
588
|
+
* Notes + Scribe; tests can omit this to assert the back-compat
|
|
589
|
+
* shape.
|
|
590
|
+
*/
|
|
591
|
+
installTiles?: readonly ModuleInstallTileState[];
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
595
|
+
const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
|
|
596
|
+
const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
|
|
597
|
+
const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
|
|
598
|
+
const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
|
|
599
|
+
const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
|
|
600
|
+
// The done-grid hosts the MCP-connect tile + the admin-UI fallback.
|
|
601
|
+
// The install tiles sit above it as a primary "what's next?" surface —
|
|
602
|
+
// they're the highest-friction next-step for most operators (operator
|
|
603
|
+
// just provisioned a vault, the obvious next action is installing the
|
|
604
|
+
// PWA / transcription module on top of it). Reachable tile leads
|
|
605
|
+
// everything because it answers "where's my hub?" before anything
|
|
606
|
+
// else — the question every operator hits before MCP / module
|
|
607
|
+
// installs even matter.
|
|
608
|
+
const body = `
|
|
609
|
+
<div class="card">
|
|
610
|
+
<div class="card-header">
|
|
611
|
+
${header("done")}
|
|
612
|
+
<h1>You're set up</h1>
|
|
613
|
+
<p class="subtitle">Your hub is ready. Here's what to do next.</p>
|
|
614
|
+
</div>
|
|
615
|
+
${reachable}
|
|
616
|
+
${installSection}
|
|
617
|
+
<section class="done-grid">
|
|
618
|
+
${mcpTile}
|
|
619
|
+
<div class="done-tile">
|
|
620
|
+
<h2>Open the admin UI</h2>
|
|
621
|
+
<p>Manage vaults, tokens, OAuth grants, and module updates.</p>
|
|
622
|
+
<p><a class="btn btn-secondary" href="/admin/modules">Go to admin</a></p>
|
|
623
|
+
</div>
|
|
624
|
+
</section>
|
|
625
|
+
<section class="explainer">
|
|
626
|
+
<h2>What just happened</h2>
|
|
627
|
+
<ul>
|
|
628
|
+
<li>An admin operator account was created on this hub.</li>
|
|
629
|
+
<li>A vault named <code>${escapeHtml(vaultName)}</code> was installed and started.</li>
|
|
630
|
+
<li>OAuth issuer + JWKS keys were minted (visible at
|
|
631
|
+
<code>/.well-known/oauth-authorization-server</code>).</li>
|
|
632
|
+
</ul>
|
|
633
|
+
<p>This wizard won't come back — the next visitor to <code>/</code>
|
|
634
|
+
sees the discovery page; visitors to <code>/admin</code> are routed
|
|
635
|
+
to <code>/login</code>.</p>
|
|
636
|
+
</section>
|
|
637
|
+
</div>`;
|
|
638
|
+
// Auto-refresh while any install op is in flight so the operator sees
|
|
639
|
+
// progress without manually reloading. Done step is the canonical
|
|
640
|
+
// poll surface for both the MCP-connect tile (static) and the
|
|
641
|
+
// module-install tiles (dynamic). Refresh interval matches the
|
|
642
|
+
// vault-op-poll page's 2s cadence so the wizard's two long-running
|
|
643
|
+
// surfaces (vault, post-vault notes/scribe) feel consistent.
|
|
644
|
+
const anyOpInFlight = tiles.some(
|
|
645
|
+
(t) => t.operation && (t.operation.status === "pending" || t.operation.status === "running"),
|
|
646
|
+
);
|
|
647
|
+
const refresh = anyOpInFlight ? 2 : undefined;
|
|
648
|
+
return baseDocument("Parachute hub — setup complete", body, refresh);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The MCP-connect tile. With a freshly-minted token the command renders
|
|
653
|
+
* fully formed with a `--header "Authorization: Bearer <token>"` flag +
|
|
654
|
+
* a Copy button. Without one, we fall back to the bare command + a
|
|
655
|
+
* pointer to `/admin/tokens` (the canonical mint surface). The Copy
|
|
656
|
+
* button is a tiny inline `<script>` — no SPA bundle, no module deps,
|
|
657
|
+
* the wizard stays server-rendered.
|
|
658
|
+
*/
|
|
659
|
+
function renderMcpTile(
|
|
660
|
+
vaultName: string,
|
|
661
|
+
hubOrigin: string,
|
|
662
|
+
mintedToken: string | undefined,
|
|
663
|
+
): string {
|
|
664
|
+
const safeVault = escapeHtml(vaultName);
|
|
665
|
+
const bareCmd = `claude mcp add --transport http parachute-${vaultName} ${hubOrigin}/vault/${vaultName}/mcp`;
|
|
666
|
+
if (mintedToken) {
|
|
667
|
+
// The token contents are surfaced once + then forgotten by the
|
|
668
|
+
// server (single-use hub_setting). Render the full command with
|
|
669
|
+
// the Bearer header pre-filled. The `--header` value is shell-
|
|
670
|
+
// quoted (double quotes) — bash + zsh both consume it as one arg.
|
|
671
|
+
const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
|
|
672
|
+
return `<div class="done-tile">
|
|
673
|
+
<h2>Connect Claude Code (MCP)</h2>
|
|
674
|
+
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
675
|
+
<div class="mcp-cmd-wrap">
|
|
676
|
+
<pre id="mcp-cmd">${escapeHtml(fullCmd)}</pre>
|
|
677
|
+
<button type="button" class="btn btn-copy" data-target="mcp-cmd"
|
|
678
|
+
onclick="(function(b){var el=document.getElementById(b.dataset.target);if(!el)return;navigator.clipboard.writeText(el.textContent||'').then(function(){b.textContent='Copied ✓';setTimeout(function(){b.textContent='Copy';},2000);});})(this)">Copy</button>
|
|
679
|
+
</div>
|
|
680
|
+
<p class="fine">We minted this token for your first MCP connection.
|
|
681
|
+
It's a full-scope operator token tied to your admin account; manage
|
|
682
|
+
and revoke tokens at <a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
|
|
683
|
+
</div>`;
|
|
684
|
+
}
|
|
685
|
+
return `<div class="done-tile">
|
|
686
|
+
<h2>Connect Claude Code (MCP)</h2>
|
|
687
|
+
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
688
|
+
<pre>${escapeHtml(bareCmd)}</pre>
|
|
689
|
+
<p class="fine">Mint an operator token at
|
|
690
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a> and append
|
|
691
|
+
<code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
|
|
692
|
+
</div>`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The "What's next?" install-tiles row (hub#272 Item B). One tile per
|
|
697
|
+
* curated module the operator might want next (Notes, Scribe). Each
|
|
698
|
+
* tile is either an install form (POST → /admin/setup/install/<short>
|
|
699
|
+
* → 303 to /admin/setup?op_<short>=<id>) or an op-poll panel mirroring
|
|
700
|
+
* the vault-step's op-poll shape.
|
|
701
|
+
*/
|
|
702
|
+
function renderInstallTiles(tiles: readonly ModuleInstallTileState[]): string {
|
|
703
|
+
const items = tiles.map((t) => renderInstallTile(t)).join("");
|
|
704
|
+
return `<section class="install-tiles">
|
|
705
|
+
<h2 class="install-tiles-heading">What's next?</h2>
|
|
706
|
+
<p class="install-tiles-subtitle">Install another module — these run alongside your vault on the same hub.</p>
|
|
707
|
+
<div class="install-grid">${items}</div>
|
|
708
|
+
</section>`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
712
|
+
const safeShort = escapeHtml(tile.short);
|
|
713
|
+
const safeName = escapeHtml(tile.displayName);
|
|
714
|
+
const safeTagline = escapeHtml(tile.tagline);
|
|
715
|
+
if (tile.operation) {
|
|
716
|
+
const op = tile.operation;
|
|
717
|
+
const logLines = op.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
718
|
+
const errBanner = op.error ? `<p class="error-banner">${escapeHtml(op.error)}</p>` : "";
|
|
719
|
+
// Terminal state (succeeded / failed) gets either a confirmation
|
|
720
|
+
// link or a retry form. Pending / running renders the live log
|
|
721
|
+
// panel and relies on the parent `<meta http-equiv="refresh">` for
|
|
722
|
+
// the next tick — no per-tile refresh needed (one full-page reload
|
|
723
|
+
// catches every in-flight op at once).
|
|
724
|
+
let actions = "";
|
|
725
|
+
if (op.status === "succeeded") {
|
|
726
|
+
actions = `<p><a class="btn btn-secondary" href="/admin/modules">Manage modules</a></p>`;
|
|
727
|
+
} else if (op.status === "failed") {
|
|
728
|
+
actions = `<form method="POST" action="/admin/setup/install/${safeShort}" class="install-retry">
|
|
729
|
+
${renderInstallTileCsrfPlaceholder()}
|
|
730
|
+
<button type="submit" class="btn btn-secondary">Retry install</button>
|
|
731
|
+
</form>`;
|
|
732
|
+
}
|
|
733
|
+
return `<div class="install-tile install-tile-${op.status}">
|
|
734
|
+
<h3>${safeName}</h3>
|
|
735
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
736
|
+
${errBanner}
|
|
737
|
+
<section class="op-log install-tile-log">
|
|
738
|
+
<p class="op-status op-${op.status}">status: ${op.status}</p>
|
|
739
|
+
<ol class="log-lines">${logLines}</ol>
|
|
740
|
+
</section>
|
|
741
|
+
${actions}
|
|
742
|
+
</div>`;
|
|
743
|
+
}
|
|
744
|
+
if (tile.alreadyInstalled) {
|
|
745
|
+
return `<div class="install-tile install-tile-installed">
|
|
746
|
+
<h3>${safeName}</h3>
|
|
747
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
748
|
+
<p class="install-tile-status">Already installed.</p>
|
|
749
|
+
<p><a class="btn btn-secondary" href="/admin/modules">Manage in admin</a></p>
|
|
750
|
+
</div>`;
|
|
751
|
+
}
|
|
752
|
+
return `<div class="install-tile">
|
|
753
|
+
<h3>${safeName}</h3>
|
|
754
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
755
|
+
<form method="POST" action="/admin/setup/install/${safeShort}" class="install-tile-form">
|
|
756
|
+
${renderInstallTileCsrfPlaceholder()}
|
|
757
|
+
<button type="submit" class="btn btn-primary">Install ${safeName}</button>
|
|
758
|
+
</form>
|
|
759
|
+
</div>`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* CSRF token placeholder for install-tile forms. The token comes from
|
|
764
|
+
* the wizard's per-request CSRF cookie; rendered by the parent step's
|
|
765
|
+
* `csrfToken` plumbing. Threaded through `renderDoneStep` props rather
|
|
766
|
+
* than read here directly because the tile renderer is a pure function
|
|
767
|
+
* the test surface can exercise without a request object.
|
|
768
|
+
*
|
|
769
|
+
* Currently rendered as a marker that the parent renderer rewrites
|
|
770
|
+
* before serving — keeps the per-tile shape pure but avoids dragging
|
|
771
|
+
* a CSRF token argument into every tile-shape function.
|
|
772
|
+
*/
|
|
773
|
+
function renderInstallTileCsrfPlaceholder(): string {
|
|
774
|
+
return INSTALL_TILE_CSRF_PLACEHOLDER;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const INSTALL_TILE_CSRF_PLACEHOLDER = "__INSTALL_TILE_CSRF__";
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Render the "Your hub is reachable at" tile on the done step, shaped by
|
|
781
|
+
* the operator's expose-mode choice. Always surfaces the loopback URL as
|
|
782
|
+
* an anchor (the operator's own browser hits the wizard on it); the
|
|
783
|
+
* tail-end instructions reframe based on what they picked.
|
|
784
|
+
*/
|
|
785
|
+
function renderReachableTile(mode: SetupExposeMode, hubOrigin: string): string {
|
|
786
|
+
const safeOrigin = escapeHtml(hubOrigin);
|
|
787
|
+
if (mode === "localhost") {
|
|
788
|
+
return `<section class="reachable">
|
|
789
|
+
<h2>Your hub is reachable at</h2>
|
|
790
|
+
<p class="reachable-url"><code>${safeOrigin}</code></p>
|
|
791
|
+
<p class="fine">Local to this machine only. Want to share it with your
|
|
792
|
+
other devices? Re-visit setup later from the admin UI or run
|
|
793
|
+
<code>tailscale serve --bg --https=1939 http://localhost:1939</code>
|
|
794
|
+
from a terminal.</p>
|
|
795
|
+
</section>`;
|
|
796
|
+
}
|
|
797
|
+
if (mode === "tailnet") {
|
|
798
|
+
return `<section class="reachable">
|
|
799
|
+
<h2>Your hub is reachable at</h2>
|
|
800
|
+
<p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
|
|
801
|
+
<p class="reachable-url">Plus your tailnet URL once you run:</p>
|
|
802
|
+
<pre>tailscale serve --bg --https=1939 http://localhost:1939</pre>
|
|
803
|
+
<p class="fine">The Tailscale CLI prints the public hostname (e.g.
|
|
804
|
+
<code>my-mac.tailnet-name.ts.net</code>); use that on your phone /
|
|
805
|
+
other devices.</p>
|
|
806
|
+
</section>`;
|
|
807
|
+
}
|
|
808
|
+
// public
|
|
809
|
+
return `<section class="reachable">
|
|
810
|
+
<h2>Your hub is reachable at</h2>
|
|
811
|
+
<p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
|
|
812
|
+
<p class="fine">Wire a reverse proxy on your domain to
|
|
813
|
+
<code>${safeOrigin}</code>, then set <code>PARACHUTE_HUB_ORIGIN</code>
|
|
814
|
+
to your public URL and restart the hub. See the
|
|
815
|
+
<a href="https://parachute.computer/docs/deploy">deploy guide</a>
|
|
816
|
+
for nginx / Caddy / Cloudflare Tunnel examples.</p>
|
|
817
|
+
</section>`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// --- handler entry points ------------------------------------------------
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* GET `/admin/setup`. Derives state, renders the appropriate step.
|
|
824
|
+
*
|
|
825
|
+
* Once the wizard's work is done (admin + vault both exist), GET 301s
|
|
826
|
+
* to `/login` so a stale bookmark lands somewhere useful — UNLESS the
|
|
827
|
+
* caller's `?just_finished=1` query is set, in which case we render the
|
|
828
|
+
* step-4 done screen one more time. The wizard's own success redirect
|
|
829
|
+
* uses `?just_finished=1` so the operator sees step 4 even though state
|
|
830
|
+
* is already "complete."
|
|
831
|
+
*/
|
|
832
|
+
export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
833
|
+
const url = new URL(req.url);
|
|
834
|
+
const state = deriveWizardState(deps);
|
|
835
|
+
const csrf = ensureCsrfToken(req);
|
|
836
|
+
const extraHeaders: Record<string, string> = {
|
|
837
|
+
"content-type": "text/html; charset=utf-8",
|
|
838
|
+
};
|
|
839
|
+
if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
|
|
840
|
+
|
|
841
|
+
// Setup fully complete (including expose-mode choice) — redirect to
|
|
842
|
+
// /login unless we're rendering the success page once. The success
|
|
843
|
+
// page sets `?just_finished=1` and the session cookie is on the
|
|
844
|
+
// request from step 2.
|
|
845
|
+
if (state.hasAdmin && state.hasVault && state.hasExposeMode) {
|
|
846
|
+
if (url.searchParams.get("just_finished") === "1") {
|
|
847
|
+
// hub#274 security fold: session-gate this branch. The
|
|
848
|
+
// `?just_finished=1` GET reads + consumes `setup_minted_token`
|
|
849
|
+
// (full-scope operator JWT) below; without a session check, any
|
|
850
|
+
// HTTP client that races the operator's browser between the
|
|
851
|
+
// expose POST (which writes the row) and the done GET (which
|
|
852
|
+
// reads it) walks off with admin-scope creds. The dispatcher
|
|
853
|
+
// in `hub-server.ts`'s `shouldGateForSetup` lets `/admin/setup*`
|
|
854
|
+
// through the pre-admin lockout, and that path stays open
|
|
855
|
+
// post-setup — so this gate has to live here, not at the
|
|
856
|
+
// dispatcher layer.
|
|
857
|
+
//
|
|
858
|
+
// A legitimate operator carrying the session cookie minted on
|
|
859
|
+
// the account POST sails through. A drive-by GET without the
|
|
860
|
+
// cookie 302s to /login: if it's a stale bookmark in the
|
|
861
|
+
// operator's other tab, they sign in + the row is already
|
|
862
|
+
// consumed by the legitimate done-GET (the single-use shape
|
|
863
|
+
// guarantees they see the fallback shape, never the secret).
|
|
864
|
+
// If it's an attacker, they can't pass /login without the
|
|
865
|
+
// password.
|
|
866
|
+
const session = findActiveSession(deps.db, req);
|
|
867
|
+
if (!session) {
|
|
868
|
+
// Preserve the CSRF set-cookie header on the 302 — same shape as
|
|
869
|
+
// every other branch of this handler. Without it, a freshly
|
|
870
|
+
// assigned CSRF token would be lost across the redirect, and
|
|
871
|
+
// form posts from a sign-in-then-come-back flow would 400 on
|
|
872
|
+
// their first attempt.
|
|
873
|
+
const redirectHeaders: Record<string, string> = { location: "/login" };
|
|
874
|
+
if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
|
|
875
|
+
return new Response(null, {
|
|
876
|
+
status: 302,
|
|
877
|
+
headers: redirectHeaders,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
const stored = getSetting(deps.db, "setup_expose_mode");
|
|
881
|
+
const exposeMode = isSetupExposeMode(stored) ? stored : undefined;
|
|
882
|
+
// hub#272 Item A: read + consume the single-use minted-token row.
|
|
883
|
+
// Render-and-forget keeps the secret from re-appearing on
|
|
884
|
+
// refresh / back-button. The mint is non-fatal (see expose POST);
|
|
885
|
+
// its absence renders the bare MCP command + a hint at
|
|
886
|
+
// /admin/tokens.
|
|
887
|
+
const mintedToken = getSetting(deps.db, "setup_minted_token");
|
|
888
|
+
if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
|
|
889
|
+
// hub#267: the operator-typed vault name lives in hub_settings
|
|
890
|
+
// (persisted by handleSetupVaultPost). Fall back to scanning
|
|
891
|
+
// services.json — covers wizard runs from before this PR where
|
|
892
|
+
// setup_vault_name wasn't written. The services.json read
|
|
893
|
+
// returns the path-tail; vault's own first-boot write produces
|
|
894
|
+
// the canonical name so the two should agree once the vault
|
|
895
|
+
// boots authoritatively.
|
|
896
|
+
const storedName = getSetting(deps.db, "setup_vault_name");
|
|
897
|
+
const vaultName = storedName ?? firstVaultName(deps.manifestPath);
|
|
898
|
+
// Module install tiles (hub#272 Item B). One per curated module
|
|
899
|
+
// other than vault (which the wizard already provisioned).
|
|
900
|
+
const installTiles = buildInstallTiles(url, deps);
|
|
901
|
+
const doneProps: RenderDoneStepProps = {
|
|
902
|
+
vaultName,
|
|
903
|
+
hubOrigin: deps.issuer,
|
|
904
|
+
installTiles,
|
|
905
|
+
};
|
|
906
|
+
if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
|
|
907
|
+
if (mintedToken) doneProps.mintedToken = mintedToken;
|
|
908
|
+
// Substitute CSRF placeholder for the install-tile forms with
|
|
909
|
+
// the current CSRF token. Keeping the per-tile renderer pure
|
|
910
|
+
// means the substitution lives here (one rewrite per render).
|
|
911
|
+
const html = renderDoneStep(doneProps).replaceAll(
|
|
912
|
+
INSTALL_TILE_CSRF_PLACEHOLDER,
|
|
913
|
+
renderCsrfHiddenInput(csrf.token),
|
|
914
|
+
);
|
|
915
|
+
return new Response(html, {
|
|
916
|
+
status: 200,
|
|
917
|
+
headers: extraHeaders,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
return new Response(null, { status: 301, headers: { location: "/login" } });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Expose step (hub#268 Item 2). Admin + vault exist, but the operator
|
|
924
|
+
// hasn't picked an expose mode yet. The wizard form posts to
|
|
925
|
+
// /admin/setup/expose. Gated on having an admin session (the session
|
|
926
|
+
// cookie was minted on step 2); on a stale tab without it, the post
|
|
927
|
+
// handler shows the no-session error.
|
|
928
|
+
if (state.hasAdmin && state.hasVault && !state.hasExposeMode) {
|
|
929
|
+
return new Response(renderExposeStep({ csrfToken: csrf.token }), {
|
|
930
|
+
status: 200,
|
|
931
|
+
headers: extraHeaders,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Step 3 (vault) with an op in flight — render the poll page.
|
|
936
|
+
if (state.hasAdmin && !state.hasVault) {
|
|
937
|
+
const opId = url.searchParams.get("op");
|
|
938
|
+
if (opId) {
|
|
939
|
+
const registry = deps.registry;
|
|
940
|
+
const op = registry?.get(opId);
|
|
941
|
+
if (op) {
|
|
942
|
+
return new Response(
|
|
943
|
+
renderVaultStep({
|
|
944
|
+
csrfToken: csrf.token,
|
|
945
|
+
operation: {
|
|
946
|
+
id: op.id,
|
|
947
|
+
status: op.status,
|
|
948
|
+
log: op.log,
|
|
949
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
950
|
+
},
|
|
951
|
+
}),
|
|
952
|
+
{ status: 200, headers: extraHeaders },
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return new Response(renderVaultStep({ csrfToken: csrf.token }), {
|
|
957
|
+
status: 200,
|
|
958
|
+
headers: extraHeaders,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Step 1+2 (no admin yet).
|
|
963
|
+
return new Response(renderAccountStep({ csrfToken: csrf.token }), {
|
|
964
|
+
status: 200,
|
|
965
|
+
headers: extraHeaders,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* POST `/admin/setup/account`. Form-encoded.
|
|
971
|
+
*
|
|
972
|
+
* Validates CSRF + form fields, creates the admin row, mints a session
|
|
973
|
+
* cookie, redirects to `/admin/setup` (which then renders step 3).
|
|
974
|
+
*
|
|
975
|
+
* Rejects if a user already exists — re-arriving here after step 2 is
|
|
976
|
+
* either a stale tab or a malicious double-submit; either way the right
|
|
977
|
+
* answer is "you're done with this step, go to /admin/setup."
|
|
978
|
+
*/
|
|
979
|
+
export async function handleSetupAccountPost(
|
|
980
|
+
req: Request,
|
|
981
|
+
deps: SetupWizardDeps,
|
|
982
|
+
): Promise<Response> {
|
|
983
|
+
const form = await req.formData();
|
|
984
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
985
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
986
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
987
|
+
}
|
|
988
|
+
// Already-bootstrapped: bounce. The wizard's GET state will resolve to
|
|
989
|
+
// step 3 or step 4 on the next request.
|
|
990
|
+
if (userCount(deps.db) > 0) {
|
|
991
|
+
return redirect("/admin/setup");
|
|
992
|
+
}
|
|
993
|
+
const username = String(form.get("username") ?? "").trim();
|
|
994
|
+
const password = String(form.get("password") ?? "");
|
|
995
|
+
const confirm = String(form.get("password_confirm") ?? "");
|
|
996
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
997
|
+
const fieldErr = validateAccountFields({ username, password, confirm });
|
|
998
|
+
if (fieldErr) {
|
|
999
|
+
return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const user = await createUser(deps.db, username, password);
|
|
1003
|
+
const session = createSession(deps.db, { userId: user.id });
|
|
1004
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
1005
|
+
secure: isHttpsRequest(req),
|
|
1006
|
+
});
|
|
1007
|
+
return redirect("/admin/setup", { "set-cookie": cookie });
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
// Log the raw error server-side for the operator's debugging, but
|
|
1010
|
+
// surface a fixed string to the browser — raw SQLite / argon2
|
|
1011
|
+
// messages leak schema details and aren't actionable for the
|
|
1012
|
+
// person filling out the form. The likely cause for a sane input
|
|
1013
|
+
// is the username-taken UNIQUE collision (createUser raises
|
|
1014
|
+
// UsernameTakenError); other paths (filesystem, argon2 native)
|
|
1015
|
+
// are rare and the same generic message lands the operator at the
|
|
1016
|
+
// right place: retry, or `parachute auth set-password` from the
|
|
1017
|
+
// shell.
|
|
1018
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1019
|
+
console.warn(`[setup-wizard] createUser failed for "${username}": ${msg}`);
|
|
1020
|
+
return htmlResponse(
|
|
1021
|
+
renderAccountStep({
|
|
1022
|
+
csrfToken,
|
|
1023
|
+
username,
|
|
1024
|
+
errorMessage: "Failed to create account. The username may already be taken.",
|
|
1025
|
+
}),
|
|
1026
|
+
400,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* POST `/admin/setup/vault`. Form-encoded.
|
|
1033
|
+
*
|
|
1034
|
+
* Gated by the admin session cookie set at step 2 — a stale tab without
|
|
1035
|
+
* the cookie won't accidentally try to provision a vault. The session is
|
|
1036
|
+
* also valid evidence that the operator who created the admin is the
|
|
1037
|
+
* same one driving step 3 (they're necessarily the only user in
|
|
1038
|
+
* single-user mode).
|
|
1039
|
+
*
|
|
1040
|
+
* Drives `runInstall` directly (not the bearer-gated `handleInstall`).
|
|
1041
|
+
* The bearer check exists to keep narrow `:auth`-scope automation
|
|
1042
|
+
* tokens from hitting destructive endpoints; the wizard is already
|
|
1043
|
+
* gated on session + on "no vault exists yet," so a separate
|
|
1044
|
+
* bearer-mint dance would be pure ceremony.
|
|
1045
|
+
*
|
|
1046
|
+
* Returns a 303-redirect to `/admin/setup?op=<id>` so the wizard's
|
|
1047
|
+
* polling GET shape kicks in. The actual `bun add` runs in the
|
|
1048
|
+
* background; failures surface in the op log.
|
|
1049
|
+
*/
|
|
1050
|
+
export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps): Promise<Response> {
|
|
1051
|
+
if (!deps.supervisor) {
|
|
1052
|
+
return badRequestPage(
|
|
1053
|
+
"Module supervisor unavailable",
|
|
1054
|
+
"The first-boot wizard needs container-mode `parachute serve` to install modules. " +
|
|
1055
|
+
"On the on-box CLI surface, run `parachute install vault` directly.",
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const form = await req.formData();
|
|
1059
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1060
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1061
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1062
|
+
}
|
|
1063
|
+
const session = findActiveSession(deps.db, req);
|
|
1064
|
+
if (!session) {
|
|
1065
|
+
return badRequestPage(
|
|
1066
|
+
"No admin session",
|
|
1067
|
+
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
// Already done — short-circuit to the done step.
|
|
1071
|
+
const state = deriveWizardState(deps);
|
|
1072
|
+
if (state.hasVault) return redirect("/admin/setup?just_finished=1");
|
|
1073
|
+
|
|
1074
|
+
// hub#267: the operator-typed vault name is now threaded all the way
|
|
1075
|
+
// through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
|
|
1076
|
+
// shipped the env-var read in vault's `server.ts`). Empty input
|
|
1077
|
+
// falls back to the canonical `DEFAULT_VAULT_NAME` so the "just give
|
|
1078
|
+
// me a vault" path still works without typing anything.
|
|
1079
|
+
const csrfTokenStr = typeof formCsrf === "string" ? formCsrf : "";
|
|
1080
|
+
const rawName = String(form.get("vault_name") ?? "").trim();
|
|
1081
|
+
let vaultName: string;
|
|
1082
|
+
if (rawName === "") {
|
|
1083
|
+
vaultName = DEFAULT_VAULT_NAME;
|
|
1084
|
+
} else {
|
|
1085
|
+
const v = validateVaultName(rawName);
|
|
1086
|
+
if (!v.ok) {
|
|
1087
|
+
return htmlResponse(
|
|
1088
|
+
renderVaultStep({
|
|
1089
|
+
csrfToken: csrfTokenStr,
|
|
1090
|
+
vaultName: rawName,
|
|
1091
|
+
errorMessage: v.error,
|
|
1092
|
+
}),
|
|
1093
|
+
400,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
vaultName = v.name;
|
|
1097
|
+
}
|
|
1098
|
+
// Persist for the done-step renderer. Vault overwrites services.json
|
|
1099
|
+
// on its first authoritative boot, but until that completes the wizard
|
|
1100
|
+
// needs a stable source of truth for the typed name — both for the
|
|
1101
|
+
// op-poll page subtitle and the post-redirect done step.
|
|
1102
|
+
setSetting(deps.db, "setup_vault_name", vaultName);
|
|
1103
|
+
const registry = deps.registry;
|
|
1104
|
+
const vaultSpec = specFor(FIRST_VAULT_SHORT);
|
|
1105
|
+
|
|
1106
|
+
// Idempotent short-circuit: if the supervisor is already running (or
|
|
1107
|
+
// mid-spawn) for vault — i.e. a previous POST already kicked off
|
|
1108
|
+
// `runInstall` and beat us to spawning — return a synthesized
|
|
1109
|
+
// succeeded op instead of firing a second `bun add -g`. Mirrors the
|
|
1110
|
+
// pattern in `handleInstall` (api-modules-ops.ts). Without this,
|
|
1111
|
+
// two concurrent POSTs both pass `state.hasVault === false` (the
|
|
1112
|
+
// services.json seed is the only signal that step exits, and it's
|
|
1113
|
+
// written by `runInstall` *after* `bun add` returns), and each
|
|
1114
|
+
// fires its own install — wasted work and a possible race on the
|
|
1115
|
+
// seed/spawn writes. Low risk on first-boot in practice, but the
|
|
1116
|
+
// fix is cheap and matches the API surface's posture.
|
|
1117
|
+
const supervisorState = deps.supervisor.get(FIRST_VAULT_SHORT);
|
|
1118
|
+
if (
|
|
1119
|
+
supervisorState?.status === "running" ||
|
|
1120
|
+
supervisorState?.status === "starting" ||
|
|
1121
|
+
supervisorState?.status === "restarting"
|
|
1122
|
+
) {
|
|
1123
|
+
if (registry) {
|
|
1124
|
+
const op = registry.create("install", FIRST_VAULT_SHORT);
|
|
1125
|
+
registry.update(
|
|
1126
|
+
op.id,
|
|
1127
|
+
{ status: "succeeded" },
|
|
1128
|
+
`${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
|
|
1129
|
+
);
|
|
1130
|
+
return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
|
|
1131
|
+
}
|
|
1132
|
+
return redirect("/admin/setup");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const op = registry
|
|
1136
|
+
? registry.create("install", FIRST_VAULT_SHORT)
|
|
1137
|
+
: { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
|
|
1138
|
+
if (registry) {
|
|
1139
|
+
// hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
|
|
1140
|
+
// vault's first-boot path (vault#342) names the created vault
|
|
1141
|
+
// accordingly. Skip the env override when the operator left the
|
|
1142
|
+
// field blank — vault's `resolveFirstBootVaultName` defaults to
|
|
1143
|
+
// `default` on absent env vars, so this preserves the prior
|
|
1144
|
+
// behaviour for the empty-input case.
|
|
1145
|
+
//
|
|
1146
|
+
// If the operator typed "default" explicitly, treat the same as
|
|
1147
|
+
// blank — vault's first-boot defaults to "default" anyway, so
|
|
1148
|
+
// skipping the env override is correct (the comparison below
|
|
1149
|
+
// catches both blank-trimmed-to-DEFAULT and typed-"default").
|
|
1150
|
+
const spawnEnv: Record<string, string> = {};
|
|
1151
|
+
if (vaultName !== DEFAULT_VAULT_NAME) {
|
|
1152
|
+
spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
|
|
1153
|
+
}
|
|
1154
|
+
void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
|
|
1155
|
+
db: deps.db,
|
|
1156
|
+
issuer: deps.issuer,
|
|
1157
|
+
manifestPath: deps.manifestPath,
|
|
1158
|
+
configDir: deps.configDir,
|
|
1159
|
+
supervisor: deps.supervisor,
|
|
1160
|
+
registry,
|
|
1161
|
+
...(deps.run ? { run: deps.run } : {}),
|
|
1162
|
+
...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
|
|
1163
|
+
}).catch((err) => {
|
|
1164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1165
|
+
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
1166
|
+
});
|
|
1167
|
+
} else {
|
|
1168
|
+
// No registry wired (test-only path; production always passes one).
|
|
1169
|
+
// Log a visible warning so future mis-wirings are debuggable —
|
|
1170
|
+
// silent swallow here would make the wizard appear to hang.
|
|
1171
|
+
console.warn(
|
|
1172
|
+
"[setup-wizard] handleSetupVaultPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* POST `/admin/setup/expose`. Form-encoded.
|
|
1180
|
+
*
|
|
1181
|
+
* Persists the operator's "how will this hub be reached?" answer to
|
|
1182
|
+
* `hub_settings.setup_expose_mode` (hub#268 Item 2). Three valid values:
|
|
1183
|
+
* `localhost`, `tailnet`, `public`.
|
|
1184
|
+
*
|
|
1185
|
+
* This is also the transition where the wizard considers itself "done"
|
|
1186
|
+
* for the auto-approve-first-client feature (hub#268 Item 3): we open a
|
|
1187
|
+
* 60-minute window where the next OAuth client registration is
|
|
1188
|
+
* auto-approved. Reasoning lives in `hub-settings.ts`; the wizard just
|
|
1189
|
+
* fires it on the only event that means "operator just finished the
|
|
1190
|
+
* canonical onboarding."
|
|
1191
|
+
*
|
|
1192
|
+
* Gated on an admin session cookie like the vault POST is — same shape,
|
|
1193
|
+
* same reason.
|
|
1194
|
+
*/
|
|
1195
|
+
export async function handleSetupExposePost(
|
|
1196
|
+
req: Request,
|
|
1197
|
+
deps: SetupWizardDeps,
|
|
1198
|
+
): Promise<Response> {
|
|
1199
|
+
const form = await req.formData();
|
|
1200
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1201
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1202
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1203
|
+
}
|
|
1204
|
+
const session = findActiveSession(deps.db, req);
|
|
1205
|
+
if (!session) {
|
|
1206
|
+
return badRequestPage(
|
|
1207
|
+
"No admin session",
|
|
1208
|
+
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
// Already done — short-circuit to the success screen. Belt-and-braces:
|
|
1212
|
+
// the wizard's GET shape catches this case too, but a direct POST
|
|
1213
|
+
// (curl, tab race) shouldn't double-fire the auto-approve window.
|
|
1214
|
+
if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
|
|
1215
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1216
|
+
}
|
|
1217
|
+
const rawMode = form.get("expose_mode");
|
|
1218
|
+
if (!isSetupExposeMode(rawMode)) {
|
|
1219
|
+
return htmlResponse(
|
|
1220
|
+
renderExposeStep({
|
|
1221
|
+
csrfToken: typeof formCsrf === "string" ? formCsrf : "",
|
|
1222
|
+
errorMessage: `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
|
|
1223
|
+
}),
|
|
1224
|
+
400,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
setSetting(deps.db, "setup_expose_mode", rawMode);
|
|
1228
|
+
// hub#268 Item 3: open the 60-minute auto-approve window for the first
|
|
1229
|
+
// OAuth client registration. Logged so an operator chasing odd behavior
|
|
1230
|
+
// can see it fired.
|
|
1231
|
+
openFirstClientAutoApproveWindow(deps.db);
|
|
1232
|
+
console.log(
|
|
1233
|
+
`[setup-wizard] opened first-client auto-approve window (60min) after expose-mode=${rawMode}`,
|
|
1234
|
+
);
|
|
1235
|
+
// hub#272 Item A: auto-mint an operator token under the broad `admin`
|
|
1236
|
+
// scope-set + persist it once so the done-step renderer can pre-fill
|
|
1237
|
+
// the MCP install command with a Bearer header. The token is single-
|
|
1238
|
+
// use surface on the done page — the renderer deletes it from
|
|
1239
|
+
// hub_settings after one read so a stale tab refresh / back button
|
|
1240
|
+
// doesn't re-disclose the secret. The jti is still in the `tokens`
|
|
1241
|
+
// registry so revocation via the admin UI works as usual. Failures
|
|
1242
|
+
// are non-fatal: the done page falls back to the un-headered MCP
|
|
1243
|
+
// command + a "mint manually at /admin/tokens" hint.
|
|
1244
|
+
try {
|
|
1245
|
+
const minted = await mintOperatorToken(deps.db, session.userId, {
|
|
1246
|
+
issuer: deps.issuer,
|
|
1247
|
+
scopeSet: "admin",
|
|
1248
|
+
});
|
|
1249
|
+
setSetting(deps.db, "setup_minted_token", minted.token);
|
|
1250
|
+
console.log(
|
|
1251
|
+
`[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
|
|
1252
|
+
);
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1255
|
+
console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
|
|
1256
|
+
}
|
|
1257
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// --- step 5 helpers: install tiles --------------------------------------
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Curated module short → display props rendered on the done-screen
|
|
1264
|
+
* install tiles. Order matters — list order is render order. Vault is
|
|
1265
|
+
* intentionally excluded (the wizard already provisioned it).
|
|
1266
|
+
*
|
|
1267
|
+
* `tagline` mirrors each module's `displayName + tagline` from
|
|
1268
|
+
* `FIRST_PARTY_FALLBACKS` (`src/service-spec.ts`); kept verbatim here
|
|
1269
|
+
* so the wizard isn't coupled to service-spec internals.
|
|
1270
|
+
*/
|
|
1271
|
+
const INSTALL_TILE_PROPS: ReadonlyArray<{
|
|
1272
|
+
short: CuratedModuleShort;
|
|
1273
|
+
displayName: string;
|
|
1274
|
+
tagline: string;
|
|
1275
|
+
}> = [
|
|
1276
|
+
{ short: "notes", displayName: "Notes", tagline: "Notes PWA backed by your vault." },
|
|
1277
|
+
{
|
|
1278
|
+
short: "scribe",
|
|
1279
|
+
displayName: "Scribe",
|
|
1280
|
+
tagline: "Local audio transcription for vault recordings.",
|
|
1281
|
+
},
|
|
1282
|
+
];
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Construct the install-tile state array for the done step. Reads the
|
|
1286
|
+
* URL's `?op_<short>=<id>` query (per-module op-poll), the services.json
|
|
1287
|
+
* manifest (already-installed detection), and the operations registry
|
|
1288
|
+
* (op status snapshot). Pure-ish — only the registry call is impure.
|
|
1289
|
+
*/
|
|
1290
|
+
function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
|
|
1291
|
+
const manifest = readManifest(deps.manifestPath);
|
|
1292
|
+
return INSTALL_TILE_PROPS.filter((p) =>
|
|
1293
|
+
(CURATED_MODULES as readonly string[]).includes(p.short),
|
|
1294
|
+
).map((p) => {
|
|
1295
|
+
const spec = specFor(p.short);
|
|
1296
|
+
const alreadyInstalled = manifest.services.some((s) => s.name === spec.manifestName);
|
|
1297
|
+
const tile: ModuleInstallTileState = {
|
|
1298
|
+
short: p.short,
|
|
1299
|
+
displayName: p.displayName,
|
|
1300
|
+
tagline: p.tagline,
|
|
1301
|
+
alreadyInstalled,
|
|
1302
|
+
};
|
|
1303
|
+
const opId = url.searchParams.get(`op_${p.short}`);
|
|
1304
|
+
if (opId && deps.registry) {
|
|
1305
|
+
const op = deps.registry.get(opId);
|
|
1306
|
+
if (op) {
|
|
1307
|
+
tile.operation = {
|
|
1308
|
+
id: op.id,
|
|
1309
|
+
status: op.status,
|
|
1310
|
+
log: op.log,
|
|
1311
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return tile;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* POST `/admin/setup/install/<short>`. Form-encoded, session-gated.
|
|
1321
|
+
*
|
|
1322
|
+
* Kicks off the same `runInstall` pipeline `/api/modules/<short>/install`
|
|
1323
|
+
* uses (hub#260) but from the wizard's session-cookie surface — no
|
|
1324
|
+
* separate bearer mint dance for the operator who just finished the
|
|
1325
|
+
* wizard.
|
|
1326
|
+
*
|
|
1327
|
+
* Returns 303 to `/admin/setup?just_finished=1&op_<short>=<opId>` so
|
|
1328
|
+
* the done-screen renderer picks up the op via `buildInstallTiles`.
|
|
1329
|
+
* Multiple in-flight installs are supported (query keeps `op_<short>`
|
|
1330
|
+
* per module); the auto-refresh meta keeps polling while any module
|
|
1331
|
+
* is pending/running.
|
|
1332
|
+
*
|
|
1333
|
+
* Rejects when:
|
|
1334
|
+
* * `short` isn't a curated module short
|
|
1335
|
+
* * `short === "vault"` — the wizard's vault step owns that
|
|
1336
|
+
* * session cookie missing
|
|
1337
|
+
* * CSRF token missing or wrong
|
|
1338
|
+
* * supervisor isn't wired (CLI-mode hub)
|
|
1339
|
+
*/
|
|
1340
|
+
export async function handleSetupInstallPost(
|
|
1341
|
+
req: Request,
|
|
1342
|
+
short: string,
|
|
1343
|
+
deps: SetupWizardDeps,
|
|
1344
|
+
): Promise<Response> {
|
|
1345
|
+
if (!deps.supervisor) {
|
|
1346
|
+
return badRequestPage(
|
|
1347
|
+
"Module supervisor unavailable",
|
|
1348
|
+
`Module installs from the wizard require container-mode \`parachute serve\`. On the on-box CLI surface, run \`parachute install ${short}\` directly.`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
if (!(CURATED_MODULES as readonly string[]).includes(short) || short === "vault") {
|
|
1352
|
+
return badRequestPage(
|
|
1353
|
+
"Unknown module",
|
|
1354
|
+
`"${short}" is not an installable wizard module. Pick from the done-screen tiles.`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
const form = await req.formData();
|
|
1358
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1359
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1360
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1361
|
+
}
|
|
1362
|
+
const session = findActiveSession(deps.db, req);
|
|
1363
|
+
if (!session) {
|
|
1364
|
+
return badRequestPage(
|
|
1365
|
+
"No admin session",
|
|
1366
|
+
"Sign in to continue. The wizard's session cookie was set at step 2; clearing cookies between steps lands you here.",
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
const moduleShort = short as CuratedModuleShort;
|
|
1370
|
+
const spec = specFor(moduleShort);
|
|
1371
|
+
const registry = deps.registry;
|
|
1372
|
+
// Idempotent short-circuit: if already supervised + running, return a
|
|
1373
|
+
// synthesized succeeded op rather than firing a second `bun add`.
|
|
1374
|
+
// Mirrors `handleSetupVaultPost` + `handleInstall`.
|
|
1375
|
+
const supervisorState = deps.supervisor.get(moduleShort);
|
|
1376
|
+
if (
|
|
1377
|
+
supervisorState?.status === "running" ||
|
|
1378
|
+
supervisorState?.status === "starting" ||
|
|
1379
|
+
supervisorState?.status === "restarting"
|
|
1380
|
+
) {
|
|
1381
|
+
if (registry) {
|
|
1382
|
+
const op = registry.create("install", moduleShort);
|
|
1383
|
+
registry.update(
|
|
1384
|
+
op.id,
|
|
1385
|
+
{ status: "succeeded" },
|
|
1386
|
+
`${moduleShort} already supervised (status=${supervisorState.status})`,
|
|
1387
|
+
);
|
|
1388
|
+
return redirect(
|
|
1389
|
+
`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`,
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1393
|
+
}
|
|
1394
|
+
const op = registry
|
|
1395
|
+
? registry.create("install", moduleShort)
|
|
1396
|
+
: { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
|
|
1397
|
+
if (registry) {
|
|
1398
|
+
void runInstall(op.id, moduleShort, spec, {
|
|
1399
|
+
db: deps.db,
|
|
1400
|
+
issuer: deps.issuer,
|
|
1401
|
+
manifestPath: deps.manifestPath,
|
|
1402
|
+
configDir: deps.configDir,
|
|
1403
|
+
supervisor: deps.supervisor,
|
|
1404
|
+
registry,
|
|
1405
|
+
...(deps.run ? { run: deps.run } : {}),
|
|
1406
|
+
}).catch((err) => {
|
|
1407
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1408
|
+
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
1409
|
+
});
|
|
1410
|
+
} else {
|
|
1411
|
+
console.warn(
|
|
1412
|
+
"[setup-wizard] handleSetupInstallPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
return redirect(`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// --- helpers ------------------------------------------------------------
|
|
1419
|
+
|
|
1420
|
+
function validateAccountFields(input: {
|
|
1421
|
+
username: string;
|
|
1422
|
+
password: string;
|
|
1423
|
+
confirm: string;
|
|
1424
|
+
}): string | undefined {
|
|
1425
|
+
if (input.username.length < 2 || input.username.length > 64) {
|
|
1426
|
+
return "Username must be 2–64 characters.";
|
|
1427
|
+
}
|
|
1428
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(input.username)) {
|
|
1429
|
+
return "Username may use letters, digits, underscore, period, hyphen.";
|
|
1430
|
+
}
|
|
1431
|
+
if (input.password.length < 8) {
|
|
1432
|
+
return "Password must be at least 8 characters.";
|
|
1433
|
+
}
|
|
1434
|
+
if (input.password !== input.confirm) {
|
|
1435
|
+
return "Passwords do not match.";
|
|
1436
|
+
}
|
|
1437
|
+
return undefined;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Read the first vault's display name from services.json for the
|
|
1442
|
+
* step-4 success page. Falls back to "default" if for any reason the
|
|
1443
|
+
* entry's metadata isn't present.
|
|
1444
|
+
*/
|
|
1445
|
+
function firstVaultName(manifestPath: string): string {
|
|
1446
|
+
const manifest = readManifest(manifestPath);
|
|
1447
|
+
// Match on the canonical vault manifestName from the curated spec.
|
|
1448
|
+
// (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
|
|
1449
|
+
// tuple-literal member, so the conjunct is always true.)
|
|
1450
|
+
const entry = manifest.services.find((s) => s.name === specFor("vault").manifestName);
|
|
1451
|
+
if (!entry) return "default";
|
|
1452
|
+
// services.json entries store the mount path (e.g. `/vault/default`).
|
|
1453
|
+
// Strip the canonical prefix to surface the display name.
|
|
1454
|
+
for (const p of entry.paths ?? []) {
|
|
1455
|
+
if (p.startsWith("/vault/")) {
|
|
1456
|
+
const tail = p.slice("/vault/".length).replace(/\/+$/, "");
|
|
1457
|
+
if (tail.length > 0) return tail;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return "default";
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
1464
|
+
return new Response(body, {
|
|
1465
|
+
status,
|
|
1466
|
+
headers: { "content-type": "text/html; charset=utf-8", ...extra },
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function redirect(location: string, extra: Record<string, string> = {}): Response {
|
|
1471
|
+
return new Response(null, { status: 303, headers: { location, ...extra } });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function badRequestPage(title: string, message: string): Response {
|
|
1475
|
+
return htmlResponse(renderBadRequestPage(title, message), 400);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderBadRequestPage(title: string, message: string): string {
|
|
1479
|
+
const body = `
|
|
1480
|
+
<div class="card">
|
|
1481
|
+
${header("welcome")}
|
|
1482
|
+
<h1 class="error-title">${escapeHtml(title)}</h1>
|
|
1483
|
+
<p class="subtitle">${escapeHtml(message)}</p>
|
|
1484
|
+
<p><a class="btn btn-primary" href="/admin/setup">Restart setup</a></p>
|
|
1485
|
+
</div>`;
|
|
1486
|
+
return baseDocument(title, body);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Fallback op id when no registry is wired — the wizard's UX still
|
|
1491
|
+
* needs *something* to redirect to so the page doesn't hang. The
|
|
1492
|
+
* redirect's `op` query then resolves to "no op found," which renders
|
|
1493
|
+
* the bare step-3 form again. Production callers always pass a
|
|
1494
|
+
* registry (the dispatcher in `hub-server.ts` plugs in
|
|
1495
|
+
* `getDefaultOperationsRegistry()`); this branch is exercised only by
|
|
1496
|
+
* tests that deliberately omit it. `handleSetupVaultPost` logs a
|
|
1497
|
+
* `console.warn` when it takes this branch so a real-world
|
|
1498
|
+
* mis-wiring surfaces in the operator's logs instead of silently
|
|
1499
|
+
* swallowing the install.
|
|
1500
|
+
*/
|
|
1501
|
+
function cryptoRandomId(): string {
|
|
1502
|
+
return `op-${Math.random().toString(36).slice(2, 10)}`;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// --- styles -------------------------------------------------------------
|
|
1506
|
+
|
|
1507
|
+
const STYLES = `
|
|
1508
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1509
|
+
html, body { margin: 0; padding: 0; }
|
|
1510
|
+
body {
|
|
1511
|
+
font-family: ${FONT_SANS};
|
|
1512
|
+
background: ${PALETTE.bg};
|
|
1513
|
+
color: ${PALETTE.fg};
|
|
1514
|
+
line-height: 1.55;
|
|
1515
|
+
min-height: 100vh;
|
|
1516
|
+
-webkit-font-smoothing: antialiased;
|
|
1517
|
+
-moz-osx-font-smoothing: grayscale;
|
|
1518
|
+
}
|
|
1519
|
+
main {
|
|
1520
|
+
display: flex;
|
|
1521
|
+
align-items: center;
|
|
1522
|
+
justify-content: center;
|
|
1523
|
+
min-height: 100vh;
|
|
1524
|
+
padding: 1.5rem;
|
|
1525
|
+
}
|
|
1526
|
+
.card {
|
|
1527
|
+
width: 100%;
|
|
1528
|
+
max-width: 38rem;
|
|
1529
|
+
background: ${PALETTE.cardBg};
|
|
1530
|
+
border: 1px solid ${PALETTE.border};
|
|
1531
|
+
border-radius: 12px;
|
|
1532
|
+
padding: 2rem 1.75rem;
|
|
1533
|
+
box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
|
|
1534
|
+
}
|
|
1535
|
+
.card-header { margin-bottom: 1.25rem; }
|
|
1536
|
+
.brand {
|
|
1537
|
+
display: flex;
|
|
1538
|
+
align-items: center;
|
|
1539
|
+
gap: 0.5rem;
|
|
1540
|
+
color: ${PALETTE.accent};
|
|
1541
|
+
font-weight: 500;
|
|
1542
|
+
font-size: 0.95rem;
|
|
1543
|
+
margin-bottom: 0.5rem;
|
|
1544
|
+
}
|
|
1545
|
+
.brand-mark { font-size: 1.1rem; line-height: 1; }
|
|
1546
|
+
.brand-name { letter-spacing: 0.01em; }
|
|
1547
|
+
.brand-tag {
|
|
1548
|
+
text-transform: uppercase;
|
|
1549
|
+
letter-spacing: 0.06em;
|
|
1550
|
+
font-size: 0.7rem;
|
|
1551
|
+
color: ${PALETTE.fgMuted};
|
|
1552
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1553
|
+
padding: 0.05rem 0.4rem;
|
|
1554
|
+
border-radius: 999px;
|
|
1555
|
+
}
|
|
1556
|
+
.steps {
|
|
1557
|
+
list-style: none;
|
|
1558
|
+
padding: 0;
|
|
1559
|
+
margin: 0 0 1rem;
|
|
1560
|
+
display: flex;
|
|
1561
|
+
gap: 0.4rem;
|
|
1562
|
+
font-size: 0.8rem;
|
|
1563
|
+
color: ${PALETTE.fgDim};
|
|
1564
|
+
flex-wrap: wrap;
|
|
1565
|
+
}
|
|
1566
|
+
.step {
|
|
1567
|
+
display: inline-flex;
|
|
1568
|
+
align-items: center;
|
|
1569
|
+
gap: 0.35rem;
|
|
1570
|
+
}
|
|
1571
|
+
.step + .step::before {
|
|
1572
|
+
content: "→";
|
|
1573
|
+
color: ${PALETTE.fgDim};
|
|
1574
|
+
margin-right: 0.2rem;
|
|
1575
|
+
}
|
|
1576
|
+
.step-marker {
|
|
1577
|
+
display: inline-flex;
|
|
1578
|
+
align-items: center;
|
|
1579
|
+
justify-content: center;
|
|
1580
|
+
width: 1.25rem;
|
|
1581
|
+
height: 1.25rem;
|
|
1582
|
+
border-radius: 999px;
|
|
1583
|
+
background: ${PALETTE.borderLight};
|
|
1584
|
+
color: ${PALETTE.fgMuted};
|
|
1585
|
+
font-size: 0.7rem;
|
|
1586
|
+
font-family: ${FONT_MONO};
|
|
1587
|
+
}
|
|
1588
|
+
.step.current .step-marker {
|
|
1589
|
+
background: ${PALETTE.accent};
|
|
1590
|
+
color: ${PALETTE.cardBg};
|
|
1591
|
+
}
|
|
1592
|
+
.step.past .step-marker {
|
|
1593
|
+
background: ${PALETTE.success};
|
|
1594
|
+
color: ${PALETTE.cardBg};
|
|
1595
|
+
}
|
|
1596
|
+
.step.current .step-label { color: ${PALETTE.fg}; font-weight: 500; }
|
|
1597
|
+
h1 {
|
|
1598
|
+
font-family: ${FONT_SERIF};
|
|
1599
|
+
font-weight: 400;
|
|
1600
|
+
font-size: 1.75rem;
|
|
1601
|
+
line-height: 1.2;
|
|
1602
|
+
margin: 0 0 0.4rem;
|
|
1603
|
+
color: ${PALETTE.fg};
|
|
1604
|
+
}
|
|
1605
|
+
h2 {
|
|
1606
|
+
font-family: ${FONT_SANS};
|
|
1607
|
+
font-size: 0.85rem;
|
|
1608
|
+
font-weight: 600;
|
|
1609
|
+
text-transform: uppercase;
|
|
1610
|
+
letter-spacing: 0.06em;
|
|
1611
|
+
color: ${PALETTE.fgMuted};
|
|
1612
|
+
margin: 1.25rem 0 0.4rem;
|
|
1613
|
+
}
|
|
1614
|
+
.subtitle { margin: 0 0 0.5rem; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
|
|
1615
|
+
.explainer {
|
|
1616
|
+
background: ${PALETTE.accentSoft};
|
|
1617
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1618
|
+
border-radius: 8px;
|
|
1619
|
+
padding: 0.5rem 1rem;
|
|
1620
|
+
margin: 1rem 0 1.25rem;
|
|
1621
|
+
font-size: 0.92rem;
|
|
1622
|
+
}
|
|
1623
|
+
.explainer h2 { margin-top: 0.75rem; }
|
|
1624
|
+
.explainer p { margin: 0 0 0.5rem; }
|
|
1625
|
+
.preview {
|
|
1626
|
+
margin: 1rem 0;
|
|
1627
|
+
}
|
|
1628
|
+
.preview-label {
|
|
1629
|
+
font-size: 0.75rem;
|
|
1630
|
+
text-transform: uppercase;
|
|
1631
|
+
letter-spacing: 0.06em;
|
|
1632
|
+
color: ${PALETTE.fgMuted};
|
|
1633
|
+
margin: 0 0 0.25rem;
|
|
1634
|
+
}
|
|
1635
|
+
.preview-card {
|
|
1636
|
+
background: ${PALETTE.warnSoft};
|
|
1637
|
+
border-left: 3px solid ${PALETTE.warn};
|
|
1638
|
+
padding: 0.6rem 0.9rem;
|
|
1639
|
+
border-radius: 0 6px 6px 0;
|
|
1640
|
+
font-family: ${FONT_MONO};
|
|
1641
|
+
font-size: 0.9rem;
|
|
1642
|
+
}
|
|
1643
|
+
.preview-key { color: ${PALETTE.fgMuted}; }
|
|
1644
|
+
.preview-val { font-weight: 600; color: ${PALETTE.fg}; }
|
|
1645
|
+
.preview-fine { color: ${PALETTE.fgMuted}; font-size: 0.85em; }
|
|
1646
|
+
|
|
1647
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
|
1648
|
+
.field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
1649
|
+
.field-label {
|
|
1650
|
+
font-size: 0.85rem;
|
|
1651
|
+
font-weight: 500;
|
|
1652
|
+
color: ${PALETTE.fgMuted};
|
|
1653
|
+
letter-spacing: 0.01em;
|
|
1654
|
+
font-family: ${FONT_MONO};
|
|
1655
|
+
}
|
|
1656
|
+
.field-hint {
|
|
1657
|
+
font-size: 0.78rem;
|
|
1658
|
+
color: ${PALETTE.fgDim};
|
|
1659
|
+
}
|
|
1660
|
+
input[type=text], input[type=password] {
|
|
1661
|
+
font: inherit;
|
|
1662
|
+
width: 100%;
|
|
1663
|
+
padding: 0.6rem 0.75rem;
|
|
1664
|
+
border: 1px solid ${PALETTE.border};
|
|
1665
|
+
border-radius: 6px;
|
|
1666
|
+
background: ${PALETTE.bg};
|
|
1667
|
+
color: ${PALETTE.fg};
|
|
1668
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
1669
|
+
}
|
|
1670
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
1671
|
+
outline: none;
|
|
1672
|
+
border-color: ${PALETTE.accent};
|
|
1673
|
+
background: ${PALETTE.cardBg};
|
|
1674
|
+
box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
|
|
1675
|
+
}
|
|
1676
|
+
.btn {
|
|
1677
|
+
font: inherit;
|
|
1678
|
+
font-weight: 500;
|
|
1679
|
+
padding: 0.65rem 1.25rem;
|
|
1680
|
+
border-radius: 6px;
|
|
1681
|
+
border: 1px solid transparent;
|
|
1682
|
+
cursor: pointer;
|
|
1683
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
1684
|
+
min-height: 2.5rem;
|
|
1685
|
+
text-decoration: none;
|
|
1686
|
+
display: inline-flex;
|
|
1687
|
+
align-items: center;
|
|
1688
|
+
justify-content: center;
|
|
1689
|
+
}
|
|
1690
|
+
.btn-primary {
|
|
1691
|
+
background: ${PALETTE.accent};
|
|
1692
|
+
color: ${PALETTE.cardBg};
|
|
1693
|
+
margin-top: 0.4rem;
|
|
1694
|
+
}
|
|
1695
|
+
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
1696
|
+
.btn-secondary {
|
|
1697
|
+
background: transparent;
|
|
1698
|
+
color: ${PALETTE.accent};
|
|
1699
|
+
border-color: ${PALETTE.accent};
|
|
1700
|
+
}
|
|
1701
|
+
.btn-secondary:hover {
|
|
1702
|
+
background: ${PALETTE.accentSoft};
|
|
1703
|
+
}
|
|
1704
|
+
/* Copy button rides at the right edge of the MCP command pre. Compact
|
|
1705
|
+
vertical sizing so it doesn't dwarf the snippet on narrow widths;
|
|
1706
|
+
full text wrap on the pre keeps the snippet readable behind it. */
|
|
1707
|
+
.mcp-cmd-wrap {
|
|
1708
|
+
position: relative;
|
|
1709
|
+
margin: 0.5rem 0;
|
|
1710
|
+
}
|
|
1711
|
+
.mcp-cmd-wrap pre {
|
|
1712
|
+
background: ${PALETTE.bg};
|
|
1713
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1714
|
+
border-radius: 6px;
|
|
1715
|
+
padding: 0.5rem 5.5rem 0.5rem 0.75rem;
|
|
1716
|
+
overflow-x: auto;
|
|
1717
|
+
font-size: 0.82rem;
|
|
1718
|
+
margin: 0;
|
|
1719
|
+
white-space: pre-wrap;
|
|
1720
|
+
word-break: break-all;
|
|
1721
|
+
}
|
|
1722
|
+
.btn-copy {
|
|
1723
|
+
position: absolute;
|
|
1724
|
+
top: 0.35rem;
|
|
1725
|
+
right: 0.35rem;
|
|
1726
|
+
padding: 0.25rem 0.6rem;
|
|
1727
|
+
font-size: 0.78rem;
|
|
1728
|
+
min-height: auto;
|
|
1729
|
+
background: ${PALETTE.cardBg};
|
|
1730
|
+
color: ${PALETTE.fg};
|
|
1731
|
+
border: 1px solid ${PALETTE.border};
|
|
1732
|
+
border-radius: 4px;
|
|
1733
|
+
cursor: pointer;
|
|
1734
|
+
}
|
|
1735
|
+
.btn-copy:hover {
|
|
1736
|
+
border-color: ${PALETTE.accent};
|
|
1737
|
+
color: ${PALETTE.accent};
|
|
1738
|
+
}
|
|
1739
|
+
/* Install-tile section (hub#272 Item B). Lives above the .done-grid;
|
|
1740
|
+
primary "what's next?" surface. Tiles render in a responsive grid
|
|
1741
|
+
that collapses to one column on narrow viewports. */
|
|
1742
|
+
.install-tiles {
|
|
1743
|
+
margin: 1rem 0 1.25rem;
|
|
1744
|
+
}
|
|
1745
|
+
.install-tiles-heading {
|
|
1746
|
+
margin: 0 0 0.25rem;
|
|
1747
|
+
text-transform: none;
|
|
1748
|
+
letter-spacing: 0;
|
|
1749
|
+
font-size: 1.05rem;
|
|
1750
|
+
color: ${PALETTE.fg};
|
|
1751
|
+
}
|
|
1752
|
+
.install-tiles-subtitle {
|
|
1753
|
+
margin: 0 0 0.75rem;
|
|
1754
|
+
color: ${PALETTE.fgMuted};
|
|
1755
|
+
font-size: 0.9rem;
|
|
1756
|
+
}
|
|
1757
|
+
.install-grid {
|
|
1758
|
+
display: grid;
|
|
1759
|
+
grid-template-columns: 1fr;
|
|
1760
|
+
gap: 0.75rem;
|
|
1761
|
+
}
|
|
1762
|
+
@media (min-width: 30rem) {
|
|
1763
|
+
.install-grid { grid-template-columns: 1fr 1fr; }
|
|
1764
|
+
}
|
|
1765
|
+
.install-tile {
|
|
1766
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1767
|
+
border-radius: 8px;
|
|
1768
|
+
padding: 0.75rem 0.9rem;
|
|
1769
|
+
background: ${PALETTE.cardBg};
|
|
1770
|
+
display: flex;
|
|
1771
|
+
flex-direction: column;
|
|
1772
|
+
gap: 0.4rem;
|
|
1773
|
+
}
|
|
1774
|
+
.install-tile h3 {
|
|
1775
|
+
margin: 0;
|
|
1776
|
+
font-family: ${FONT_SERIF};
|
|
1777
|
+
font-weight: 400;
|
|
1778
|
+
font-size: 1.1rem;
|
|
1779
|
+
color: ${PALETTE.fg};
|
|
1780
|
+
}
|
|
1781
|
+
.install-tile-tagline {
|
|
1782
|
+
margin: 0;
|
|
1783
|
+
color: ${PALETTE.fgMuted};
|
|
1784
|
+
font-size: 0.85rem;
|
|
1785
|
+
}
|
|
1786
|
+
.install-tile-form {
|
|
1787
|
+
margin: 0;
|
|
1788
|
+
}
|
|
1789
|
+
.install-tile-installed {
|
|
1790
|
+
background: ${PALETTE.accentSoft};
|
|
1791
|
+
border-color: ${PALETTE.accent};
|
|
1792
|
+
}
|
|
1793
|
+
.install-tile-status {
|
|
1794
|
+
margin: 0;
|
|
1795
|
+
color: ${PALETTE.success};
|
|
1796
|
+
font-weight: 500;
|
|
1797
|
+
font-size: 0.85rem;
|
|
1798
|
+
}
|
|
1799
|
+
.install-tile-running, .install-tile-pending {
|
|
1800
|
+
border-color: ${PALETTE.warn};
|
|
1801
|
+
}
|
|
1802
|
+
.install-tile-succeeded {
|
|
1803
|
+
background: ${PALETTE.accentSoft};
|
|
1804
|
+
border-color: ${PALETTE.accent};
|
|
1805
|
+
}
|
|
1806
|
+
.install-tile-failed {
|
|
1807
|
+
border-color: ${PALETTE.danger};
|
|
1808
|
+
background: ${PALETTE.dangerSoft};
|
|
1809
|
+
}
|
|
1810
|
+
.install-tile-log {
|
|
1811
|
+
margin: 0;
|
|
1812
|
+
font-size: 0.78rem;
|
|
1813
|
+
}
|
|
1814
|
+
.alt-path {
|
|
1815
|
+
margin-top: 1.25rem;
|
|
1816
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
1817
|
+
padding-top: 0.75rem;
|
|
1818
|
+
font-size: 0.88rem;
|
|
1819
|
+
color: ${PALETTE.fgMuted};
|
|
1820
|
+
}
|
|
1821
|
+
.alt-path summary {
|
|
1822
|
+
cursor: pointer;
|
|
1823
|
+
font-weight: 500;
|
|
1824
|
+
color: ${PALETTE.fgMuted};
|
|
1825
|
+
}
|
|
1826
|
+
.alt-path p { margin: 0.5rem 0 0; }
|
|
1827
|
+
|
|
1828
|
+
.error-banner {
|
|
1829
|
+
background: ${PALETTE.dangerSoft};
|
|
1830
|
+
border: 1px solid ${PALETTE.danger};
|
|
1831
|
+
border-radius: 6px;
|
|
1832
|
+
color: ${PALETTE.danger};
|
|
1833
|
+
padding: 0.6rem 0.8rem;
|
|
1834
|
+
margin: 0 0 1rem;
|
|
1835
|
+
font-size: 0.9rem;
|
|
1836
|
+
}
|
|
1837
|
+
.error-title { color: ${PALETTE.danger}; }
|
|
1838
|
+
|
|
1839
|
+
.op-log {
|
|
1840
|
+
background: ${PALETTE.bg};
|
|
1841
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1842
|
+
border-radius: 8px;
|
|
1843
|
+
padding: 0.75rem 1rem;
|
|
1844
|
+
margin: 1rem 0;
|
|
1845
|
+
font-family: ${FONT_MONO};
|
|
1846
|
+
font-size: 0.85rem;
|
|
1847
|
+
}
|
|
1848
|
+
.op-status {
|
|
1849
|
+
margin: 0 0 0.5rem;
|
|
1850
|
+
font-weight: 600;
|
|
1851
|
+
color: ${PALETTE.fgMuted};
|
|
1852
|
+
}
|
|
1853
|
+
.op-succeeded { color: ${PALETTE.success}; }
|
|
1854
|
+
.op-failed { color: ${PALETTE.danger}; }
|
|
1855
|
+
.log-lines { margin: 0; padding-left: 1.25rem; color: ${PALETTE.fgMuted}; }
|
|
1856
|
+
.log-lines li { margin: 0.15rem 0; }
|
|
1857
|
+
|
|
1858
|
+
.done-grid {
|
|
1859
|
+
display: grid;
|
|
1860
|
+
grid-template-columns: 1fr;
|
|
1861
|
+
gap: 1rem;
|
|
1862
|
+
margin: 1rem 0;
|
|
1863
|
+
}
|
|
1864
|
+
@media (min-width: 36rem) {
|
|
1865
|
+
.done-grid { grid-template-columns: 1fr 1fr; }
|
|
1866
|
+
}
|
|
1867
|
+
.done-tile {
|
|
1868
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1869
|
+
border-radius: 8px;
|
|
1870
|
+
padding: 0.75rem 1rem;
|
|
1871
|
+
}
|
|
1872
|
+
.done-tile h2 {
|
|
1873
|
+
margin-top: 0;
|
|
1874
|
+
text-transform: none;
|
|
1875
|
+
letter-spacing: 0;
|
|
1876
|
+
font-size: 1.05rem;
|
|
1877
|
+
color: ${PALETTE.fg};
|
|
1878
|
+
}
|
|
1879
|
+
.done-tile pre {
|
|
1880
|
+
background: ${PALETTE.bg};
|
|
1881
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1882
|
+
border-radius: 6px;
|
|
1883
|
+
padding: 0.5rem 0.75rem;
|
|
1884
|
+
overflow-x: auto;
|
|
1885
|
+
font-size: 0.82rem;
|
|
1886
|
+
margin: 0.5rem 0;
|
|
1887
|
+
}
|
|
1888
|
+
.done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
|
|
1889
|
+
|
|
1890
|
+
/* expose step (hub#268 Item 2). Vertical stack of radio cards;
|
|
1891
|
+
each label is the full clickable hit target. */
|
|
1892
|
+
.expose-form { gap: 0.65rem; }
|
|
1893
|
+
.expose-option {
|
|
1894
|
+
display: flex;
|
|
1895
|
+
align-items: flex-start;
|
|
1896
|
+
gap: 0.65rem;
|
|
1897
|
+
padding: 0.85rem 1rem;
|
|
1898
|
+
border: 1px solid ${PALETTE.border};
|
|
1899
|
+
border-radius: 8px;
|
|
1900
|
+
cursor: pointer;
|
|
1901
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
1902
|
+
background: ${PALETTE.cardBg};
|
|
1903
|
+
}
|
|
1904
|
+
.expose-option:hover { border-color: ${PALETTE.accent}; }
|
|
1905
|
+
.expose-option input[type=radio] {
|
|
1906
|
+
margin-top: 0.25rem;
|
|
1907
|
+
accent-color: ${PALETTE.accent};
|
|
1908
|
+
flex-shrink: 0;
|
|
1909
|
+
}
|
|
1910
|
+
.expose-option-body {
|
|
1911
|
+
display: flex;
|
|
1912
|
+
flex-direction: column;
|
|
1913
|
+
gap: 0.25rem;
|
|
1914
|
+
min-width: 0;
|
|
1915
|
+
}
|
|
1916
|
+
.expose-option-title {
|
|
1917
|
+
font-weight: 600;
|
|
1918
|
+
color: ${PALETTE.fg};
|
|
1919
|
+
font-size: 0.95rem;
|
|
1920
|
+
}
|
|
1921
|
+
.expose-option-desc {
|
|
1922
|
+
color: ${PALETTE.fgMuted};
|
|
1923
|
+
font-size: 0.88rem;
|
|
1924
|
+
line-height: 1.45;
|
|
1925
|
+
}
|
|
1926
|
+
.expose-option-cmd {
|
|
1927
|
+
background: ${PALETTE.bg};
|
|
1928
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1929
|
+
border-radius: 6px;
|
|
1930
|
+
padding: 0.4rem 0.6rem;
|
|
1931
|
+
font-family: ${FONT_MONO};
|
|
1932
|
+
font-size: 0.82rem;
|
|
1933
|
+
margin: 0.25rem 0;
|
|
1934
|
+
overflow-x: auto;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/* reachable tile on the done step. Lives outside the .done-grid so it
|
|
1938
|
+
spans the full width — the URL itself is the headline. */
|
|
1939
|
+
.reachable {
|
|
1940
|
+
background: ${PALETTE.accentSoft};
|
|
1941
|
+
border-left: 3px solid ${PALETTE.accent};
|
|
1942
|
+
border-radius: 0 8px 8px 0;
|
|
1943
|
+
padding: 0.75rem 1rem;
|
|
1944
|
+
margin: 0 0 1rem;
|
|
1945
|
+
}
|
|
1946
|
+
.reachable h2 {
|
|
1947
|
+
margin: 0 0 0.4rem;
|
|
1948
|
+
text-transform: none;
|
|
1949
|
+
letter-spacing: 0;
|
|
1950
|
+
font-size: 0.9rem;
|
|
1951
|
+
color: ${PALETTE.accent};
|
|
1952
|
+
}
|
|
1953
|
+
.reachable-url {
|
|
1954
|
+
margin: 0.2rem 0;
|
|
1955
|
+
font-size: 0.95rem;
|
|
1956
|
+
}
|
|
1957
|
+
.reachable-url code {
|
|
1958
|
+
background: ${PALETTE.cardBg};
|
|
1959
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1960
|
+
padding: 0.1rem 0.4rem;
|
|
1961
|
+
border-radius: 4px;
|
|
1962
|
+
}
|
|
1963
|
+
.reachable pre {
|
|
1964
|
+
background: ${PALETTE.cardBg};
|
|
1965
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1966
|
+
border-radius: 6px;
|
|
1967
|
+
padding: 0.5rem 0.75rem;
|
|
1968
|
+
overflow-x: auto;
|
|
1969
|
+
font-size: 0.82rem;
|
|
1970
|
+
margin: 0.4rem 0;
|
|
1971
|
+
}
|
|
1972
|
+
.reachable .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
|
|
1973
|
+
|
|
1974
|
+
code {
|
|
1975
|
+
background: ${PALETTE.borderLight};
|
|
1976
|
+
padding: 0.05rem 0.3rem;
|
|
1977
|
+
border-radius: 4px;
|
|
1978
|
+
font-family: ${FONT_MONO};
|
|
1979
|
+
font-size: 0.92em;
|
|
1980
|
+
}
|
|
1981
|
+
pre code {
|
|
1982
|
+
background: transparent;
|
|
1983
|
+
padding: 0;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
@media (max-width: 480px) {
|
|
1987
|
+
main { padding: 0.75rem; }
|
|
1988
|
+
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
|
1989
|
+
h1 { font-size: 1.5rem; }
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
@media (prefers-color-scheme: dark) {
|
|
1993
|
+
body { background: #1a1815; color: #e8e4dc; }
|
|
1994
|
+
.card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
|
1995
|
+
h1 { color: #f0ece4; }
|
|
1996
|
+
.subtitle, .field-label, .field-hint, .step-label { color: #a8a29a; }
|
|
1997
|
+
input[type=text], input[type=password] {
|
|
1998
|
+
background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
|
|
1999
|
+
}
|
|
2000
|
+
input[type=text]:focus, input[type=password]:focus {
|
|
2001
|
+
background: #25221d;
|
|
2002
|
+
}
|
|
2003
|
+
.brand-tag { border-color: #3a362f; color: #a8a29a; }
|
|
2004
|
+
.explainer { background: rgba(74, 124, 89, 0.12); border-color: #3a362f; }
|
|
2005
|
+
.preview-card { background: rgba(212, 160, 23, 0.15); }
|
|
2006
|
+
.done-tile { border-color: #3a362f; }
|
|
2007
|
+
.op-log { background: #1f1c18; border-color: #3a362f; }
|
|
2008
|
+
}
|
|
2009
|
+
`;
|