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

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.7",
3
+ "version": "0.6.4-rc.8",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -660,6 +660,47 @@ describe("renderAccountHome", () => {
660
660
  expect(html).toContain('data-testid="vault-card"');
661
661
  });
662
662
 
663
+ test("onboarding condensed state keeps a 'Connect another AI' expander with the full instructions (hub#583)", () => {
664
+ const html = renderAccountHome({
665
+ username: "alice",
666
+ assignedVaults: ["alice"],
667
+ passwordChanged: true,
668
+ hubOrigin: HUB_ORIGIN,
669
+ isFirstAdmin: false,
670
+ csrfToken: CSRF,
671
+ twoFactorEnabled: false,
672
+ connectedVault: true,
673
+ });
674
+ // The expander itself...
675
+ expect(html).toContain('data-testid="onboarding-connect-another"');
676
+ expect(html).toContain('data-testid="onboarding-connect-another-summary"');
677
+ expect(html).toContain("Connect another AI");
678
+ // ...re-reveals the endpoint + BOTH connect methods that the condensed
679
+ // line used to delete entirely (the hub#583 defect).
680
+ expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
681
+ expect(html).toContain('data-testid="onboarding-mcp-add-command"');
682
+ expect(html).toContain("Claude.ai (web)");
683
+ expect(html).toContain("Claude Code (terminal)");
684
+ });
685
+
686
+ test("onboarding NON-condensed (not connected) state has no 'Connect another AI' expander (hub#583)", () => {
687
+ const html = renderAccountHome({
688
+ username: "alice",
689
+ assignedVaults: ["alice"],
690
+ passwordChanged: true,
691
+ hubOrigin: HUB_ORIGIN,
692
+ isFirstAdmin: false,
693
+ csrfToken: CSRF,
694
+ twoFactorEnabled: false,
695
+ connectedVault: false,
696
+ });
697
+ // Full checklist already shows the inline instructions in step 2, so the
698
+ // expander is condensed-state-only.
699
+ expect(html).not.toContain('data-testid="onboarding-connect-another"');
700
+ expect(html).toContain('data-testid="onboarding-step-2"');
701
+ expect(html).toContain('data-testid="onboarding-mcp-endpoint"');
702
+ });
703
+
663
704
  test("onboarding checklist — leads the page: BEFORE the vault card and the starter prompts", () => {
664
705
  const html = renderAccountHome({
665
706
  username: "alice",
@@ -28,7 +28,9 @@ import {
28
28
  handleAccountHomeGet,
29
29
  markPasswordChanged,
30
30
  } from "../api-account.ts";
31
+ import { registerClient } from "../clients.ts";
31
32
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
33
+ import { recordGrant } from "../grants.ts";
32
34
  import { hubDbPath, openHubDb } from "../hub-db.ts";
33
35
  import { recordTokenMint } from "../jwt-sign.ts";
34
36
  import {
@@ -832,6 +834,65 @@ describe("handleAccountHomeGet", () => {
832
834
  expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
833
835
  });
834
836
 
837
+ test("onboarding NOT condensed when only a first-party browser grant exists (hub#583, GET /account/)", async () => {
838
+ // The exact field report: create account → open Notes (a first-party OAuth
839
+ // client that writes a vault-scoped grant) → later visit /account/ to wire
840
+ // up Claude. The checklist must NOT already be condensed.
841
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
842
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
843
+ allowMulti: true,
844
+ passwordChanged: true,
845
+ assignedVaults: ["alice"],
846
+ });
847
+ // Notes signs in via DCR (generated client_id, client_name "Notes") and
848
+ // records a vault-scoped grant.
849
+ const notes = registerClient(harness.db, {
850
+ redirectUris: ["https://hub.test/notes/cb"],
851
+ clientName: "Notes",
852
+ });
853
+ recordGrant(harness.db, friend.id, notes.client.clientId, ["vault:alice:read"]);
854
+
855
+ const session = createSession(harness.db, { userId: friend.id });
856
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
857
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
858
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
859
+ expect(res.status).toBe(200);
860
+ const html = await res.text();
861
+ // Full checklist (not condensed): the connect step is present, the
862
+ // "you're connected" done-line is NOT.
863
+ expect(html).toContain('data-connected="false"');
864
+ expect(html).toContain('data-testid="onboarding-step-2"');
865
+ expect(html).not.toContain('data-testid="onboarding-done-line"');
866
+ });
867
+
868
+ test("onboarding condensed when an external AI client grant exists (hub#583, GET /account/)", async () => {
869
+ await createUser(harness.db, "admin", "admin-passphrase", { passwordChanged: true });
870
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", {
871
+ allowMulti: true,
872
+ passwordChanged: true,
873
+ assignedVaults: ["alice"],
874
+ });
875
+ // Claude Code: a genuine external MCP client.
876
+ const claude = registerClient(harness.db, {
877
+ redirectUris: ["https://claude.ai/cb"],
878
+ clientName: "Claude",
879
+ });
880
+ recordGrant(harness.db, friend.id, claude.client.clientId, ["vault:alice:read"]);
881
+
882
+ const session = createSession(harness.db, { userId: friend.id });
883
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
884
+ const req = new Request(`${HUB_ORIGIN}/account/`, { headers: { cookie } });
885
+ const res = await handleAccountHomeGet(req, { db: harness.db, hubOrigin: HUB_ORIGIN });
886
+ expect(res.status).toBe(200);
887
+ const html = await res.text();
888
+ // Condensed done-state.
889
+ expect(html).toContain('data-connected="true"');
890
+ expect(html).toContain('data-testid="onboarding-done-line"');
891
+ expect(html).toContain("You're connected");
892
+ // And it still keeps the "Connect another AI" expander.
893
+ expect(html).toContain('data-testid="onboarding-connect-another"');
894
+ });
895
+
835
896
  test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
836
897
  // The first-created user with no vault pin is the admin posture.
837
898
  const admin = await createUser(harness.db, "admin", "admin-passphrase", {
@@ -1671,13 +1671,19 @@ describe("well-known regen after module ops", () => {
1671
1671
  const vaultRow = manifest.services.find((s) => s.name === "parachute-vault");
1672
1672
  expect(vaultRow?.installDir).toBe(install.installDir);
1673
1673
 
1674
- // The on-disk well-known doc reflects the new module.
1674
+ // The on-disk well-known doc reflects the new module in `services`...
1675
1675
  const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
1676
1676
  services: Array<{ name: string; version: string }>;
1677
1677
  vaults: Array<{ name: string }>;
1678
1678
  };
1679
1679
  expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
1680
- expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
1680
+ // ...but does NOT fabricate a phantom `default` vault row (hub#577). The
1681
+ // install seeds the entry at SEED_VERSION ("module installed, no instance
1682
+ // booted"); vault's own boot registers the real instance later. Until then
1683
+ // the vaults[] list is honestly empty so the management page reads "No
1684
+ // vaults yet" rather than showing a `default` that doesn't exist.
1685
+ expect(doc.vaults.some((v) => v.name === "default")).toBe(false);
1686
+ expect(doc.vaults).toEqual([]);
1681
1687
  });
1682
1688
 
1683
1689
  test("runInstall sets PORT in child env from services.json entry (hub#356)", async () => {
@@ -8,9 +8,11 @@ import {
8
8
  findGrantByClientName,
9
9
  isCoveredByGrant,
10
10
  isCoveredByGrantForClientName,
11
+ isFirstPartyBrowserClient,
11
12
  listGrantsForUser,
12
13
  recordGrant,
13
14
  revokeGrant,
15
+ userHasExternalAiGrant,
14
16
  userHasVaultGrant,
15
17
  } from "../grants.ts";
16
18
  import { hubDbPath, openHubDb } from "../hub-db.ts";
@@ -395,3 +397,100 @@ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () =
395
397
  }
396
398
  });
397
399
  });
400
+
401
+ describe("userHasExternalAiGrant / isFirstPartyBrowserClient (hub#583)", () => {
402
+ test("isFirstPartyBrowserClient matches fixed first-party client_ids", () => {
403
+ expect(isFirstPartyBrowserClient("parachute-hub-spa", null)).toBe(true);
404
+ expect(isFirstPartyBrowserClient("parachute-account", null)).toBe(true);
405
+ expect(isFirstPartyBrowserClient("some-random-dcr-id", null)).toBe(false);
406
+ });
407
+
408
+ test("isFirstPartyBrowserClient matches Notes by client_name (case-insensitive)", () => {
409
+ expect(isFirstPartyBrowserClient("dcr-generated-id", "Notes")).toBe(true);
410
+ expect(isFirstPartyBrowserClient("dcr-generated-id", "notes")).toBe(true);
411
+ expect(isFirstPartyBrowserClient("dcr-generated-id", "Claude")).toBe(false);
412
+ expect(isFirstPartyBrowserClient("dcr-generated-id", null)).toBe(false);
413
+ });
414
+
415
+ test("a first-party browser grant does NOT count as a connected AI", async () => {
416
+ const h = await harness();
417
+ try {
418
+ // Notes signs in via DCR (generated client_id, client_name "Notes") and
419
+ // writes a vault-scoped grant — the exact false-positive in hub#583.
420
+ const notes = registerClient(h.db, {
421
+ redirectUris: ["https://app.example/cb"],
422
+ clientName: "Notes",
423
+ });
424
+ recordGrant(h.db, h.userId, notes.client.clientId, ["vault:default:read"]);
425
+ // The coarse signal lights up...
426
+ expect(userHasVaultGrant(h.db, h.userId, "default")).toBe(true);
427
+ // ...but the AI-connection signal does NOT.
428
+ expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
429
+ } finally {
430
+ h.cleanup();
431
+ }
432
+ });
433
+
434
+ test("the fixed first-party SPA client_id does NOT count as a connected AI", async () => {
435
+ const h = await harness();
436
+ try {
437
+ registerClient(h.db, {
438
+ redirectUris: ["https://app.example/cb"],
439
+ clientId: "parachute-hub-spa",
440
+ });
441
+ recordGrant(h.db, h.userId, "parachute-hub-spa", ["vault:default:read"]);
442
+ expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
443
+ } finally {
444
+ h.cleanup();
445
+ }
446
+ });
447
+
448
+ test("an external AI/MCP client grant DOES count as connected", async () => {
449
+ const h = await harness();
450
+ try {
451
+ // Claude Code: DCR-registered, ordinary client_name, vault scope.
452
+ const claude = registerClient(h.db, {
453
+ redirectUris: ["https://claude.ai/cb"],
454
+ clientName: "Claude",
455
+ });
456
+ recordGrant(h.db, h.userId, claude.client.clientId, ["vault:default:read"]);
457
+ expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(true);
458
+ } finally {
459
+ h.cleanup();
460
+ }
461
+ });
462
+
463
+ test("external grant is scoped to the named vault", async () => {
464
+ const h = await harness();
465
+ try {
466
+ const claude = registerClient(h.db, {
467
+ redirectUris: ["https://claude.ai/cb"],
468
+ clientName: "Claude",
469
+ });
470
+ recordGrant(h.db, h.userId, claude.client.clientId, ["vault:other:read"]);
471
+ expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(false);
472
+ expect(userHasExternalAiGrant(h.db, h.userId, "other")).toBe(true);
473
+ } finally {
474
+ h.cleanup();
475
+ }
476
+ });
477
+
478
+ test("Notes + Claude both granted: still counts (the external one wins)", async () => {
479
+ const h = await harness();
480
+ try {
481
+ const notes = registerClient(h.db, {
482
+ redirectUris: ["https://app.example/cb"],
483
+ clientName: "Notes",
484
+ });
485
+ const claude = registerClient(h.db, {
486
+ redirectUris: ["https://claude.ai/cb"],
487
+ clientName: "Claude",
488
+ });
489
+ recordGrant(h.db, h.userId, notes.client.clientId, ["vault:default:read"]);
490
+ recordGrant(h.db, h.userId, claude.client.clientId, ["vault:default:read"]);
491
+ expect(userHasExternalAiGrant(h.db, h.userId, "default")).toBe(true);
492
+ } finally {
493
+ h.cleanup();
494
+ }
495
+ });
496
+ });
@@ -485,6 +485,142 @@ describe("init", () => {
485
485
  });
486
486
  });
487
487
 
488
+ describe("init bootstrap-token first-claim (hub#576)", () => {
489
+ function publicState(): ExposeState {
490
+ return {
491
+ version: 1,
492
+ layer: "public",
493
+ mode: "path",
494
+ canonicalFqdn: "demo.parachute.computer",
495
+ port: 443,
496
+ funnel: false,
497
+ entries: [],
498
+ hubOrigin: "https://demo.parachute.computer",
499
+ };
500
+ }
501
+
502
+ test("publicly-exposed + wizard mode: prints the bootstrap token in the terminal", async () => {
503
+ const h = makeHarness();
504
+ try {
505
+ writeHubPort(1939, h.configDir);
506
+ const probed: string[] = [];
507
+ const logs: string[] = [];
508
+ const code = await init({
509
+ configDir: h.configDir,
510
+ manifestPath: h.manifestPath,
511
+ log: (l) => logs.push(l),
512
+ alive: () => true,
513
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
514
+ readExposeStateFn: () => publicState(),
515
+ isTty: false,
516
+ platform: "linux",
517
+ installVaultModuleImpl: noopVaultInstall,
518
+ fetchBootstrapTokenImpl: async (loopbackUrl) => {
519
+ probed.push(loopbackUrl);
520
+ return "parachute-bootstrap-XYZ";
521
+ },
522
+ });
523
+ expect(code).toBe(0);
524
+ // Probed the LOOPBACK hub, not the public FQDN.
525
+ expect(probed).toEqual(["http://127.0.0.1:1939"]);
526
+ const joined = logs.join("\n");
527
+ expect(joined).toContain("parachute-bootstrap-XYZ");
528
+ expect(joined).toContain("bootstrap token");
529
+ // Still prints the public admin URL.
530
+ expect(joined).toContain("https://demo.parachute.computer/admin/");
531
+ } finally {
532
+ h.cleanup();
533
+ }
534
+ });
535
+
536
+ test("loopback-only install: does NOT probe or print a token", async () => {
537
+ const h = makeHarness();
538
+ try {
539
+ writeHubPort(1939, h.configDir);
540
+ let probedCount = 0;
541
+ const logs: string[] = [];
542
+ const code = await init({
543
+ configDir: h.configDir,
544
+ manifestPath: h.manifestPath,
545
+ log: (l) => logs.push(l),
546
+ alive: () => true,
547
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
548
+ readExposeStateFn: () => undefined, // no public exposure
549
+ isTty: false,
550
+ platform: "linux",
551
+ installVaultModuleImpl: noopVaultInstall,
552
+ fetchBootstrapTokenImpl: async () => {
553
+ probedCount++;
554
+ return "parachute-bootstrap-XYZ";
555
+ },
556
+ });
557
+ expect(code).toBe(0);
558
+ expect(probedCount).toBe(0);
559
+ expect(logs.join("\n")).not.toContain("parachute-bootstrap-");
560
+ } finally {
561
+ h.cleanup();
562
+ }
563
+ });
564
+
565
+ test("admin already exists (no token): prints the URL without a token block", async () => {
566
+ const h = makeHarness();
567
+ try {
568
+ writeHubPort(1939, h.configDir);
569
+ const logs: string[] = [];
570
+ const code = await init({
571
+ configDir: h.configDir,
572
+ manifestPath: h.manifestPath,
573
+ log: (l) => logs.push(l),
574
+ alive: () => true,
575
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
576
+ readExposeStateFn: () => publicState(),
577
+ isTty: false,
578
+ platform: "linux",
579
+ installVaultModuleImpl: noopVaultInstall,
580
+ // Hub returns undefined → already-claimed / no token to surface.
581
+ fetchBootstrapTokenImpl: async () => undefined,
582
+ });
583
+ expect(code).toBe(0);
584
+ const joined = logs.join("\n");
585
+ expect(joined).toContain("https://demo.parachute.computer/admin/");
586
+ expect(joined).not.toContain("bootstrap token");
587
+ } finally {
588
+ h.cleanup();
589
+ }
590
+ });
591
+
592
+ test("CLI wizard is driven against the LOOPBACK hub, not the public FQDN", async () => {
593
+ const h = makeHarness();
594
+ try {
595
+ writeHubPort(1939, h.configDir);
596
+ const wizardUrls: string[] = [];
597
+ const code = await init({
598
+ configDir: h.configDir,
599
+ manifestPath: h.manifestPath,
600
+ log: () => {},
601
+ alive: () => true,
602
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
603
+ readExposeStateFn: () => publicState(),
604
+ isTty: false,
605
+ platform: "linux",
606
+ installVaultModuleImpl: noopVaultInstall,
607
+ wizardChoice: "cli",
608
+ fetchBootstrapTokenImpl: async () => "parachute-bootstrap-XYZ",
609
+ runCliWizardImpl: async ({ hubUrl }) => {
610
+ wizardUrls.push(hubUrl);
611
+ return 0;
612
+ },
613
+ });
614
+ expect(code).toBe(0);
615
+ // The CLI wizard runs on-box → must use loopback (where the hub hands it
616
+ // the token transparently), never the public FQDN.
617
+ expect(wizardUrls).toEqual(["http://127.0.0.1:1939"]);
618
+ } finally {
619
+ h.cleanup();
620
+ }
621
+ });
622
+ });
623
+
488
624
  describe("looksLikeServer heuristic", () => {
489
625
  test("macOS is never a server", () => {
490
626
  expect(looksLikeServer("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
@@ -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({
@@ -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");
package/src/grants.ts CHANGED
@@ -190,6 +190,94 @@ export function isCoveredByGrantForClientName(
190
190
 
191
191
  const VAULT_SCOPE_PREFIX_RE = /^vault:([^:]+):/;
192
192
 
193
+ /**
194
+ * Fixed `client_id`s the hub mints for its OWN first-party browser surfaces.
195
+ * A grant carrying one of these is a browser sign-in (the operator opened the
196
+ * admin SPA, the account-home friend surface, etc.) — NOT "the operator
197
+ * connected an AI to their vault." See `userHasExternalAiGrant` (hub#583).
198
+ *
199
+ * - `parachute-hub-spa` — hub admin SPA + vault admin SPA mints
200
+ * (`admin-host-admin-token.ts`, `admin-vault-admin-token.ts`,
201
+ * `account-vault-admin-token.ts`).
202
+ * - `parachute-account` — account-home friend-vault token mints
203
+ * (`account-vault-token.ts`).
204
+ */
205
+ const FIRST_PARTY_BROWSER_CLIENT_IDS = new Set(["parachute-hub-spa", "parachute-account"]);
206
+
207
+ /**
208
+ * `client_name`s of first-party browser SPAs that register dynamically via DCR
209
+ * (so their `client_id` is generated per-registration and can't be enumerated).
210
+ * Notes registers with `client_name: "Notes"` (the @openparachute/notes-ui PWA
211
+ * signing into a vault). Matched case-insensitively. See hub#583.
212
+ */
213
+ const FIRST_PARTY_BROWSER_CLIENT_NAMES = new Set(["notes"]);
214
+
215
+ /**
216
+ * True when a grant belongs to one of the hub's own first-party browser
217
+ * surfaces (admin SPA, account home, Notes PWA) rather than an external AI/MCP
218
+ * client (Claude, Cursor, …). Used to keep a browser sign-in from
219
+ * false-positiving the "you've connected your AI" onboarding signal (hub#583).
220
+ *
221
+ * Discriminates two ways because first-party surfaces register two ways: the
222
+ * hub-minted SPAs use fixed `client_id`s; DCR-registered SPAs (Notes) get a
223
+ * generated id but a stable `client_name`.
224
+ */
225
+ export function isFirstPartyBrowserClient(
226
+ clientId: string,
227
+ clientName: string | null | undefined,
228
+ ): boolean {
229
+ if (FIRST_PARTY_BROWSER_CLIENT_IDS.has(clientId)) return true;
230
+ if (clientName && FIRST_PARTY_BROWSER_CLIENT_NAMES.has(clientName.trim().toLowerCase())) {
231
+ return true;
232
+ }
233
+ return false;
234
+ }
235
+
236
+ interface GrantWithClientNameRow extends GrantRow {
237
+ client_name: string | null;
238
+ }
239
+
240
+ /**
241
+ * True when the user has approved at least one EXTERNAL AI/MCP client (Claude,
242
+ * Cursor, etc.) whose granted scopes touch `vaultName` — i.e. "has this person
243
+ * actually wired up an AI to this vault yet?" (hub#583).
244
+ *
245
+ * Stricter than {@link userHasVaultGrant}: it excludes grants from the hub's
246
+ * own first-party browser surfaces (admin SPA, account home, Notes PWA). Those
247
+ * are OAuth clients too — signing into Notes writes a vault-scoped grant — so
248
+ * the coarse "any vault grant" signal lit up the `/account/` onboarding
249
+ * checklist's "✓ You're connected" line even when no AI was ever connected.
250
+ * This is the detection the checklist should use.
251
+ *
252
+ * Trade-off: this fetches ALL of the user's grants (joined to `clients` for
253
+ * `client_name`) and filters in JS, rather than pushing the first-party
254
+ * exclusion into a WHERE clause. Fine at current scale — a user has a handful
255
+ * of grants, and the scope/client-name discrimination is awkward to express in
256
+ * SQL (scopes are a space-joined column, first-party names are an in-process
257
+ * set). If the grants table grows large per-user, add an index on
258
+ * `grants(user_id)` (already the PK prefix) and consider a `client_name`-aware
259
+ * WHERE filter.
260
+ */
261
+ export function userHasExternalAiGrant(db: Database, userId: string, vaultName: string): boolean {
262
+ const rows = db
263
+ .prepare(
264
+ `SELECT g.user_id, g.client_id, g.scopes, g.granted_at, c.client_name
265
+ FROM grants g
266
+ LEFT JOIN clients c ON g.client_id = c.client_id
267
+ WHERE g.user_id = ?`,
268
+ )
269
+ .all(userId) as GrantWithClientNameRow[];
270
+ for (const row of rows) {
271
+ if (isFirstPartyBrowserClient(row.client_id, row.client_name)) continue;
272
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
273
+ for (const s of scopes) {
274
+ const m = s.match(VAULT_SCOPE_PREFIX_RE);
275
+ if (m && m[1] === vaultName) return true;
276
+ }
277
+ }
278
+ return false;
279
+ }
280
+
193
281
  /**
194
282
  * True when the user has approved at least one OAuth client whose granted
195
283
  * scopes touch `vaultName` (any `vault:<name>:<verb>` scope). This is the
package/src/help.ts CHANGED
@@ -160,7 +160,7 @@ Flags:
160
160
  --no-expose-prompt skip the exposure question; fall through to localhost URL
161
161
  --expose <choice> non-interactive exposure override:
162
162
  none — stay loopback-only
163
- tailnet — set up Tailscale Funnel (private to your tailnet)
163
+ tailnet — set up Tailscale serve (private to your tailnet)
164
164
  cloudflare — set up Cloudflare Tunnel (your own domain)
165
165
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
166
166
  in this terminal (hub#168 Cut 4)
package/src/hub-server.ts CHANGED
@@ -1553,6 +1553,11 @@ export function hubFetch(
1553
1553
  configDir: CONFIG_DIR,
1554
1554
  issuer: oauthDeps(req).issuer,
1555
1555
  registry: getDefaultOperationsRegistry(),
1556
+ // hub#576: a loopback peer (the on-box operator's own shell) is allowed
1557
+ // to read the actual bootstrap token from the GET /admin/setup JSON
1558
+ // probe. `layerOf` fails closed to non-loopback when peerAddr is
1559
+ // unknown, so a header-less caller never gets the token.
1560
+ requestIsLoopback: layerOf(req, peerAddr) === "loopback",
1556
1561
  };
1557
1562
  if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
1558
1563
  if (pathname === "/admin/setup") {
@@ -182,7 +182,15 @@ const NOTES_SERVE_PATH = fileURLToPath(new URL("./notes-serve.ts", import.meta.u
182
182
  * telegraphs the state: the row is a stopgap, and the service's first boot
183
183
  * will overwrite with its own authoritative write.
184
184
  */
185
- const SEED_VERSION = "0.0.0-linked";
185
+ /**
186
+ * Version string stamped on a CLI-seeded services.json entry — the
187
+ * "module installed, but its own daemon hasn't booted and registered a real
188
+ * row yet" placeholder. A real service boot overwrites this with the package's
189
+ * actual version (see "Services own their write side of services.json" in
190
+ * CLAUDE.md). Exported so consumers (e.g. well-known generation, hub#577) can
191
+ * tell a placeholder entry apart from a live one.
192
+ */
193
+ export const SEED_VERSION = "0.0.0-linked";
186
194
 
187
195
  function pathBasedUrl(entry: ServiceEntry): string {
188
196
  const first = entry.paths[0] ?? "";
@@ -399,6 +399,18 @@ export interface SetupWizardDeps {
399
399
  userId: string,
400
400
  opts: MintOperatorTokenOpts & { dir?: string },
401
401
  ) => Promise<IssueOperatorTokenResult>;
402
+ /**
403
+ * Whether the in-flight request arrived over loopback (peer `127.0.0.1` /
404
+ * `::1`). Set by `hub-server.ts` from `layerOf(req, peerAddr)`. hub#576: a
405
+ * loopback caller already proves on-box access (it's the operator's own
406
+ * shell — `parachute init` driving the CLI wizard), so the GET `/admin/setup`
407
+ * JSON probe reveals the actual bootstrap token VALUE to it, not just the
408
+ * `requireBootstrapToken` boolean. Public / tailnet callers (any browser
409
+ * that found the FQDN) get only the boolean and must paste the token the
410
+ * operator copied from their terminal. Absent (undefined) is treated as
411
+ * NON-loopback — fail closed, never leak the token to a header-less caller.
412
+ */
413
+ requestIsLoopback?: boolean;
402
414
  }
403
415
 
404
416
  /**
@@ -1601,8 +1613,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1601
1613
  // the HTML rendering branches means the CLI gets the answer it needs
1602
1614
  // without the wizard having to render a 30KB HTML page per poll.
1603
1615
  if (wantsJson) {
1604
- const requireToken = getBootstrapToken() !== undefined;
1605
- const envelope = {
1616
+ const activeToken = getBootstrapToken();
1617
+ const requireToken = activeToken !== undefined;
1618
+ const envelope: {
1619
+ step: typeof state.step;
1620
+ hasAdmin: boolean;
1621
+ hasVault: boolean;
1622
+ hasExposeMode: boolean;
1623
+ requireBootstrapToken: boolean;
1624
+ csrfToken: string;
1625
+ bootstrapToken?: string;
1626
+ } = {
1606
1627
  step: state.step,
1607
1628
  hasAdmin: state.hasAdmin,
1608
1629
  hasVault: state.hasVault,
@@ -1610,6 +1631,17 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1610
1631
  requireBootstrapToken: requireToken,
1611
1632
  csrfToken: csrf.token,
1612
1633
  };
1634
+ // hub#576: hand the actual token to a LOOPBACK caller only. The on-box
1635
+ // operator (`parachute init` → CLI wizard, or a curl from their own shell)
1636
+ // already proves box access by reaching loopback — same trust level as
1637
+ // reading the token off the startup banner in the hub log. This lets init
1638
+ // surface the token in the operator's terminal and feed it to the CLI
1639
+ // wizard transparently, instead of making them dig through `parachute logs
1640
+ // hub`. A public / tailnet browser never gets the value — it stays gated on
1641
+ // the operator pasting what they copied from their terminal.
1642
+ if (requireToken && deps.requestIsLoopback === true && activeToken !== undefined) {
1643
+ envelope.bootstrapToken = activeToken;
1644
+ }
1613
1645
  const jsonHeaders: Record<string, string> = {
1614
1646
  "content-type": "application/json; charset=utf-8",
1615
1647
  "cache-control": "no-store",
package/src/well-known.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.ts";
4
4
  import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
5
+ import { SEED_VERSION } from "./service-spec.ts";
5
6
  import {
6
7
  type ServiceEntry,
7
8
  type UiSubUnit,
@@ -315,6 +316,18 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
315
316
  }
316
317
  doc.services.push(entry);
317
318
  if (isVault) {
319
+ // hub#577: don't fabricate a phantom vault row from a SEED placeholder.
320
+ // `parachute init` installs the vault MODULE without creating an
321
+ // instance (hub#168 Cut 1: `noCreate`), seeding a services.json entry
322
+ // at SEED_VERSION with the canonical `/vault/default` mount. Vault's
323
+ // own boot overwrites that entry with the real instance path(s) once a
324
+ // vault is actually created. Until then, emitting a `vaults[]` row here
325
+ // makes the management page show a `default` vault that doesn't exist —
326
+ // it vanishes the moment a real vault registers. Keep the `services`
327
+ // entry (so the SPA knows the module IS installed and offers "New
328
+ // vault" rather than "Install module"), but suppress the vault row so
329
+ // the list honestly reads "No vaults yet."
330
+ if (s.version === SEED_VERSION) continue;
318
331
  const managementUrl = opts.managementUrlFor?.(s);
319
332
  const entry: WellKnownVaultEntry = {
320
333
  name: vaultInstanceNameFor(s.name, path),