@openparachute/hub 0.6.4-rc.7 → 0.6.4-rc.9

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.
@@ -4051,6 +4051,116 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
4051
4051
  }
4052
4052
  });
4053
4053
 
4054
+ test("JSON probe hands the bootstrap token VALUE to a loopback caller (hub#576)", async () => {
4055
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4056
+ "../bootstrap-token.ts"
4057
+ );
4058
+ _resetBootstrapTokenForTests();
4059
+ const token = generateBootstrapToken();
4060
+ const db = openHubDb(hubDbPath(h.dir));
4061
+ try {
4062
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4063
+ db,
4064
+ manifestPath: h.manifestPath,
4065
+ configDir: h.dir,
4066
+ readExposeStateFn: h.readExposeStateFn,
4067
+ issuer: "http://127.0.0.1:1939",
4068
+ registry: getDefaultOperationsRegistry(),
4069
+ requestIsLoopback: true,
4070
+ });
4071
+ const body = (await res.json()) as {
4072
+ requireBootstrapToken: boolean;
4073
+ bootstrapToken?: string;
4074
+ };
4075
+ expect(body.requireBootstrapToken).toBe(true);
4076
+ expect(body.bootstrapToken).toBe(token);
4077
+ } finally {
4078
+ _resetBootstrapTokenForTests();
4079
+ db.close();
4080
+ }
4081
+ });
4082
+
4083
+ test("JSON probe withholds the token VALUE from a non-loopback caller (hub#576)", async () => {
4084
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4085
+ "../bootstrap-token.ts"
4086
+ );
4087
+ _resetBootstrapTokenForTests();
4088
+ generateBootstrapToken();
4089
+ const db = openHubDb(hubDbPath(h.dir));
4090
+ try {
4091
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4092
+ db,
4093
+ manifestPath: h.manifestPath,
4094
+ configDir: h.dir,
4095
+ readExposeStateFn: h.readExposeStateFn,
4096
+ issuer: "http://127.0.0.1:1939",
4097
+ registry: getDefaultOperationsRegistry(),
4098
+ requestIsLoopback: false,
4099
+ });
4100
+ const body = (await res.json()) as {
4101
+ requireBootstrapToken: boolean;
4102
+ bootstrapToken?: string;
4103
+ };
4104
+ // The boolean still tells a public browser a token is required...
4105
+ expect(body.requireBootstrapToken).toBe(true);
4106
+ // ...but the VALUE never leaks to it.
4107
+ expect(body.bootstrapToken).toBeUndefined();
4108
+ } finally {
4109
+ _resetBootstrapTokenForTests();
4110
+ db.close();
4111
+ }
4112
+ });
4113
+
4114
+ test("JSON probe fails CLOSED when loopback is unknown (hub#576)", async () => {
4115
+ const { generateBootstrapToken, _resetBootstrapTokenForTests } = await import(
4116
+ "../bootstrap-token.ts"
4117
+ );
4118
+ _resetBootstrapTokenForTests();
4119
+ generateBootstrapToken();
4120
+ const db = openHubDb(hubDbPath(h.dir));
4121
+ try {
4122
+ // `requestIsLoopback` omitted entirely — must be treated as non-loopback.
4123
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4124
+ db,
4125
+ manifestPath: h.manifestPath,
4126
+ configDir: h.dir,
4127
+ readExposeStateFn: h.readExposeStateFn,
4128
+ issuer: "http://127.0.0.1:1939",
4129
+ registry: getDefaultOperationsRegistry(),
4130
+ });
4131
+ const body = (await res.json()) as { bootstrapToken?: string };
4132
+ expect(body.bootstrapToken).toBeUndefined();
4133
+ } finally {
4134
+ _resetBootstrapTokenForTests();
4135
+ db.close();
4136
+ }
4137
+ });
4138
+
4139
+ test("JSON probe omits the token when no admin gate is active (hub#576)", async () => {
4140
+ const { _resetBootstrapTokenForTests } = await import("../bootstrap-token.ts");
4141
+ _resetBootstrapTokenForTests(); // no token minted → not in wizard mode
4142
+ const db = openHubDb(hubDbPath(h.dir));
4143
+ try {
4144
+ const res = handleSetupGet(req("/admin/setup", { headers: { accept: "application/json" } }), {
4145
+ db,
4146
+ manifestPath: h.manifestPath,
4147
+ configDir: h.dir,
4148
+ readExposeStateFn: h.readExposeStateFn,
4149
+ issuer: "http://127.0.0.1:1939",
4150
+ registry: getDefaultOperationsRegistry(),
4151
+ requestIsLoopback: true,
4152
+ });
4153
+ const body = (await res.json()) as {
4154
+ requireBootstrapToken: boolean;
4155
+ bootstrapToken?: string;
4156
+ };
4157
+ expect(body.requireBootstrapToken).toBe(false);
4158
+ expect(body.bootstrapToken).toBeUndefined();
4159
+ } finally {
4160
+ db.close();
4161
+ }
4162
+ });
4163
+
4054
4164
  test("vault step skip mode short-circuits + persists setup_vault_skipped", async () => {
4055
4165
  const db = openHubDb(hubDbPath(h.dir));
4056
4166
  try {
@@ -123,6 +123,31 @@ describe("buildWellKnown", () => {
123
123
  ]);
124
124
  });
125
125
 
126
+ test("SEED placeholder vault entry is NOT fabricated into a vault row (hub#577)", () => {
127
+ // `parachute init` installs the vault MODULE without creating an instance,
128
+ // seeding a services.json entry at version "0.0.0-linked" with the
129
+ // canonical /vault/default mount. That must NOT surface as a phantom
130
+ // `default` vault in the management page.
131
+ const seed: ServiceEntry = { ...vault, version: "0.0.0-linked" };
132
+ const doc = buildWellKnown({
133
+ services: [seed],
134
+ canonicalOrigin: "https://x.example",
135
+ });
136
+ // No phantom vault row...
137
+ expect(doc.vaults).toEqual([]);
138
+ // ...but the services entry stays so the SPA knows the module IS installed
139
+ // (offers "New vault", not "Install module").
140
+ expect(doc.services.map((s) => s.name)).toEqual(["parachute-vault"]);
141
+ });
142
+
143
+ test("a REAL (non-seed) vault entry still lands in vaults[] (hub#577 regression guard)", () => {
144
+ const doc = buildWellKnown({
145
+ services: [{ ...vault, version: "0.5.1", paths: ["/vault/techne"] }],
146
+ canonicalOrigin: "https://x.example",
147
+ });
148
+ expect(doc.vaults.map((v) => v.name)).toEqual(["techne"]);
149
+ });
150
+
126
151
  test("multiple installs of the same kind both land in the array (#92)", () => {
127
152
  const work: ServiceEntry = { ...notes, paths: ["/notes-work"], port: 5174 };
128
153
  const doc = buildWellKnown({
@@ -32,6 +32,12 @@ interface FakeHubState {
32
32
  importParams?: { remoteUrl: string; pat?: string; mode: string };
33
33
  exposeMode?: string;
34
34
  posted: Array<{ path: string; body: unknown }>;
35
+ /** hub#576: when set, the fake GET /admin/setup reports requireBootstrapToken=true. */
36
+ requireBootstrapToken?: boolean;
37
+ /** hub#576: when set, the fake GET also returns it (loopback-probe behavior). */
38
+ bootstrapToken?: string;
39
+ /** hub#576: when true, the account POST 401s unless the right token is supplied. */
40
+ enforceBootstrapToken?: boolean;
35
41
  }
36
42
 
37
43
  function makeFakeHub(initialState?: Partial<FakeHubState>): {
@@ -86,8 +92,10 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
86
92
  hasAdmin: state.hasAdmin,
87
93
  hasVault: state.hasVault,
88
94
  hasExposeMode: state.hasExposeMode,
89
- requireBootstrapToken: false,
95
+ requireBootstrapToken: state.requireBootstrapToken ?? false,
90
96
  csrfToken: csrf,
97
+ // hub#576: a loopback probe carries the actual token value.
98
+ ...(state.bootstrapToken ? { bootstrapToken: state.bootstrapToken } : {}),
91
99
  });
92
100
  return new Response(respBody, {
93
101
  status: 200,
@@ -101,6 +109,17 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
101
109
  // POST /admin/setup/account
102
110
  if (path === "/admin/setup/account" && method === "POST") {
103
111
  state.posted.push({ path, body: bodyJson });
112
+ // hub#576: reject when the gate is enforced and the supplied token is
113
+ // wrong / missing — proves the CLI wizard actually sends it.
114
+ if (state.enforceBootstrapToken) {
115
+ const supplied = (bodyJson as { bootstrap_token?: string })?.bootstrap_token;
116
+ if (supplied !== state.bootstrapToken) {
117
+ return new Response(JSON.stringify({ error: "bad bootstrap token" }), {
118
+ status: 401,
119
+ headers: { "content-type": "application/json; charset=utf-8" },
120
+ });
121
+ }
122
+ }
104
123
  state.hasAdmin = true;
105
124
  return new Response(JSON.stringify({ step: "vault", message: "admin created" }), {
106
125
  status: 200,
@@ -270,6 +289,58 @@ describe("runCliWizard", () => {
270
289
  expect(state.exposeMode).toBe("localhost");
271
290
  });
272
291
 
292
+ test("loopback-probe bootstrap token is sent transparently (no prompt) — hub#576", async () => {
293
+ const { state, fetchImpl } = makeFakeHub({
294
+ requireBootstrapToken: true,
295
+ bootstrapToken: "parachute-bootstrap-LOOPBACK",
296
+ enforceBootstrapToken: true,
297
+ });
298
+ let prompted = false;
299
+ const code = await runCliWizard({
300
+ hubUrl: "http://127.0.0.1:1939",
301
+ log: () => {},
302
+ fetchImpl,
303
+ sleep: async () => {},
304
+ // No --bootstrap-token flag, no env: the value must come from the probe.
305
+ prompt: async () => {
306
+ prompted = true;
307
+ return "";
308
+ },
309
+ accountUsername: "admin",
310
+ accountPassword: "longpassword",
311
+ vaultMode: "skip",
312
+ exposeMode: "localhost",
313
+ });
314
+ expect(code).toBe(0);
315
+ // The account POST carried the probe-supplied token...
316
+ const accountBody = state.posted[0]?.body as Record<string, string>;
317
+ expect(accountBody.bootstrap_token).toBe("parachute-bootstrap-LOOPBACK");
318
+ // ...and the operator was never asked for it.
319
+ expect(prompted).toBe(false);
320
+ });
321
+
322
+ test("explicit --bootstrap-token flag still wins over the probe value — hub#576", async () => {
323
+ const { state, fetchImpl } = makeFakeHub({
324
+ requireBootstrapToken: true,
325
+ bootstrapToken: "parachute-bootstrap-PROBE",
326
+ enforceBootstrapToken: false,
327
+ });
328
+ const code = await runCliWizard({
329
+ hubUrl: "http://127.0.0.1:1939",
330
+ log: () => {},
331
+ fetchImpl,
332
+ sleep: async () => {},
333
+ bootstrapToken: "parachute-bootstrap-EXPLICIT",
334
+ accountUsername: "admin",
335
+ accountPassword: "longpassword",
336
+ vaultMode: "skip",
337
+ exposeMode: "localhost",
338
+ });
339
+ expect(code).toBe(0);
340
+ const accountBody = state.posted[0]?.body as Record<string, string>;
341
+ expect(accountBody.bootstrap_token).toBe("parachute-bootstrap-EXPLICIT");
342
+ });
343
+
273
344
  test("vault import mode threads remote_url + pat + import_mode", async () => {
274
345
  const { state, fetchImpl } = makeFakeHub();
275
346
  const code = await runCliWizard({
@@ -327,8 +327,31 @@ function renderOnboardingChecklist(opts: OnboardingChecklistOpts): string {
327
327
  const safeEndpoint = escapeHtml(endpoint);
328
328
  const safeAddCmd = escapeHtml(addCmd);
329
329
 
330
- // Condensed state they've connected, so the checklist shrinks to a single
331
- // reassuring line. The vault card below remains the place to actually work.
330
+ // The endpoint + both connect methods. Shared between the full checklist
331
+ // (step 2) and the condensed "Connect another AI" expander (hub#583) so a
332
+ // genuinely-connected user can still wire up a SECOND client without losing
333
+ // the instructions.
334
+ const connectMethods = `
335
+ <div class="copy-row">
336
+ <code data-testid="onboarding-mcp-endpoint">${safeEndpoint}</code>
337
+ <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
338
+ data-testid="copy-onboarding-endpoint">Copy</button>
339
+ </div>
340
+ <p class="onboarding-method"><strong>Claude.ai (web):</strong> open
341
+ Settings → Connectors → Add custom connector, and paste the address above.</p>
342
+ <p class="onboarding-method"><strong>Claude Code (terminal):</strong> run this command:</p>
343
+ <div class="copy-row">
344
+ <code data-testid="onboarding-mcp-add-command">${safeAddCmd}</code>
345
+ <button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
346
+ data-testid="copy-onboarding-add-command">Copy</button>
347
+ </div>`;
348
+
349
+ // Condensed state — they've connected, so the checklist shrinks to a quiet
350
+ // reassuring line. But keep a "Connect another AI" expander (hub#583): the
351
+ // condensed line used to DELETE the endpoint + methods outright, leaving a
352
+ // connected user no way to wire up a second client. A <details> expander
353
+ // (server-rendered, no-JS-required — the copy buttons stay progressive
354
+ // enhancement) re-reveals the full inline instructions on demand.
332
355
  if (connected) {
333
356
  return `
334
357
  <section class="section onboarding onboarding-done" data-testid="onboarding-checklist"
@@ -336,6 +359,14 @@ function renderOnboardingChecklist(opts: OnboardingChecklistOpts): string {
336
359
  <p class="onboarding-done-line" data-testid="onboarding-done-line">
337
360
  <span class="onboarding-check" aria-hidden="true">✓</span>
338
361
  You're connected — here's your vault.</p>
362
+ <details class="onboarding-connect-another" data-testid="onboarding-connect-another">
363
+ <summary data-testid="onboarding-connect-another-summary">Connect another AI →</summary>
364
+ <div class="onboarding-step-body">
365
+ <p class="onboarding-step-sub">Point another AI client at your vault using this
366
+ address — you'll sign in and approve the first time:</p>
367
+ ${connectMethods}
368
+ </div>
369
+ </details>
339
370
  </section>`;
340
371
  }
341
372
 
@@ -360,19 +391,7 @@ function renderOnboardingChecklist(opts: OnboardingChecklistOpts): string {
360
391
  <p class="onboarding-step-title">Connect your AI</p>
361
392
  <p class="onboarding-step-sub">Point Claude (or another AI) at your vault using this
362
393
  address — no token to copy, you'll sign in and approve the first time:</p>
363
- <div class="copy-row">
364
- <code data-testid="onboarding-mcp-endpoint">${safeEndpoint}</code>
365
- <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
366
- data-testid="copy-onboarding-endpoint">Copy</button>
367
- </div>
368
- <p class="onboarding-method"><strong>Claude.ai (web):</strong> open
369
- Settings → Connectors → Add custom connector, and paste the address above.</p>
370
- <p class="onboarding-method"><strong>Claude Code (terminal):</strong> run this command:</p>
371
- <div class="copy-row">
372
- <code data-testid="onboarding-mcp-add-command">${safeAddCmd}</code>
373
- <button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
374
- data-testid="copy-onboarding-add-command">Copy</button>
375
- </div>
394
+ ${connectMethods}
376
395
  </div>
377
396
  </li>
378
397
 
@@ -955,6 +974,18 @@ const STYLES = `
955
974
  align-items: center;
956
975
  justify-content: center;
957
976
  }
977
+ .onboarding-connect-another { margin: 0.7rem 0 0; }
978
+ .onboarding-connect-another > summary {
979
+ cursor: pointer;
980
+ font-size: 0.85rem;
981
+ font-weight: 500;
982
+ color: ${PALETTE.accent};
983
+ list-style: none;
984
+ user-select: none;
985
+ }
986
+ .onboarding-connect-another > summary::-webkit-details-marker { display: none; }
987
+ .onboarding-connect-another[open] > summary { margin-bottom: 0.4rem; }
988
+ .onboarding-connect-another .copy-row { margin: 0.35rem 0; }
958
989
 
959
990
  .account-security {
960
991
  margin: 0.9rem 0 0;
@@ -69,7 +69,7 @@ import {
69
69
  } from "./account-home-ui.ts";
70
70
  import { renderAdminError } from "./admin-login-ui.ts";
71
71
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
72
- import { userHasVaultGrant } from "./grants.ts";
72
+ import { userHasExternalAiGrant } from "./grants.ts";
73
73
  import { inferAudience } from "./jwt-audience.ts";
74
74
  import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
75
75
  import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
@@ -190,7 +190,14 @@ export async function handleAccountVaultTokenPost(
190
190
  csrfToken: csrf.token,
191
191
  twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
192
192
  mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
193
- connectedVault: user.assignedVaults.some((v) => userHasVaultGrant(deps.db, user.id, v)),
193
+ // hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor,
194
+ // …) was wired to a vault — NOT a first-party browser sign-in. Notes /
195
+ // the admin SPA are OAuth clients too and write vault-scoped grants, so
196
+ // the old `userHasVaultGrant` lit "✓ You're connected" the moment the
197
+ // user opened Notes. `userHasExternalAiGrant` filters those out.
198
+ connectedVault: user.assignedVaults.some((v) =>
199
+ userHasExternalAiGrant(deps.db, user.id, v),
200
+ ),
194
201
  ...extras,
195
202
  }),
196
203
  status,
@@ -54,7 +54,7 @@ import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
54
54
  import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
55
55
  import { renderAdminError } from "./admin-login-ui.ts";
56
56
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
57
- import { userHasVaultGrant } from "./grants.ts";
57
+ import { userHasExternalAiGrant } from "./grants.ts";
58
58
  import { changePasswordRateLimiter } from "./rate-limit.ts";
59
59
  import { isHttpsRequest } from "./request-protocol.ts";
60
60
  import { findActiveSession } from "./sessions.ts";
@@ -601,11 +601,18 @@ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps):
601
601
  );
602
602
  }
603
603
 
604
- // "Has this user connected an AI to any of their vaults yet?" — drives the
605
- // onboarding checklist's "Connect your AI" step (done/condensed when true).
606
- // A grant row only lands after the user clicks through an OAuth consent for a
607
- // client wired to one of their vaults.
608
- const connectedVault = user.assignedVaults.some((v) => userHasVaultGrant(deps.db, user.id, v));
604
+ // hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor, …)
605
+ // was wired to a vault NOT a first-party browser sign-in. This is the
606
+ // PRIMARY browser GET /account/ route the exact page the field report
607
+ // describes so it must use the same filtered check as the vault-token
608
+ // re-render (account-vault-token.ts:196): the old `userHasVaultGrant` lit
609
+ // "✓ You're connected" the moment the user opened Notes (a first-party OAuth
610
+ // client that writes a vault-scoped grant). `userHasExternalAiGrant` excludes
611
+ // first-party browser surfaces so the checklist only condenses on a real AI
612
+ // connection.
613
+ const connectedVault = user.assignedVaults.some((v) =>
614
+ userHasExternalAiGrant(deps.db, user.id, v),
615
+ );
609
616
 
610
617
  return htmlResponse(
611
618
  renderAccountHome({
@@ -53,6 +53,7 @@ import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
53
53
  import { type AliveFn, defaultAlive } from "../process-state.ts";
54
54
  import { readManifestLenient } from "../services-manifest.ts";
55
55
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
56
+ import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
56
57
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
57
58
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
58
59
  import {
@@ -1137,8 +1138,35 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
1137
1138
  // downstream consumers stop resolving the now-dead public URL (mirrors the
1138
1139
  // up-path write above + the Tailscale off-path's expose-state teardown). When
1139
1140
  // other tunnels survive we leave it — a later off for the last one clears it.
1141
+ //
1142
+ // TODO(multi-tunnel) #588: with TWO CF tunnels up, tearing down the
1143
+ // last-written-up one (whose hostname is what's in vault's `.env`) while the
1144
+ // other survives leaves `.env` carrying the dead tunnel's origin while the
1145
+ // surviving tunnel serves a different one → stale-iss on the next vault
1146
+ // restart. Retention is still the only SAFE choice here: a single
1147
+ // `PARACHUTE_HUB_ORIGIN` field can't represent "which surviving tunnel wins,"
1148
+ // and clearing it would break the survivor's iss check. Properly fixing it
1149
+ // needs re-resolving the effective origin from the survivor (or multi-origin
1150
+ // issuer acceptance vault-side) — larger than the #503 single-tunnel fix, and
1151
+ // multi-CF-tunnel-on-one-box is rare. See #588.
1140
1152
  if (!state) {
1141
1153
  clearExposeState(r.exposeStatePath);
1154
+ // Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env` (#503). With
1155
+ // the last Cloudflare tunnel gone, the hub is loopback-only and mints
1156
+ // loopback-`iss` tokens; a stale public origin left in `vault/.env` would
1157
+ // pin a public expected issuer and 401 every request on the next vault
1158
+ // daemon restart ("not signed in to the hub" — the inverse of the bug
1159
+ // selfHealVaultHubOrigin closed). This mirrors exactly what the Tailscale
1160
+ // off-path does (`exposeOff` in expose.ts) — the Cloudflare path had been
1161
+ // the asymmetric gap. expose-state's own `hubOrigin` is cleared above via
1162
+ // clearExposeState, so hub's per-request `resolveIssuer`/`exposeIssuerOrigin`
1163
+ // (which read expose-state) also stop minting the public iss after teardown.
1164
+ // No restart needed for the gap this closes — the next vault restart picks
1165
+ // up the cleared `.env` — but tell the operator so an already-running vault
1166
+ // doesn't keep validating against the now-dead public origin.
1167
+ if (clearVaultHubOrigin(r.configDir, r.log)) {
1168
+ r.log(" Restart vault to apply the loopback issuer now: `parachute restart vault`.");
1169
+ }
1142
1170
  }
1143
1171
  return failed ? 1 : 0;
1144
1172
  }
@@ -162,6 +162,17 @@ export interface InitOpts {
162
162
  * already known so there's no question to ask).
163
163
  */
164
164
  noWizardPrompt?: boolean;
165
+ /**
166
+ * Test seam: probe the running hub for its first-claim bootstrap token
167
+ * (hub#576). Production hits `GET http://127.0.0.1:<port>/admin/setup` with
168
+ * `accept: application/json` and reads `bootstrapToken` (the hub returns it
169
+ * only to loopback callers). Returns the token string when the hub is in
170
+ * wizard mode (no admin yet), or `undefined` when there's no token to surface
171
+ * (admin already exists, or the probe failed). Init uses it to print the
172
+ * token next to the admin URL when the hub is publicly exposed, so a browser
173
+ * operator can claim the box without digging through the hub logs.
174
+ */
175
+ fetchBootstrapTokenImpl?: (loopbackUrl: string) => Promise<string | undefined>;
165
176
  }
166
177
 
167
178
  /**
@@ -461,6 +472,41 @@ async function defaultRunCliWizard(opts: {
461
472
  return await runCliWizard(opts);
462
473
  }
463
474
 
475
+ /**
476
+ * Default impl for the bootstrap-token probe (hub#576). GETs the loopback hub's
477
+ * `/admin/setup` with `accept: application/json` and returns the `bootstrapToken`
478
+ * the hub hands to loopback callers. Returns `undefined` on any failure (hub
479
+ * not answering, no token because an admin already exists, malformed body) —
480
+ * surfacing the token is a convenience, never a hard dependency of init.
481
+ */
482
+ async function defaultFetchBootstrapToken(loopbackUrl: string): Promise<string | undefined> {
483
+ // Debug breadcrumb (gated on PARACHUTE_DEBUG so it never clutters the normal
484
+ // operator output). When the token doesn't print in the field, this tells a
485
+ // troubleshooter WHY — hub didn't answer, returned non-200, or the body
486
+ // carried no token (already-claimed / no-gate) — instead of a silent nothing.
487
+ const debug = (msg: string): void => {
488
+ if (process.env.PARACHUTE_DEBUG) console.error(`[init][bootstrap-token] ${msg}`);
489
+ };
490
+ try {
491
+ const res = await fetch(`${loopbackUrl.replace(/\/+$/, "")}/admin/setup`, {
492
+ headers: { accept: "application/json" },
493
+ });
494
+ if (!res.ok) {
495
+ debug(`probe returned ${res.status}; not printing a token`);
496
+ return undefined;
497
+ }
498
+ const body = (await res.json()) as { bootstrapToken?: unknown };
499
+ if (typeof body.bootstrapToken === "string" && body.bootstrapToken.length > 0) {
500
+ return body.bootstrapToken;
501
+ }
502
+ debug("probe ok but no bootstrapToken in body (already-claimed or no gate active)");
503
+ return undefined;
504
+ } catch (err) {
505
+ debug(`probe failed: ${err instanceof Error ? err.message : String(err)}`);
506
+ return undefined;
507
+ }
508
+ }
509
+
464
510
  /**
465
511
  * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
466
512
  * picked option, or `undefined` if the operator quit. Default is
@@ -547,6 +593,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
547
593
  const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
548
594
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
549
595
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
596
+ const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
550
597
 
551
598
  log("Parachute init — getting your hub set up.");
552
599
  log("");
@@ -711,6 +758,19 @@ export async function init(opts: InitOpts = {}): Promise<number> {
711
758
  return 1;
712
759
  }
713
760
 
761
+ // hub#576: when the hub is publicly exposed AND still in wizard mode (no
762
+ // admin yet), the admin URL above is a public FQDN — whoever opens it first
763
+ // claims the box. Surface the first-claim bootstrap token in the operator's
764
+ // OWN terminal so the wizard's account step demands proof of box access. We
765
+ // only probe + print on the public-FQDN path: a loopback-only install needs
766
+ // no token (reaching 127.0.0.1 already proves access), and the CLI-wizard
767
+ // path picks the token up transparently over loopback (above). The probe is
768
+ // best-effort — a failure (or an already-claimed hub) just prints nothing.
769
+ let bootstrapToken: string | undefined;
770
+ if (exposeState?.canonicalFqdn) {
771
+ bootstrapToken = await fetchBootstrapTokenImpl(`http://127.0.0.1:${hubPort}`);
772
+ }
773
+
714
774
  log("");
715
775
  if (hasVault) {
716
776
  log("Looks good — your hub is up and a vault is configured.");
@@ -720,6 +780,17 @@ export async function init(opts: InitOpts = {}): Promise<number> {
720
780
  log("");
721
781
  log(` ${adminUrl}`);
722
782
  log("");
783
+ if (bootstrapToken) {
784
+ log("Because this hub is reachable on the public internet, the wizard asks for a");
785
+ log("one-time bootstrap token before it lets anyone create the admin account —");
786
+ log("so whoever opens the URL first can't claim your hub. Paste this when asked:");
787
+ log("");
788
+ log(` ${bootstrapToken}`);
789
+ log("");
790
+ log("(Valid until the admin is created or the hub restarts. Re-run `parachute init`");
791
+ log(" to mint a fresh one.)");
792
+ log("");
793
+ }
723
794
  // hub#565: when we're on the loopback URL (no public exposure active),
724
795
  // remind the operator they can expose later. Skipped once an FQDN is up.
725
796
  if (!exposeState?.canonicalFqdn) {
@@ -756,7 +827,13 @@ export async function init(opts: InitOpts = {}): Promise<number> {
756
827
  if (choice === "cli") {
757
828
  log("");
758
829
  log("Launching the CLI wizard. (You can also visit the URL above in a browser any time.)");
759
- return await runCliWizardImpl({ hubUrl: adminUrl.replace(/\/admin\/?$/, ""), log });
830
+ // hub#576: drive the CLI wizard against the LOOPBACK hub, not the public
831
+ // FQDN in `adminUrl`. The wizard runs on this box, so loopback is both
832
+ // correct and what lets the hub hand it the bootstrap token transparently
833
+ // (the loopback-gated GET /admin/setup probe) — the operator never has to
834
+ // copy the token out of the startup logs.
835
+ const cliWizardUrl = `http://127.0.0.1:${hubPort}`;
836
+ return await runCliWizardImpl({ hubUrl: cliWizardUrl, log });
760
837
  }
761
838
 
762
839
  // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
@@ -255,6 +255,14 @@ interface WizardStateSnapshot {
255
255
  hasVault: boolean;
256
256
  hasExposeMode: boolean;
257
257
  requireBootstrapToken: boolean;
258
+ /**
259
+ * The actual bootstrap token, present ONLY when the wizard-state probe ran
260
+ * over loopback (the on-box operator's own shell — hub#576). The hub returns
261
+ * it so the CLI wizard can satisfy the first-claim gate transparently without
262
+ * the operator copy-pasting it from the startup logs. Absent on any
263
+ * public/tailnet probe.
264
+ */
265
+ bootstrapToken?: string;
258
266
  csrfToken: string;
259
267
  /** Optional URL to redirect to (when state is fully done — 301 to /login). */
260
268
  redirectTo?: string;
@@ -294,7 +302,7 @@ async function fetchWizardState(
294
302
  );
295
303
  }
296
304
  const body = res.json as Partial<WizardStateSnapshot> & { csrfToken?: string };
297
- return {
305
+ const snapshot: WizardStateSnapshot = {
298
306
  step: body.step ?? "welcome",
299
307
  hasAdmin: Boolean(body.hasAdmin),
300
308
  hasVault: Boolean(body.hasVault),
@@ -302,6 +310,12 @@ async function fetchWizardState(
302
310
  requireBootstrapToken: Boolean(body.requireBootstrapToken),
303
311
  csrfToken: typeof body.csrfToken === "string" ? body.csrfToken : (jar.csrf ?? ""),
304
312
  };
313
+ // hub#576: the loopback probe carries the actual token. Thread it through so
314
+ // the account step can submit it without prompting the operator.
315
+ if (typeof body.bootstrapToken === "string" && body.bootstrapToken.length > 0) {
316
+ snapshot.bootstrapToken = body.bootstrapToken;
317
+ }
318
+ return snapshot;
305
319
  }
306
320
 
307
321
  /**
@@ -422,7 +436,27 @@ async function walkAccountStep(
422
436
  log(` ✗ ${pwErr}`);
423
437
  return 1;
424
438
  }
425
- let bootstrap = opts.bootstrapToken ?? process.env.PARACHUTE_BOOTSTRAP_TOKEN;
439
+ // Token resolution order (hub#576):
440
+ // 1. Explicit `--bootstrap-token` flag / `opts.bootstrapToken` (init passes
441
+ // this when it fetched the token from the loopback probe).
442
+ // 2. `PARACHUTE_BOOTSTRAP_TOKEN` env.
443
+ // 3. The token carried on the loopback wizard-state probe itself — the
444
+ // common on-box `parachute init` path: the hub handed us the value
445
+ // because we reached it over loopback, so we satisfy the gate
446
+ // transparently with no operator action.
447
+ // 4. Prompt — the fallback when none of the above apply (e.g. a remote
448
+ // `parachute init --cli-wizard` against a public hub, where the probe
449
+ // didn't carry the token). The operator reads it from the startup logs.
450
+ // Treat an empty / whitespace value at any level as "absent" so a falsy
451
+ // `PARACHUTE_BOOTSTRAP_TOKEN=` (exported-but-empty) doesn't suppress the
452
+ // loopback-probe token and silently submit a blank token.
453
+ const firstNonEmpty = (...vals: Array<string | undefined>): string | undefined =>
454
+ vals.find((v) => typeof v === "string" && v.trim().length > 0);
455
+ let bootstrap = firstNonEmpty(
456
+ opts.bootstrapToken,
457
+ process.env.PARACHUTE_BOOTSTRAP_TOKEN,
458
+ state.bootstrapToken,
459
+ );
426
460
  if (state.requireBootstrapToken && !bootstrap) {
427
461
  log("");
428
462
  log(" This hub is in container/serve mode and minted a one-time");