@openparachute/hub 0.5.10-rc.6 → 0.5.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-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -39,14 +39,24 @@
|
|
|
39
39
|
|
|
40
40
|
import type { Database } from "bun:sqlite";
|
|
41
41
|
import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
|
|
42
|
-
import type
|
|
42
|
+
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
43
43
|
import {
|
|
44
44
|
CSRF_FIELD_NAME,
|
|
45
45
|
ensureCsrfToken,
|
|
46
46
|
renderCsrfHiddenInput,
|
|
47
47
|
verifyCsrfToken,
|
|
48
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";
|
|
49
58
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
59
|
+
import { mintOperatorToken } from "./operator-token.ts";
|
|
50
60
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
51
61
|
import { findService, readManifest } from "./services-manifest.ts";
|
|
52
62
|
import {
|
|
@@ -57,6 +67,7 @@ import {
|
|
|
57
67
|
} from "./sessions.ts";
|
|
58
68
|
import type { Supervisor } from "./supervisor.ts";
|
|
59
69
|
import { createUser, userCount } from "./users.ts";
|
|
70
|
+
import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
|
|
60
71
|
|
|
61
72
|
// --- shared chrome --------------------------------------------------------
|
|
62
73
|
|
|
@@ -88,7 +99,15 @@ function escapeAttr(s: string): string {
|
|
|
88
99
|
|
|
89
100
|
// --- state derivation ----------------------------------------------------
|
|
90
101
|
|
|
91
|
-
|
|
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";
|
|
92
111
|
|
|
93
112
|
export interface DerivedWizardState {
|
|
94
113
|
/** Current step the wizard should render. */
|
|
@@ -97,6 +116,13 @@ export interface DerivedWizardState {
|
|
|
97
116
|
hasAdmin: boolean;
|
|
98
117
|
/** Whether the first vault (curated) has been provisioned in services.json. */
|
|
99
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;
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
/**
|
|
@@ -121,11 +147,19 @@ export function deriveWizardState(deps: {
|
|
|
121
147
|
const vaultSpec = specFor(FIRST_VAULT_SHORT);
|
|
122
148
|
const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
|
|
123
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;
|
|
124
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.
|
|
125
158
|
if (!hasAdmin) step = "welcome";
|
|
126
159
|
else if (!hasVault) step = "vault";
|
|
160
|
+
else if (!hasExposeMode) step = "expose";
|
|
127
161
|
else step = "done";
|
|
128
|
-
return { step, hasAdmin, hasVault };
|
|
162
|
+
return { step, hasAdmin, hasVault, hasExposeMode };
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
// --- handler types -------------------------------------------------------
|
|
@@ -184,7 +218,7 @@ ${body}
|
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
function header(currentStep: WizardStep): string {
|
|
187
|
-
const stepOrder: WizardStep[] = ["welcome", "account", "vault", "done"];
|
|
221
|
+
const stepOrder: WizardStep[] = ["welcome", "account", "vault", "expose", "done"];
|
|
188
222
|
// Step 1 (welcome) + step 2 (account) collapse on the rendered page —
|
|
189
223
|
// we show them as a single combined form. The progress bar still names
|
|
190
224
|
// them separately so the operator sees the shape.
|
|
@@ -192,6 +226,7 @@ function header(currentStep: WizardStep): string {
|
|
|
192
226
|
welcome: "Welcome",
|
|
193
227
|
account: "Account",
|
|
194
228
|
vault: "Vault",
|
|
229
|
+
expose: "Expose",
|
|
195
230
|
done: "Done",
|
|
196
231
|
};
|
|
197
232
|
const items = stepOrder
|
|
@@ -283,6 +318,8 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
|
283
318
|
export interface RenderVaultStepProps {
|
|
284
319
|
csrfToken: string;
|
|
285
320
|
errorMessage?: string;
|
|
321
|
+
/** Pre-fill the vault name input after a validation failure. */
|
|
322
|
+
vaultName?: string;
|
|
286
323
|
/**
|
|
287
324
|
* When an install op is in progress, render the polling shape: no
|
|
288
325
|
* form, just the op log + auto-refresh.
|
|
@@ -296,19 +333,21 @@ export interface RenderVaultStepProps {
|
|
|
296
333
|
}
|
|
297
334
|
|
|
298
335
|
export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
299
|
-
const { csrfToken, errorMessage, operation } = props;
|
|
336
|
+
const { csrfToken, errorMessage, operation, vaultName } = props;
|
|
300
337
|
if (operation) return renderVaultOpStep({ operation });
|
|
301
338
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
302
|
-
// hub#
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
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;
|
|
312
351
|
const body = `
|
|
313
352
|
<div class="card">
|
|
314
353
|
<div class="card-header">
|
|
@@ -321,7 +360,7 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
321
360
|
<section class="explainer">
|
|
322
361
|
<h2>Why this step</h2>
|
|
323
362
|
<p>The wizard provisions a vault module at the path
|
|
324
|
-
<code>/vault
|
|
363
|
+
<code>/vault/<name></code> and issues you an operator token —
|
|
325
364
|
the same shape <code>parachute install vault</code> produces from
|
|
326
365
|
the CLI. We're doing both in one click.</p>
|
|
327
366
|
<h2>What's next</h2>
|
|
@@ -333,20 +372,29 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
333
372
|
<p class="preview-label">About to create</p>
|
|
334
373
|
<div class="preview-card">
|
|
335
374
|
<span class="preview-key">vault:</span>
|
|
336
|
-
<span class="preview-val" id="preview-vault-name"
|
|
375
|
+
<span class="preview-val" id="preview-vault-name">${previewName}</span>
|
|
337
376
|
<span class="preview-fine">— admin: you, MCP-ready for Claude Code</span>
|
|
338
377
|
</div>
|
|
339
378
|
<p class="preview-fine">
|
|
340
|
-
The
|
|
341
|
-
|
|
342
|
-
<
|
|
343
|
-
for now, rename or add vaults from the admin UI after setup.
|
|
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>.
|
|
344
382
|
</p>
|
|
345
383
|
</section>
|
|
346
384
|
${error}
|
|
347
385
|
<form method="POST" action="/admin/setup/vault" class="auth-form">
|
|
348
386
|
${renderCsrfHiddenInput(csrfToken)}
|
|
349
|
-
<
|
|
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>
|
|
350
398
|
</form>
|
|
351
399
|
</div>`;
|
|
352
400
|
return baseDocument("Set up your Parachute hub — vault", body);
|
|
@@ -397,17 +445,166 @@ function renderVaultOpStep(props: {
|
|
|
397
445
|
return baseDocument("Set up your Parachute hub — vault", body, refresh);
|
|
398
446
|
}
|
|
399
447
|
|
|
400
|
-
// --- step 4:
|
|
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
|
+
}
|
|
401
562
|
|
|
402
563
|
export interface RenderDoneStepProps {
|
|
403
564
|
vaultName: string;
|
|
404
565
|
/** Hub origin used in copy-pastable MCP install commands. */
|
|
405
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[];
|
|
406
592
|
}
|
|
407
593
|
|
|
408
594
|
export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
409
|
-
const { vaultName, hubOrigin } = props;
|
|
410
|
-
const
|
|
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.
|
|
411
608
|
const body = `
|
|
412
609
|
<div class="card">
|
|
413
610
|
<div class="card-header">
|
|
@@ -415,19 +612,14 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
|
415
612
|
<h1>You're set up</h1>
|
|
416
613
|
<p class="subtitle">Your hub is ready. Here's what to do next.</p>
|
|
417
614
|
</div>
|
|
615
|
+
${reachable}
|
|
616
|
+
${installSection}
|
|
418
617
|
<section class="done-grid">
|
|
618
|
+
${mcpTile}
|
|
419
619
|
<div class="done-tile">
|
|
420
620
|
<h2>Open the admin UI</h2>
|
|
421
621
|
<p>Manage vaults, tokens, OAuth grants, and module updates.</p>
|
|
422
|
-
<p><a class="btn btn-
|
|
423
|
-
</div>
|
|
424
|
-
<div class="done-tile">
|
|
425
|
-
<h2>Connect Claude Code (MCP)</h2>
|
|
426
|
-
<p>Wire <code>vault:${escapeHtml(vaultName)}</code> into Claude Code as an MCP server:</p>
|
|
427
|
-
<pre>${escapeHtml(mcpCmd)}</pre>
|
|
428
|
-
<p class="fine">You'll be prompted to mint an operator token from
|
|
429
|
-
the admin UI on first use. See
|
|
430
|
-
<code>/admin/tokens</code> for the canonical mint surface.</p>
|
|
622
|
+
<p><a class="btn btn-secondary" href="/admin/modules">Go to admin</a></p>
|
|
431
623
|
</div>
|
|
432
624
|
</section>
|
|
433
625
|
<section class="explainer">
|
|
@@ -443,7 +635,276 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
|
443
635
|
to <code>/login</code>.</p>
|
|
444
636
|
</section>
|
|
445
637
|
</div>`;
|
|
446
|
-
|
|
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). Two visible variants of the
|
|
669
|
+
// command live in the DOM:
|
|
670
|
+
//
|
|
671
|
+
// * `pre#mcp-cmd` — what the operator sees. The Bearer token is
|
|
672
|
+
// replaced with a fixed-width row of • so shoulder-surfers,
|
|
673
|
+
// screencasts, and over-the-shoulder photos don't capture
|
|
674
|
+
// credentials by default. This is the "discoverable but not
|
|
675
|
+
// shoulder-surf-able" framing Aaron asked for.
|
|
676
|
+
// * `script#mcp-cmd-real` (type=text/plain) — the real command
|
|
677
|
+
// with the live token, stashed in a non-rendering script tag.
|
|
678
|
+
// The Copy + Show handlers read from this so the operator's
|
|
679
|
+
// terminal paste still gets the real header without the page
|
|
680
|
+
// ever painting the token.
|
|
681
|
+
//
|
|
682
|
+
// The view-source threat model is unchanged from rc.9 — the token
|
|
683
|
+
// is part of the response body either way. The improvement is
|
|
684
|
+
// *visibly hidden by default*, which is what an over-the-shoulder
|
|
685
|
+
// observer needs (and what existing screencasts of the wizard
|
|
686
|
+
// currently leak).
|
|
687
|
+
//
|
|
688
|
+
// Show toggles textContent between masked + real and flips a
|
|
689
|
+
// data-state attribute so a screencast / pair-programming session
|
|
690
|
+
// can briefly reveal-and-rehide without the operator losing the
|
|
691
|
+
// line of sight on which mode they're in. Auto-hide after 10s so
|
|
692
|
+
// a forgotten reveal doesn't leak the token into a subsequent
|
|
693
|
+
// recording.
|
|
694
|
+
const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
|
|
695
|
+
// Clamp the dot count to a 8–40 range so very-short or very-long
|
|
696
|
+
// tokens don't render comically — token format is fixed-width
|
|
697
|
+
// (JTI-derived), so this is purely visual.
|
|
698
|
+
const maskedToken = "•".repeat(Math.max(8, Math.min(40, mintedToken.length)));
|
|
699
|
+
const maskedCmd = `${bareCmd} --header "Authorization: Bearer ${maskedToken}"`;
|
|
700
|
+
// The real command rides in a hidden <script type="application/json">
|
|
701
|
+
// block as a JSON-encoded string. <script> element content is parsed
|
|
702
|
+
// as raw text (no entity references), so HTML escaping would put
|
|
703
|
+
// literal `"` into the string — and Copy would paste that into
|
|
704
|
+
// the operator's terminal. JSON encoding (with `</` escaped so the
|
|
705
|
+
// sequence can't prematurely close the tag) round-trips safely:
|
|
706
|
+
// textContent returns the JSON, JSON.parse decodes back to the
|
|
707
|
+
// exact bytes of the original command. Caught while smoke-testing
|
|
708
|
+
// the rc.11 reveal/copy UX — pre-fix, the copied command included
|
|
709
|
+
// `"` placeholders that broke shell parsing.
|
|
710
|
+
const fullCmdJson = JSON.stringify(fullCmd).replace(/<\//g, "<\\/");
|
|
711
|
+
return `<div class="done-tile">
|
|
712
|
+
<h2>Connect Claude Code (MCP)</h2>
|
|
713
|
+
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
714
|
+
<div class="mcp-cmd-wrap" data-state="masked">
|
|
715
|
+
<pre id="mcp-cmd">${escapeHtml(maskedCmd)}</pre>
|
|
716
|
+
<script type="application/json" id="mcp-cmd-real">${fullCmdJson}</script>
|
|
717
|
+
<div class="mcp-cmd-actions">
|
|
718
|
+
<button type="button" class="btn btn-mcp-aux" id="mcp-cmd-show">Show token</button>
|
|
719
|
+
<button type="button" class="btn btn-copy" id="mcp-cmd-copy">Copy</button>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
<p class="fine">We minted this token for your first MCP connection.
|
|
723
|
+
It's masked above so it's safe to leave open on screen; Copy
|
|
724
|
+
copies the real command. It's a full-scope operator token tied
|
|
725
|
+
to your admin account; manage and revoke tokens at
|
|
726
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
|
|
727
|
+
<script>
|
|
728
|
+
(function () {
|
|
729
|
+
var wrap = document.querySelector('.mcp-cmd-wrap[data-state]');
|
|
730
|
+
var pre = document.getElementById('mcp-cmd');
|
|
731
|
+
var real = document.getElementById('mcp-cmd-real');
|
|
732
|
+
var copyBtn = document.getElementById('mcp-cmd-copy');
|
|
733
|
+
var showBtn = document.getElementById('mcp-cmd-show');
|
|
734
|
+
if (!wrap || !pre || !real || !copyBtn || !showBtn) return;
|
|
735
|
+
var realCmd;
|
|
736
|
+
try { realCmd = JSON.parse(real.textContent || '""'); }
|
|
737
|
+
catch (e) { realCmd = ''; }
|
|
738
|
+
var maskedCmd = pre.textContent || '';
|
|
739
|
+
var revealTimer = null;
|
|
740
|
+
function setMasked() {
|
|
741
|
+
pre.textContent = maskedCmd;
|
|
742
|
+
wrap.setAttribute('data-state', 'masked');
|
|
743
|
+
showBtn.textContent = 'Show token';
|
|
744
|
+
if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
|
|
745
|
+
}
|
|
746
|
+
function setRevealed() {
|
|
747
|
+
pre.textContent = realCmd;
|
|
748
|
+
wrap.setAttribute('data-state', 'revealed');
|
|
749
|
+
showBtn.textContent = 'Hide token';
|
|
750
|
+
// Auto-hide after 10s so a stray reveal doesn't leak the
|
|
751
|
+
// token into a screencast capture that started after the
|
|
752
|
+
// click.
|
|
753
|
+
if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
|
|
754
|
+
revealTimer = setTimeout(setMasked, 10000);
|
|
755
|
+
}
|
|
756
|
+
showBtn.addEventListener('click', function () {
|
|
757
|
+
if (wrap.getAttribute('data-state') === 'masked') setRevealed();
|
|
758
|
+
else setMasked();
|
|
759
|
+
});
|
|
760
|
+
copyBtn.addEventListener('click', function () {
|
|
761
|
+
// Copy ALWAYS pulls from the real command — the operator's
|
|
762
|
+
// terminal needs the live token regardless of whether the
|
|
763
|
+
// page is currently masked. This is the load-bearing path:
|
|
764
|
+
// the visible mask is a UX nicety; the clipboard must
|
|
765
|
+
// carry the real header.
|
|
766
|
+
navigator.clipboard.writeText(realCmd).then(function () {
|
|
767
|
+
copyBtn.textContent = 'Copied ✓';
|
|
768
|
+
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
})();
|
|
772
|
+
</script>
|
|
773
|
+
</div>`;
|
|
774
|
+
}
|
|
775
|
+
return `<div class="done-tile">
|
|
776
|
+
<h2>Connect Claude Code (MCP)</h2>
|
|
777
|
+
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
778
|
+
<pre>${escapeHtml(bareCmd)}</pre>
|
|
779
|
+
<p class="fine">Mint an operator token at
|
|
780
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a> and append
|
|
781
|
+
<code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
|
|
782
|
+
</div>`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* The "What's next?" install-tiles row (hub#272 Item B). One tile per
|
|
787
|
+
* curated module the operator might want next (Notes, Scribe). Each
|
|
788
|
+
* tile is either an install form (POST → /admin/setup/install/<short>
|
|
789
|
+
* → 303 to /admin/setup?op_<short>=<id>) or an op-poll panel mirroring
|
|
790
|
+
* the vault-step's op-poll shape.
|
|
791
|
+
*/
|
|
792
|
+
function renderInstallTiles(tiles: readonly ModuleInstallTileState[]): string {
|
|
793
|
+
const items = tiles.map((t) => renderInstallTile(t)).join("");
|
|
794
|
+
return `<section class="install-tiles">
|
|
795
|
+
<h2 class="install-tiles-heading">What's next?</h2>
|
|
796
|
+
<p class="install-tiles-subtitle">Install another module — these run alongside your vault on the same hub.</p>
|
|
797
|
+
<div class="install-grid">${items}</div>
|
|
798
|
+
</section>`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
802
|
+
const safeShort = escapeHtml(tile.short);
|
|
803
|
+
const safeName = escapeHtml(tile.displayName);
|
|
804
|
+
const safeTagline = escapeHtml(tile.tagline);
|
|
805
|
+
if (tile.operation) {
|
|
806
|
+
const op = tile.operation;
|
|
807
|
+
const logLines = op.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
808
|
+
const errBanner = op.error ? `<p class="error-banner">${escapeHtml(op.error)}</p>` : "";
|
|
809
|
+
// Terminal state (succeeded / failed) gets either a confirmation
|
|
810
|
+
// link or a retry form. Pending / running renders the live log
|
|
811
|
+
// panel and relies on the parent `<meta http-equiv="refresh">` for
|
|
812
|
+
// the next tick — no per-tile refresh needed (one full-page reload
|
|
813
|
+
// catches every in-flight op at once).
|
|
814
|
+
let actions = "";
|
|
815
|
+
if (op.status === "succeeded") {
|
|
816
|
+
actions = `<p><a class="btn btn-secondary" href="/admin/modules">Manage modules</a></p>`;
|
|
817
|
+
} else if (op.status === "failed") {
|
|
818
|
+
actions = `<form method="POST" action="/admin/setup/install/${safeShort}" class="install-retry">
|
|
819
|
+
${renderInstallTileCsrfPlaceholder()}
|
|
820
|
+
<button type="submit" class="btn btn-secondary">Retry install</button>
|
|
821
|
+
</form>`;
|
|
822
|
+
}
|
|
823
|
+
return `<div class="install-tile install-tile-${op.status}">
|
|
824
|
+
<h3>${safeName}</h3>
|
|
825
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
826
|
+
${errBanner}
|
|
827
|
+
<section class="op-log install-tile-log">
|
|
828
|
+
<p class="op-status op-${op.status}">status: ${op.status}</p>
|
|
829
|
+
<ol class="log-lines">${logLines}</ol>
|
|
830
|
+
</section>
|
|
831
|
+
${actions}
|
|
832
|
+
</div>`;
|
|
833
|
+
}
|
|
834
|
+
if (tile.alreadyInstalled) {
|
|
835
|
+
return `<div class="install-tile install-tile-installed">
|
|
836
|
+
<h3>${safeName}</h3>
|
|
837
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
838
|
+
<p class="install-tile-status">Already installed.</p>
|
|
839
|
+
<p><a class="btn btn-secondary" href="/admin/modules">Manage in admin</a></p>
|
|
840
|
+
</div>`;
|
|
841
|
+
}
|
|
842
|
+
return `<div class="install-tile">
|
|
843
|
+
<h3>${safeName}</h3>
|
|
844
|
+
<p class="install-tile-tagline">${safeTagline}</p>
|
|
845
|
+
<form method="POST" action="/admin/setup/install/${safeShort}" class="install-tile-form">
|
|
846
|
+
${renderInstallTileCsrfPlaceholder()}
|
|
847
|
+
<button type="submit" class="btn btn-primary">Install ${safeName}</button>
|
|
848
|
+
</form>
|
|
849
|
+
</div>`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* CSRF token placeholder for install-tile forms. The token comes from
|
|
854
|
+
* the wizard's per-request CSRF cookie; rendered by the parent step's
|
|
855
|
+
* `csrfToken` plumbing. Threaded through `renderDoneStep` props rather
|
|
856
|
+
* than read here directly because the tile renderer is a pure function
|
|
857
|
+
* the test surface can exercise without a request object.
|
|
858
|
+
*
|
|
859
|
+
* Currently rendered as a marker that the parent renderer rewrites
|
|
860
|
+
* before serving — keeps the per-tile shape pure but avoids dragging
|
|
861
|
+
* a CSRF token argument into every tile-shape function.
|
|
862
|
+
*/
|
|
863
|
+
function renderInstallTileCsrfPlaceholder(): string {
|
|
864
|
+
return INSTALL_TILE_CSRF_PLACEHOLDER;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const INSTALL_TILE_CSRF_PLACEHOLDER = "__INSTALL_TILE_CSRF__";
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Render the "Your hub is reachable at" tile on the done step, shaped by
|
|
871
|
+
* the operator's expose-mode choice. Always surfaces the loopback URL as
|
|
872
|
+
* an anchor (the operator's own browser hits the wizard on it); the
|
|
873
|
+
* tail-end instructions reframe based on what they picked.
|
|
874
|
+
*/
|
|
875
|
+
function renderReachableTile(mode: SetupExposeMode, hubOrigin: string): string {
|
|
876
|
+
const safeOrigin = escapeHtml(hubOrigin);
|
|
877
|
+
if (mode === "localhost") {
|
|
878
|
+
return `<section class="reachable">
|
|
879
|
+
<h2>Your hub is reachable at</h2>
|
|
880
|
+
<p class="reachable-url"><code>${safeOrigin}</code></p>
|
|
881
|
+
<p class="fine">Local to this machine only. Want to share it with your
|
|
882
|
+
other devices? Re-visit setup later from the admin UI or run
|
|
883
|
+
<code>tailscale serve --bg --https=1939 http://localhost:1939</code>
|
|
884
|
+
from a terminal.</p>
|
|
885
|
+
</section>`;
|
|
886
|
+
}
|
|
887
|
+
if (mode === "tailnet") {
|
|
888
|
+
return `<section class="reachable">
|
|
889
|
+
<h2>Your hub is reachable at</h2>
|
|
890
|
+
<p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
|
|
891
|
+
<p class="reachable-url">Plus your tailnet URL once you run:</p>
|
|
892
|
+
<pre>tailscale serve --bg --https=1939 http://localhost:1939</pre>
|
|
893
|
+
<p class="fine">The Tailscale CLI prints the public hostname (e.g.
|
|
894
|
+
<code>my-mac.tailnet-name.ts.net</code>); use that on your phone /
|
|
895
|
+
other devices.</p>
|
|
896
|
+
</section>`;
|
|
897
|
+
}
|
|
898
|
+
// public
|
|
899
|
+
return `<section class="reachable">
|
|
900
|
+
<h2>Your hub is reachable at</h2>
|
|
901
|
+
<p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
|
|
902
|
+
<p class="fine">Wire a reverse proxy on your domain to
|
|
903
|
+
<code>${safeOrigin}</code>, then set <code>PARACHUTE_HUB_ORIGIN</code>
|
|
904
|
+
to your public URL and restart the hub. See the
|
|
905
|
+
<a href="https://parachute.computer/docs/deploy">deploy guide</a>
|
|
906
|
+
for nginx / Caddy / Cloudflare Tunnel examples.</p>
|
|
907
|
+
</section>`;
|
|
447
908
|
}
|
|
448
909
|
|
|
449
910
|
// --- handler entry points ------------------------------------------------
|
|
@@ -467,22 +928,100 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
467
928
|
};
|
|
468
929
|
if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
|
|
469
930
|
|
|
470
|
-
// Setup fully complete — redirect to
|
|
471
|
-
// success page once. The success
|
|
472
|
-
// session cookie is on the
|
|
473
|
-
|
|
931
|
+
// Setup fully complete (including expose-mode choice) — redirect to
|
|
932
|
+
// /login unless we're rendering the success page once. The success
|
|
933
|
+
// page sets `?just_finished=1` and the session cookie is on the
|
|
934
|
+
// request from step 2.
|
|
935
|
+
if (state.hasAdmin && state.hasVault && state.hasExposeMode) {
|
|
474
936
|
if (url.searchParams.get("just_finished") === "1") {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
937
|
+
// hub#274 security fold: session-gate this branch. The
|
|
938
|
+
// `?just_finished=1` GET reads + consumes `setup_minted_token`
|
|
939
|
+
// (full-scope operator JWT) below; without a session check, any
|
|
940
|
+
// HTTP client that races the operator's browser between the
|
|
941
|
+
// expose POST (which writes the row) and the done GET (which
|
|
942
|
+
// reads it) walks off with admin-scope creds. The dispatcher
|
|
943
|
+
// in `hub-server.ts`'s `shouldGateForSetup` lets `/admin/setup*`
|
|
944
|
+
// through the pre-admin lockout, and that path stays open
|
|
945
|
+
// post-setup — so this gate has to live here, not at the
|
|
946
|
+
// dispatcher layer.
|
|
947
|
+
//
|
|
948
|
+
// A legitimate operator carrying the session cookie minted on
|
|
949
|
+
// the account POST sails through. A drive-by GET without the
|
|
950
|
+
// cookie 302s to /login: if it's a stale bookmark in the
|
|
951
|
+
// operator's other tab, they sign in + the row is already
|
|
952
|
+
// consumed by the legitimate done-GET (the single-use shape
|
|
953
|
+
// guarantees they see the fallback shape, never the secret).
|
|
954
|
+
// If it's an attacker, they can't pass /login without the
|
|
955
|
+
// password.
|
|
956
|
+
const session = findActiveSession(deps.db, req);
|
|
957
|
+
if (!session) {
|
|
958
|
+
// Preserve the CSRF set-cookie header on the 302 — same shape as
|
|
959
|
+
// every other branch of this handler. Without it, a freshly
|
|
960
|
+
// assigned CSRF token would be lost across the redirect, and
|
|
961
|
+
// form posts from a sign-in-then-come-back flow would 400 on
|
|
962
|
+
// their first attempt.
|
|
963
|
+
const redirectHeaders: Record<string, string> = { location: "/login" };
|
|
964
|
+
if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
|
|
965
|
+
return new Response(null, {
|
|
966
|
+
status: 302,
|
|
967
|
+
headers: redirectHeaders,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
const stored = getSetting(deps.db, "setup_expose_mode");
|
|
971
|
+
const exposeMode = isSetupExposeMode(stored) ? stored : undefined;
|
|
972
|
+
// hub#272 Item A: read + consume the single-use minted-token row.
|
|
973
|
+
// Render-and-forget keeps the secret from re-appearing on
|
|
974
|
+
// refresh / back-button. The mint is non-fatal (see expose POST);
|
|
975
|
+
// its absence renders the bare MCP command + a hint at
|
|
976
|
+
// /admin/tokens.
|
|
977
|
+
const mintedToken = getSetting(deps.db, "setup_minted_token");
|
|
978
|
+
if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
|
|
979
|
+
// hub#267: the operator-typed vault name lives in hub_settings
|
|
980
|
+
// (persisted by handleSetupVaultPost). Fall back to scanning
|
|
981
|
+
// services.json — covers wizard runs from before this PR where
|
|
982
|
+
// setup_vault_name wasn't written. The services.json read
|
|
983
|
+
// returns the path-tail; vault's own first-boot write produces
|
|
984
|
+
// the canonical name so the two should agree once the vault
|
|
985
|
+
// boots authoritatively.
|
|
986
|
+
const storedName = getSetting(deps.db, "setup_vault_name");
|
|
987
|
+
const vaultName = storedName ?? firstVaultName(deps.manifestPath);
|
|
988
|
+
// Module install tiles (hub#272 Item B). One per curated module
|
|
989
|
+
// other than vault (which the wizard already provisioned).
|
|
990
|
+
const installTiles = buildInstallTiles(url, deps);
|
|
991
|
+
const doneProps: RenderDoneStepProps = {
|
|
992
|
+
vaultName,
|
|
993
|
+
hubOrigin: deps.issuer,
|
|
994
|
+
installTiles,
|
|
995
|
+
};
|
|
996
|
+
if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
|
|
997
|
+
if (mintedToken) doneProps.mintedToken = mintedToken;
|
|
998
|
+
// Substitute CSRF placeholder for the install-tile forms with
|
|
999
|
+
// the current CSRF token. Keeping the per-tile renderer pure
|
|
1000
|
+
// means the substitution lives here (one rewrite per render).
|
|
1001
|
+
const html = renderDoneStep(doneProps).replaceAll(
|
|
1002
|
+
INSTALL_TILE_CSRF_PLACEHOLDER,
|
|
1003
|
+
renderCsrfHiddenInput(csrf.token),
|
|
481
1004
|
);
|
|
1005
|
+
return new Response(html, {
|
|
1006
|
+
status: 200,
|
|
1007
|
+
headers: extraHeaders,
|
|
1008
|
+
});
|
|
482
1009
|
}
|
|
483
1010
|
return new Response(null, { status: 301, headers: { location: "/login" } });
|
|
484
1011
|
}
|
|
485
1012
|
|
|
1013
|
+
// Expose step (hub#268 Item 2). Admin + vault exist, but the operator
|
|
1014
|
+
// hasn't picked an expose mode yet. The wizard form posts to
|
|
1015
|
+
// /admin/setup/expose. Gated on having an admin session (the session
|
|
1016
|
+
// cookie was minted on step 2); on a stale tab without it, the post
|
|
1017
|
+
// handler shows the no-session error.
|
|
1018
|
+
if (state.hasAdmin && state.hasVault && !state.hasExposeMode) {
|
|
1019
|
+
return new Response(renderExposeStep({ csrfToken: csrf.token }), {
|
|
1020
|
+
status: 200,
|
|
1021
|
+
headers: extraHeaders,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
486
1025
|
// Step 3 (vault) with an op in flight — render the poll page.
|
|
487
1026
|
if (state.hasAdmin && !state.hasVault) {
|
|
488
1027
|
const opId = url.searchParams.get("op");
|
|
@@ -550,7 +1089,12 @@ export async function handleSetupAccountPost(
|
|
|
550
1089
|
return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
|
|
551
1090
|
}
|
|
552
1091
|
try {
|
|
553
|
-
|
|
1092
|
+
// Wizard-admin chose their password through this very form; skip the
|
|
1093
|
+
// multi-user-Phase-1 force-change-password redirect by landing
|
|
1094
|
+
// `password_changed=true`. `assignedVault` stays null — admin posture
|
|
1095
|
+
// (the wizard never asks the first admin to pin themselves to a
|
|
1096
|
+
// single vault; that's a non-admin user pattern).
|
|
1097
|
+
const user = await createUser(deps.db, username, password, { passwordChanged: true });
|
|
554
1098
|
const session = createSession(deps.db, { userId: user.id });
|
|
555
1099
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
556
1100
|
secure: isHttpsRequest(req),
|
|
@@ -622,16 +1166,35 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
622
1166
|
const state = deriveWizardState(deps);
|
|
623
1167
|
if (state.hasVault) return redirect("/admin/setup?just_finished=1");
|
|
624
1168
|
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1169
|
+
// hub#267: the operator-typed vault name is now threaded all the way
|
|
1170
|
+
// through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
|
|
1171
|
+
// shipped the env-var read in vault's `server.ts`). Empty input
|
|
1172
|
+
// falls back to the canonical `DEFAULT_VAULT_NAME` so the "just give
|
|
1173
|
+
// me a vault" path still works without typing anything.
|
|
1174
|
+
const csrfTokenStr = typeof formCsrf === "string" ? formCsrf : "";
|
|
1175
|
+
const rawName = String(form.get("vault_name") ?? "").trim();
|
|
1176
|
+
let vaultName: string;
|
|
1177
|
+
if (rawName === "") {
|
|
1178
|
+
vaultName = DEFAULT_VAULT_NAME;
|
|
1179
|
+
} else {
|
|
1180
|
+
const v = validateVaultName(rawName);
|
|
1181
|
+
if (!v.ok) {
|
|
1182
|
+
return htmlResponse(
|
|
1183
|
+
renderVaultStep({
|
|
1184
|
+
csrfToken: csrfTokenStr,
|
|
1185
|
+
vaultName: rawName,
|
|
1186
|
+
errorMessage: v.error,
|
|
1187
|
+
}),
|
|
1188
|
+
400,
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
vaultName = v.name;
|
|
1192
|
+
}
|
|
1193
|
+
// Persist for the done-step renderer. Vault overwrites services.json
|
|
1194
|
+
// on its first authoritative boot, but until that completes the wizard
|
|
1195
|
+
// needs a stable source of truth for the typed name — both for the
|
|
1196
|
+
// op-poll page subtitle and the post-redirect done step.
|
|
1197
|
+
setSetting(deps.db, "setup_vault_name", vaultName);
|
|
635
1198
|
const registry = deps.registry;
|
|
636
1199
|
const vaultSpec = specFor(FIRST_VAULT_SHORT);
|
|
637
1200
|
|
|
@@ -668,6 +1231,21 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
668
1231
|
? registry.create("install", FIRST_VAULT_SHORT)
|
|
669
1232
|
: { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
|
|
670
1233
|
if (registry) {
|
|
1234
|
+
// hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
|
|
1235
|
+
// vault's first-boot path (vault#342) names the created vault
|
|
1236
|
+
// accordingly. Skip the env override when the operator left the
|
|
1237
|
+
// field blank — vault's `resolveFirstBootVaultName` defaults to
|
|
1238
|
+
// `default` on absent env vars, so this preserves the prior
|
|
1239
|
+
// behaviour for the empty-input case.
|
|
1240
|
+
//
|
|
1241
|
+
// If the operator typed "default" explicitly, treat the same as
|
|
1242
|
+
// blank — vault's first-boot defaults to "default" anyway, so
|
|
1243
|
+
// skipping the env override is correct (the comparison below
|
|
1244
|
+
// catches both blank-trimmed-to-DEFAULT and typed-"default").
|
|
1245
|
+
const spawnEnv: Record<string, string> = {};
|
|
1246
|
+
if (vaultName !== DEFAULT_VAULT_NAME) {
|
|
1247
|
+
spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
|
|
1248
|
+
}
|
|
671
1249
|
void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
|
|
672
1250
|
db: deps.db,
|
|
673
1251
|
issuer: deps.issuer,
|
|
@@ -676,6 +1254,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
676
1254
|
supervisor: deps.supervisor,
|
|
677
1255
|
registry,
|
|
678
1256
|
...(deps.run ? { run: deps.run } : {}),
|
|
1257
|
+
...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
|
|
679
1258
|
}).catch((err) => {
|
|
680
1259
|
const msg = err instanceof Error ? err.message : String(err);
|
|
681
1260
|
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
@@ -691,6 +1270,246 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
691
1270
|
return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
|
|
692
1271
|
}
|
|
693
1272
|
|
|
1273
|
+
/**
|
|
1274
|
+
* POST `/admin/setup/expose`. Form-encoded.
|
|
1275
|
+
*
|
|
1276
|
+
* Persists the operator's "how will this hub be reached?" answer to
|
|
1277
|
+
* `hub_settings.setup_expose_mode` (hub#268 Item 2). Three valid values:
|
|
1278
|
+
* `localhost`, `tailnet`, `public`.
|
|
1279
|
+
*
|
|
1280
|
+
* This is also the transition where the wizard considers itself "done"
|
|
1281
|
+
* for the auto-approve-first-client feature (hub#268 Item 3): we open a
|
|
1282
|
+
* 60-minute window where the next OAuth client registration is
|
|
1283
|
+
* auto-approved. Reasoning lives in `hub-settings.ts`; the wizard just
|
|
1284
|
+
* fires it on the only event that means "operator just finished the
|
|
1285
|
+
* canonical onboarding."
|
|
1286
|
+
*
|
|
1287
|
+
* Gated on an admin session cookie like the vault POST is — same shape,
|
|
1288
|
+
* same reason.
|
|
1289
|
+
*/
|
|
1290
|
+
export async function handleSetupExposePost(
|
|
1291
|
+
req: Request,
|
|
1292
|
+
deps: SetupWizardDeps,
|
|
1293
|
+
): Promise<Response> {
|
|
1294
|
+
const form = await req.formData();
|
|
1295
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1296
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1297
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1298
|
+
}
|
|
1299
|
+
const session = findActiveSession(deps.db, req);
|
|
1300
|
+
if (!session) {
|
|
1301
|
+
return badRequestPage(
|
|
1302
|
+
"No admin session",
|
|
1303
|
+
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
// Already done — short-circuit to the success screen. Belt-and-braces:
|
|
1307
|
+
// the wizard's GET shape catches this case too, but a direct POST
|
|
1308
|
+
// (curl, tab race) shouldn't double-fire the auto-approve window.
|
|
1309
|
+
if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
|
|
1310
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1311
|
+
}
|
|
1312
|
+
const rawMode = form.get("expose_mode");
|
|
1313
|
+
if (!isSetupExposeMode(rawMode)) {
|
|
1314
|
+
return htmlResponse(
|
|
1315
|
+
renderExposeStep({
|
|
1316
|
+
csrfToken: typeof formCsrf === "string" ? formCsrf : "",
|
|
1317
|
+
errorMessage: `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
|
|
1318
|
+
}),
|
|
1319
|
+
400,
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
setSetting(deps.db, "setup_expose_mode", rawMode);
|
|
1323
|
+
// hub#268 Item 3: open the 60-minute auto-approve window for the first
|
|
1324
|
+
// OAuth client registration. Logged so an operator chasing odd behavior
|
|
1325
|
+
// can see it fired.
|
|
1326
|
+
openFirstClientAutoApproveWindow(deps.db);
|
|
1327
|
+
console.log(
|
|
1328
|
+
`[setup-wizard] opened first-client auto-approve window (60min) after expose-mode=${rawMode}`,
|
|
1329
|
+
);
|
|
1330
|
+
// hub#272 Item A: auto-mint an operator token under the broad `admin`
|
|
1331
|
+
// scope-set + persist it once so the done-step renderer can pre-fill
|
|
1332
|
+
// the MCP install command with a Bearer header. The token is single-
|
|
1333
|
+
// use surface on the done page — the renderer deletes it from
|
|
1334
|
+
// hub_settings after one read so a stale tab refresh / back button
|
|
1335
|
+
// doesn't re-disclose the secret. The jti is still in the `tokens`
|
|
1336
|
+
// registry so revocation via the admin UI works as usual. Failures
|
|
1337
|
+
// are non-fatal: the done page falls back to the un-headered MCP
|
|
1338
|
+
// command + a "mint manually at /admin/tokens" hint.
|
|
1339
|
+
try {
|
|
1340
|
+
const minted = await mintOperatorToken(deps.db, session.userId, {
|
|
1341
|
+
issuer: deps.issuer,
|
|
1342
|
+
scopeSet: "admin",
|
|
1343
|
+
});
|
|
1344
|
+
setSetting(deps.db, "setup_minted_token", minted.token);
|
|
1345
|
+
console.log(
|
|
1346
|
+
`[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
|
|
1347
|
+
);
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1350
|
+
console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
|
|
1351
|
+
}
|
|
1352
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// --- step 5 helpers: install tiles --------------------------------------
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Curated module short → display props rendered on the done-screen
|
|
1359
|
+
* install tiles. Order matters — list order is render order. Vault is
|
|
1360
|
+
* intentionally excluded (the wizard already provisioned it).
|
|
1361
|
+
*
|
|
1362
|
+
* `tagline` mirrors each module's `displayName + tagline` from
|
|
1363
|
+
* `FIRST_PARTY_FALLBACKS` (`src/service-spec.ts`); kept verbatim here
|
|
1364
|
+
* so the wizard isn't coupled to service-spec internals.
|
|
1365
|
+
*/
|
|
1366
|
+
const INSTALL_TILE_PROPS: ReadonlyArray<{
|
|
1367
|
+
short: CuratedModuleShort;
|
|
1368
|
+
displayName: string;
|
|
1369
|
+
tagline: string;
|
|
1370
|
+
}> = [
|
|
1371
|
+
{ short: "notes", displayName: "Notes", tagline: "Notes PWA backed by your vault." },
|
|
1372
|
+
{
|
|
1373
|
+
short: "scribe",
|
|
1374
|
+
displayName: "Scribe",
|
|
1375
|
+
tagline: "Local audio transcription for vault recordings.",
|
|
1376
|
+
},
|
|
1377
|
+
];
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Construct the install-tile state array for the done step. Reads the
|
|
1381
|
+
* URL's `?op_<short>=<id>` query (per-module op-poll), the services.json
|
|
1382
|
+
* manifest (already-installed detection), and the operations registry
|
|
1383
|
+
* (op status snapshot). Pure-ish — only the registry call is impure.
|
|
1384
|
+
*/
|
|
1385
|
+
function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
|
|
1386
|
+
const manifest = readManifest(deps.manifestPath);
|
|
1387
|
+
return INSTALL_TILE_PROPS.filter((p) =>
|
|
1388
|
+
(CURATED_MODULES as readonly string[]).includes(p.short),
|
|
1389
|
+
).map((p) => {
|
|
1390
|
+
const spec = specFor(p.short);
|
|
1391
|
+
const alreadyInstalled = manifest.services.some((s) => s.name === spec.manifestName);
|
|
1392
|
+
const tile: ModuleInstallTileState = {
|
|
1393
|
+
short: p.short,
|
|
1394
|
+
displayName: p.displayName,
|
|
1395
|
+
tagline: p.tagline,
|
|
1396
|
+
alreadyInstalled,
|
|
1397
|
+
};
|
|
1398
|
+
const opId = url.searchParams.get(`op_${p.short}`);
|
|
1399
|
+
if (opId && deps.registry) {
|
|
1400
|
+
const op = deps.registry.get(opId);
|
|
1401
|
+
if (op) {
|
|
1402
|
+
tile.operation = {
|
|
1403
|
+
id: op.id,
|
|
1404
|
+
status: op.status,
|
|
1405
|
+
log: op.log,
|
|
1406
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return tile;
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* POST `/admin/setup/install/<short>`. Form-encoded, session-gated.
|
|
1416
|
+
*
|
|
1417
|
+
* Kicks off the same `runInstall` pipeline `/api/modules/<short>/install`
|
|
1418
|
+
* uses (hub#260) but from the wizard's session-cookie surface — no
|
|
1419
|
+
* separate bearer mint dance for the operator who just finished the
|
|
1420
|
+
* wizard.
|
|
1421
|
+
*
|
|
1422
|
+
* Returns 303 to `/admin/setup?just_finished=1&op_<short>=<opId>` so
|
|
1423
|
+
* the done-screen renderer picks up the op via `buildInstallTiles`.
|
|
1424
|
+
* Multiple in-flight installs are supported (query keeps `op_<short>`
|
|
1425
|
+
* per module); the auto-refresh meta keeps polling while any module
|
|
1426
|
+
* is pending/running.
|
|
1427
|
+
*
|
|
1428
|
+
* Rejects when:
|
|
1429
|
+
* * `short` isn't a curated module short
|
|
1430
|
+
* * `short === "vault"` — the wizard's vault step owns that
|
|
1431
|
+
* * session cookie missing
|
|
1432
|
+
* * CSRF token missing or wrong
|
|
1433
|
+
* * supervisor isn't wired (CLI-mode hub)
|
|
1434
|
+
*/
|
|
1435
|
+
export async function handleSetupInstallPost(
|
|
1436
|
+
req: Request,
|
|
1437
|
+
short: string,
|
|
1438
|
+
deps: SetupWizardDeps,
|
|
1439
|
+
): Promise<Response> {
|
|
1440
|
+
if (!deps.supervisor) {
|
|
1441
|
+
return badRequestPage(
|
|
1442
|
+
"Module supervisor unavailable",
|
|
1443
|
+
`Module installs from the wizard require container-mode \`parachute serve\`. On the on-box CLI surface, run \`parachute install ${short}\` directly.`,
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (!(CURATED_MODULES as readonly string[]).includes(short) || short === "vault") {
|
|
1447
|
+
return badRequestPage(
|
|
1448
|
+
"Unknown module",
|
|
1449
|
+
`"${short}" is not an installable wizard module. Pick from the done-screen tiles.`,
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
const form = await req.formData();
|
|
1453
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
1454
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
1455
|
+
return badRequestPage("Invalid form submission", "Reload and try again.");
|
|
1456
|
+
}
|
|
1457
|
+
const session = findActiveSession(deps.db, req);
|
|
1458
|
+
if (!session) {
|
|
1459
|
+
return badRequestPage(
|
|
1460
|
+
"No admin session",
|
|
1461
|
+
"Sign in to continue. The wizard's session cookie was set at step 2; clearing cookies between steps lands you here.",
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
const moduleShort = short as CuratedModuleShort;
|
|
1465
|
+
const spec = specFor(moduleShort);
|
|
1466
|
+
const registry = deps.registry;
|
|
1467
|
+
// Idempotent short-circuit: if already supervised + running, return a
|
|
1468
|
+
// synthesized succeeded op rather than firing a second `bun add`.
|
|
1469
|
+
// Mirrors `handleSetupVaultPost` + `handleInstall`.
|
|
1470
|
+
const supervisorState = deps.supervisor.get(moduleShort);
|
|
1471
|
+
if (
|
|
1472
|
+
supervisorState?.status === "running" ||
|
|
1473
|
+
supervisorState?.status === "starting" ||
|
|
1474
|
+
supervisorState?.status === "restarting"
|
|
1475
|
+
) {
|
|
1476
|
+
if (registry) {
|
|
1477
|
+
const op = registry.create("install", moduleShort);
|
|
1478
|
+
registry.update(
|
|
1479
|
+
op.id,
|
|
1480
|
+
{ status: "succeeded" },
|
|
1481
|
+
`${moduleShort} already supervised (status=${supervisorState.status})`,
|
|
1482
|
+
);
|
|
1483
|
+
return redirect(
|
|
1484
|
+
`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`,
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
return redirect("/admin/setup?just_finished=1");
|
|
1488
|
+
}
|
|
1489
|
+
const op = registry
|
|
1490
|
+
? registry.create("install", moduleShort)
|
|
1491
|
+
: { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
|
|
1492
|
+
if (registry) {
|
|
1493
|
+
void runInstall(op.id, moduleShort, spec, {
|
|
1494
|
+
db: deps.db,
|
|
1495
|
+
issuer: deps.issuer,
|
|
1496
|
+
manifestPath: deps.manifestPath,
|
|
1497
|
+
configDir: deps.configDir,
|
|
1498
|
+
supervisor: deps.supervisor,
|
|
1499
|
+
registry,
|
|
1500
|
+
...(deps.run ? { run: deps.run } : {}),
|
|
1501
|
+
}).catch((err) => {
|
|
1502
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1503
|
+
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
1504
|
+
});
|
|
1505
|
+
} else {
|
|
1506
|
+
console.warn(
|
|
1507
|
+
"[setup-wizard] handleSetupInstallPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
return redirect(`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
694
1513
|
// --- helpers ------------------------------------------------------------
|
|
695
1514
|
|
|
696
1515
|
function validateAccountFields(input: {
|
|
@@ -969,6 +1788,147 @@ const STYLES = `
|
|
|
969
1788
|
margin-top: 0.4rem;
|
|
970
1789
|
}
|
|
971
1790
|
.btn-primary:hover { background: ${PALETTE.accentHover}; }
|
|
1791
|
+
.btn-secondary {
|
|
1792
|
+
background: transparent;
|
|
1793
|
+
color: ${PALETTE.accent};
|
|
1794
|
+
border-color: ${PALETTE.accent};
|
|
1795
|
+
}
|
|
1796
|
+
.btn-secondary:hover {
|
|
1797
|
+
background: ${PALETTE.accentSoft};
|
|
1798
|
+
}
|
|
1799
|
+
/* Copy + Show buttons ride the right edge of the MCP command pre.
|
|
1800
|
+
Compact vertical sizing so they don't dwarf the snippet on narrow
|
|
1801
|
+
widths; full text wrap on the pre keeps the snippet readable
|
|
1802
|
+
behind them. The Show button toggles the visible mask on the
|
|
1803
|
+
auto-minted Bearer token (rc.11 — discoverable
|
|
1804
|
+
but not shoulder-surf-able). Both buttons share a small flex
|
|
1805
|
+
container so they stack predictably on the wrap; layout-wise we
|
|
1806
|
+
keep the right-edge padding on .mcp-cmd-wrap pre so the buttons
|
|
1807
|
+
never overlap the command text. */
|
|
1808
|
+
.mcp-cmd-wrap {
|
|
1809
|
+
position: relative;
|
|
1810
|
+
margin: 0.5rem 0;
|
|
1811
|
+
}
|
|
1812
|
+
.mcp-cmd-wrap pre {
|
|
1813
|
+
background: ${PALETTE.bg};
|
|
1814
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1815
|
+
border-radius: 6px;
|
|
1816
|
+
padding: 0.5rem 8.5rem 0.5rem 0.75rem;
|
|
1817
|
+
overflow-x: auto;
|
|
1818
|
+
font-size: 0.82rem;
|
|
1819
|
+
margin: 0;
|
|
1820
|
+
white-space: pre-wrap;
|
|
1821
|
+
word-break: break-all;
|
|
1822
|
+
}
|
|
1823
|
+
.mcp-cmd-actions {
|
|
1824
|
+
position: absolute;
|
|
1825
|
+
top: 0.35rem;
|
|
1826
|
+
right: 0.35rem;
|
|
1827
|
+
display: flex;
|
|
1828
|
+
gap: 0.3rem;
|
|
1829
|
+
}
|
|
1830
|
+
.btn-copy, .btn-mcp-aux {
|
|
1831
|
+
padding: 0.25rem 0.6rem;
|
|
1832
|
+
font-size: 0.78rem;
|
|
1833
|
+
min-height: auto;
|
|
1834
|
+
background: ${PALETTE.cardBg};
|
|
1835
|
+
color: ${PALETTE.fg};
|
|
1836
|
+
border: 1px solid ${PALETTE.border};
|
|
1837
|
+
border-radius: 4px;
|
|
1838
|
+
cursor: pointer;
|
|
1839
|
+
font: inherit;
|
|
1840
|
+
font-size: 0.78rem;
|
|
1841
|
+
}
|
|
1842
|
+
.btn-copy:hover, .btn-mcp-aux:hover {
|
|
1843
|
+
border-color: ${PALETTE.accent};
|
|
1844
|
+
color: ${PALETTE.accent};
|
|
1845
|
+
}
|
|
1846
|
+
.mcp-cmd-wrap[data-state="revealed"] pre {
|
|
1847
|
+
/* Subtle visual cue that the token is currently visible — a warm
|
|
1848
|
+
border so the operator notices on a screencast even at low
|
|
1849
|
+
resolution. */
|
|
1850
|
+
border-color: #d4a017;
|
|
1851
|
+
background: rgba(212, 160, 23, 0.04);
|
|
1852
|
+
}
|
|
1853
|
+
.mcp-cmd-wrap[data-state="revealed"] .btn-mcp-aux {
|
|
1854
|
+
border-color: #d4a017;
|
|
1855
|
+
color: #6b4a00;
|
|
1856
|
+
}
|
|
1857
|
+
/* Install-tile section (hub#272 Item B). Lives above the .done-grid;
|
|
1858
|
+
primary "what's next?" surface. Tiles render in a responsive grid
|
|
1859
|
+
that collapses to one column on narrow viewports. */
|
|
1860
|
+
.install-tiles {
|
|
1861
|
+
margin: 1rem 0 1.25rem;
|
|
1862
|
+
}
|
|
1863
|
+
.install-tiles-heading {
|
|
1864
|
+
margin: 0 0 0.25rem;
|
|
1865
|
+
text-transform: none;
|
|
1866
|
+
letter-spacing: 0;
|
|
1867
|
+
font-size: 1.05rem;
|
|
1868
|
+
color: ${PALETTE.fg};
|
|
1869
|
+
}
|
|
1870
|
+
.install-tiles-subtitle {
|
|
1871
|
+
margin: 0 0 0.75rem;
|
|
1872
|
+
color: ${PALETTE.fgMuted};
|
|
1873
|
+
font-size: 0.9rem;
|
|
1874
|
+
}
|
|
1875
|
+
.install-grid {
|
|
1876
|
+
display: grid;
|
|
1877
|
+
grid-template-columns: 1fr;
|
|
1878
|
+
gap: 0.75rem;
|
|
1879
|
+
}
|
|
1880
|
+
@media (min-width: 30rem) {
|
|
1881
|
+
.install-grid { grid-template-columns: 1fr 1fr; }
|
|
1882
|
+
}
|
|
1883
|
+
.install-tile {
|
|
1884
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1885
|
+
border-radius: 8px;
|
|
1886
|
+
padding: 0.75rem 0.9rem;
|
|
1887
|
+
background: ${PALETTE.cardBg};
|
|
1888
|
+
display: flex;
|
|
1889
|
+
flex-direction: column;
|
|
1890
|
+
gap: 0.4rem;
|
|
1891
|
+
}
|
|
1892
|
+
.install-tile h3 {
|
|
1893
|
+
margin: 0;
|
|
1894
|
+
font-family: ${FONT_SERIF};
|
|
1895
|
+
font-weight: 400;
|
|
1896
|
+
font-size: 1.1rem;
|
|
1897
|
+
color: ${PALETTE.fg};
|
|
1898
|
+
}
|
|
1899
|
+
.install-tile-tagline {
|
|
1900
|
+
margin: 0;
|
|
1901
|
+
color: ${PALETTE.fgMuted};
|
|
1902
|
+
font-size: 0.85rem;
|
|
1903
|
+
}
|
|
1904
|
+
.install-tile-form {
|
|
1905
|
+
margin: 0;
|
|
1906
|
+
}
|
|
1907
|
+
.install-tile-installed {
|
|
1908
|
+
background: ${PALETTE.accentSoft};
|
|
1909
|
+
border-color: ${PALETTE.accent};
|
|
1910
|
+
}
|
|
1911
|
+
.install-tile-status {
|
|
1912
|
+
margin: 0;
|
|
1913
|
+
color: ${PALETTE.success};
|
|
1914
|
+
font-weight: 500;
|
|
1915
|
+
font-size: 0.85rem;
|
|
1916
|
+
}
|
|
1917
|
+
.install-tile-running, .install-tile-pending {
|
|
1918
|
+
border-color: ${PALETTE.warn};
|
|
1919
|
+
}
|
|
1920
|
+
.install-tile-succeeded {
|
|
1921
|
+
background: ${PALETTE.accentSoft};
|
|
1922
|
+
border-color: ${PALETTE.accent};
|
|
1923
|
+
}
|
|
1924
|
+
.install-tile-failed {
|
|
1925
|
+
border-color: ${PALETTE.danger};
|
|
1926
|
+
background: ${PALETTE.dangerSoft};
|
|
1927
|
+
}
|
|
1928
|
+
.install-tile-log {
|
|
1929
|
+
margin: 0;
|
|
1930
|
+
font-size: 0.78rem;
|
|
1931
|
+
}
|
|
972
1932
|
.alt-path {
|
|
973
1933
|
margin-top: 1.25rem;
|
|
974
1934
|
border-top: 1px solid ${PALETTE.borderLight};
|
|
@@ -1045,6 +2005,90 @@ const STYLES = `
|
|
|
1045
2005
|
}
|
|
1046
2006
|
.done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
|
|
1047
2007
|
|
|
2008
|
+
/* expose step (hub#268 Item 2). Vertical stack of radio cards;
|
|
2009
|
+
each label is the full clickable hit target. */
|
|
2010
|
+
.expose-form { gap: 0.65rem; }
|
|
2011
|
+
.expose-option {
|
|
2012
|
+
display: flex;
|
|
2013
|
+
align-items: flex-start;
|
|
2014
|
+
gap: 0.65rem;
|
|
2015
|
+
padding: 0.85rem 1rem;
|
|
2016
|
+
border: 1px solid ${PALETTE.border};
|
|
2017
|
+
border-radius: 8px;
|
|
2018
|
+
cursor: pointer;
|
|
2019
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
2020
|
+
background: ${PALETTE.cardBg};
|
|
2021
|
+
}
|
|
2022
|
+
.expose-option:hover { border-color: ${PALETTE.accent}; }
|
|
2023
|
+
.expose-option input[type=radio] {
|
|
2024
|
+
margin-top: 0.25rem;
|
|
2025
|
+
accent-color: ${PALETTE.accent};
|
|
2026
|
+
flex-shrink: 0;
|
|
2027
|
+
}
|
|
2028
|
+
.expose-option-body {
|
|
2029
|
+
display: flex;
|
|
2030
|
+
flex-direction: column;
|
|
2031
|
+
gap: 0.25rem;
|
|
2032
|
+
min-width: 0;
|
|
2033
|
+
}
|
|
2034
|
+
.expose-option-title {
|
|
2035
|
+
font-weight: 600;
|
|
2036
|
+
color: ${PALETTE.fg};
|
|
2037
|
+
font-size: 0.95rem;
|
|
2038
|
+
}
|
|
2039
|
+
.expose-option-desc {
|
|
2040
|
+
color: ${PALETTE.fgMuted};
|
|
2041
|
+
font-size: 0.88rem;
|
|
2042
|
+
line-height: 1.45;
|
|
2043
|
+
}
|
|
2044
|
+
.expose-option-cmd {
|
|
2045
|
+
background: ${PALETTE.bg};
|
|
2046
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
2047
|
+
border-radius: 6px;
|
|
2048
|
+
padding: 0.4rem 0.6rem;
|
|
2049
|
+
font-family: ${FONT_MONO};
|
|
2050
|
+
font-size: 0.82rem;
|
|
2051
|
+
margin: 0.25rem 0;
|
|
2052
|
+
overflow-x: auto;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/* reachable tile on the done step. Lives outside the .done-grid so it
|
|
2056
|
+
spans the full width — the URL itself is the headline. */
|
|
2057
|
+
.reachable {
|
|
2058
|
+
background: ${PALETTE.accentSoft};
|
|
2059
|
+
border-left: 3px solid ${PALETTE.accent};
|
|
2060
|
+
border-radius: 0 8px 8px 0;
|
|
2061
|
+
padding: 0.75rem 1rem;
|
|
2062
|
+
margin: 0 0 1rem;
|
|
2063
|
+
}
|
|
2064
|
+
.reachable h2 {
|
|
2065
|
+
margin: 0 0 0.4rem;
|
|
2066
|
+
text-transform: none;
|
|
2067
|
+
letter-spacing: 0;
|
|
2068
|
+
font-size: 0.9rem;
|
|
2069
|
+
color: ${PALETTE.accent};
|
|
2070
|
+
}
|
|
2071
|
+
.reachable-url {
|
|
2072
|
+
margin: 0.2rem 0;
|
|
2073
|
+
font-size: 0.95rem;
|
|
2074
|
+
}
|
|
2075
|
+
.reachable-url code {
|
|
2076
|
+
background: ${PALETTE.cardBg};
|
|
2077
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
2078
|
+
padding: 0.1rem 0.4rem;
|
|
2079
|
+
border-radius: 4px;
|
|
2080
|
+
}
|
|
2081
|
+
.reachable pre {
|
|
2082
|
+
background: ${PALETTE.cardBg};
|
|
2083
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
2084
|
+
border-radius: 6px;
|
|
2085
|
+
padding: 0.5rem 0.75rem;
|
|
2086
|
+
overflow-x: auto;
|
|
2087
|
+
font-size: 0.82rem;
|
|
2088
|
+
margin: 0.4rem 0;
|
|
2089
|
+
}
|
|
2090
|
+
.reachable .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
|
|
2091
|
+
|
|
1048
2092
|
code {
|
|
1049
2093
|
background: ${PALETTE.borderLight};
|
|
1050
2094
|
padding: 0.05rem 0.3rem;
|