@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.
- package/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +41 -0
- package/src/__tests__/api-account.test.ts +61 -0
- package/src/__tests__/api-modules-ops.test.ts +8 -2
- package/src/__tests__/expose-cloudflare.test.ts +103 -0
- package/src/__tests__/grants.test.ts +99 -0
- package/src/__tests__/hub-server.test.ts +133 -0
- package/src/__tests__/init.test.ts +136 -0
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +46 -15
- package/src/account-vault-token.ts +9 -2
- package/src/api-account.ts +13 -6
- package/src/commands/expose-cloudflare.ts +28 -0
- package/src/commands/init.ts +78 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +88 -0
- package/src/help.ts +2 -2
- package/src/hub-server.ts +39 -0
- package/src/hub-settings.ts +3 -3
- package/src/service-spec.ts +9 -1
- package/src/setup-wizard.ts +34 -2
- package/src/well-known.ts +13 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
|
@@ -1464,6 +1464,109 @@ describe("exposeCloudflareOff", () => {
|
|
|
1464
1464
|
}
|
|
1465
1465
|
});
|
|
1466
1466
|
|
|
1467
|
+
test("clears stale PARACHUTE_HUB_ORIGIN from vault/.env on last-tunnel down (#503)", async () => {
|
|
1468
|
+
const env = makeEnv();
|
|
1469
|
+
try {
|
|
1470
|
+
// Seed the stale public origin the up-path persisted into vault/.env.
|
|
1471
|
+
// After teardown the hub is loopback-only, so leaving this would pin a
|
|
1472
|
+
// public expected issuer and 401 every request on the next vault restart.
|
|
1473
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1474
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1475
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1476
|
+
|
|
1477
|
+
writeCloudflaredState(
|
|
1478
|
+
{
|
|
1479
|
+
version: 2,
|
|
1480
|
+
tunnels: {
|
|
1481
|
+
parachute: {
|
|
1482
|
+
pid: 55557,
|
|
1483
|
+
tunnelUuid: "ffffffff-0000-0000-0000-000000000006",
|
|
1484
|
+
tunnelName: "parachute",
|
|
1485
|
+
hostname: "vault.example.com",
|
|
1486
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1487
|
+
configPath: env.configPath,
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
env.statePath,
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const logs: string[] = [];
|
|
1495
|
+
const code = await exposeCloudflareOff({
|
|
1496
|
+
configDir: env.configDir,
|
|
1497
|
+
statePath: env.statePath,
|
|
1498
|
+
exposeStatePath: env.exposeStatePath,
|
|
1499
|
+
alive: () => false,
|
|
1500
|
+
kill: () => {},
|
|
1501
|
+
log: (l) => logs.push(l),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
expect(code).toBe(0);
|
|
1505
|
+
// The stale public origin is gone — vault reverts to its loopback default.
|
|
1506
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
1507
|
+
// Operator is told what to restart so a running vault picks up the change.
|
|
1508
|
+
expect(logs.join("\n")).toContain("cleared PARACHUTE_HUB_ORIGIN");
|
|
1509
|
+
expect(logs.join("\n")).toContain("parachute restart vault");
|
|
1510
|
+
} finally {
|
|
1511
|
+
env.cleanup();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("leaves vault/.env untouched while other tunnels survive (#503)", async () => {
|
|
1516
|
+
const env = makeEnv();
|
|
1517
|
+
try {
|
|
1518
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1519
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1520
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1521
|
+
|
|
1522
|
+
// Two tunnels; tear down only one by name → the box stays exposed, so the
|
|
1523
|
+
// persisted public origin must remain (clearing it would break the live
|
|
1524
|
+
// tunnel's iss check). Symmetric with the expose-state.json retention.
|
|
1525
|
+
writeCloudflaredState(
|
|
1526
|
+
{
|
|
1527
|
+
version: 2,
|
|
1528
|
+
tunnels: {
|
|
1529
|
+
alpha: {
|
|
1530
|
+
pid: 55558,
|
|
1531
|
+
tunnelUuid: "11111111-0000-0000-0000-000000000007",
|
|
1532
|
+
tunnelName: "alpha",
|
|
1533
|
+
hostname: "alpha.example.com",
|
|
1534
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1535
|
+
configPath: env.configPath,
|
|
1536
|
+
},
|
|
1537
|
+
beta: {
|
|
1538
|
+
pid: 55559,
|
|
1539
|
+
tunnelUuid: "22222222-0000-0000-0000-000000000008",
|
|
1540
|
+
tunnelName: "beta",
|
|
1541
|
+
hostname: "beta.example.com",
|
|
1542
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1543
|
+
configPath: env.configPath,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
env.statePath,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
const code = await exposeCloudflareOff({
|
|
1551
|
+
configDir: env.configDir,
|
|
1552
|
+
tunnelName: "alpha",
|
|
1553
|
+
statePath: env.statePath,
|
|
1554
|
+
exposeStatePath: env.exposeStatePath,
|
|
1555
|
+
alive: () => false,
|
|
1556
|
+
kill: () => {},
|
|
1557
|
+
log: () => {},
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
expect(code).toBe(0);
|
|
1561
|
+
// Beta tunnel survives → public origin stays.
|
|
1562
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBe(
|
|
1563
|
+
"https://vault.example.com",
|
|
1564
|
+
);
|
|
1565
|
+
} finally {
|
|
1566
|
+
env.cleanup();
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1467
1570
|
test("clears stale state when the process is already gone", async () => {
|
|
1468
1571
|
const env = makeEnv();
|
|
1469
1572
|
try {
|
|
@@ -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
|
+
});
|
|
@@ -1630,6 +1630,139 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1630
1630
|
}
|
|
1631
1631
|
});
|
|
1632
1632
|
|
|
1633
|
+
// #525: bare `/vault/<name>` POST (no `/mcp` suffix) half-connects MCP
|
|
1634
|
+
// clients. The fix 308-redirects the bare-path POST to `<mount>/mcp` BEFORE
|
|
1635
|
+
// proxying, so a compliant client re-POSTs to the right endpoint and clients
|
|
1636
|
+
// that don't follow redirects at least get an actionable Location + JSON body
|
|
1637
|
+
// (vs the old opaque vault 405).
|
|
1638
|
+
test("POST to bare /vault/<name> 308-redirects to /vault/<name>/mcp with an actionable body (#525)", async () => {
|
|
1639
|
+
const h = makeHarness();
|
|
1640
|
+
try {
|
|
1641
|
+
writeManifest({ services: [vaultEntry("aaron")] }, h.manifestPath);
|
|
1642
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1643
|
+
const res = await fetcher(
|
|
1644
|
+
req("/vault/aaron", {
|
|
1645
|
+
method: "POST",
|
|
1646
|
+
headers: { "content-type": "application/json" },
|
|
1647
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
expect(res.status).toBe(308);
|
|
1651
|
+
expect(res.headers.get("location")).toBe("/vault/aaron/mcp");
|
|
1652
|
+
// 308 is permanently cacheable by default — no-store prevents a cached
|
|
1653
|
+
// redirect outliving a remount.
|
|
1654
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1655
|
+
const body = (await res.json()) as { error: string; mcp_url: string; message: string };
|
|
1656
|
+
expect(body.error).toBe("missing_mcp_suffix");
|
|
1657
|
+
expect(body.mcp_url).toBe("/vault/aaron/mcp");
|
|
1658
|
+
expect(body.message).toContain("/vault/aaron/mcp");
|
|
1659
|
+
} finally {
|
|
1660
|
+
h.cleanup();
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test("bare-path 308 honors a trailing-slash mount: POST /vault/default → /vault/default/mcp (#525)", async () => {
|
|
1665
|
+
// Mounts in services.json sometimes carry a trailing slash (#197). The
|
|
1666
|
+
// redirect target must be built from the *normalized* mount so it stays
|
|
1667
|
+
// `/vault/default/mcp`, never `/vault/default//mcp`.
|
|
1668
|
+
const h = makeHarness();
|
|
1669
|
+
try {
|
|
1670
|
+
writeManifest(
|
|
1671
|
+
{
|
|
1672
|
+
services: [
|
|
1673
|
+
{
|
|
1674
|
+
name: "parachute-vault-default",
|
|
1675
|
+
port: 1940,
|
|
1676
|
+
paths: ["/vault/default/"],
|
|
1677
|
+
health: "/health",
|
|
1678
|
+
version: "0.4.0",
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
h.manifestPath,
|
|
1683
|
+
);
|
|
1684
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1685
|
+
const res = await fetcher(req("/vault/default", { method: "POST" }));
|
|
1686
|
+
expect(res.status).toBe(308);
|
|
1687
|
+
expect(res.headers.get("location")).toBe("/vault/default/mcp");
|
|
1688
|
+
} finally {
|
|
1689
|
+
h.cleanup();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("GET to bare /vault/<name> is NOT redirected — proxies through untouched (#525)", async () => {
|
|
1694
|
+
// Only POST (the MCP transport verb) is caught. A browser GET to the bare
|
|
1695
|
+
// path keeps its existing proxy behavior so we don't break any bare-path
|
|
1696
|
+
// GET surface.
|
|
1697
|
+
const h = makeHarness();
|
|
1698
|
+
const upstream = startUpstream("bare-get");
|
|
1699
|
+
try {
|
|
1700
|
+
writeManifest(
|
|
1701
|
+
{
|
|
1702
|
+
services: [
|
|
1703
|
+
{
|
|
1704
|
+
name: "parachute-vault-aaron",
|
|
1705
|
+
port: upstream.port,
|
|
1706
|
+
paths: ["/vault/aaron"],
|
|
1707
|
+
health: "/health",
|
|
1708
|
+
version: "0.4.0",
|
|
1709
|
+
},
|
|
1710
|
+
],
|
|
1711
|
+
},
|
|
1712
|
+
h.manifestPath,
|
|
1713
|
+
);
|
|
1714
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1715
|
+
const res = await fetcher(req("/vault/aaron", { method: "GET" }));
|
|
1716
|
+
expect(res.status).toBe(200);
|
|
1717
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1718
|
+
expect(body.tag).toBe("bare-get");
|
|
1719
|
+
expect(body.method).toBe("GET");
|
|
1720
|
+
expect(body.pathname).toBe("/vault/aaron");
|
|
1721
|
+
} finally {
|
|
1722
|
+
upstream.stop();
|
|
1723
|
+
h.cleanup();
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("POST to the canonical /vault/<name>/mcp sub-path is NOT redirected — proxies through (#525)", async () => {
|
|
1728
|
+
// The real MCP endpoint must keep working: a POST that already carries the
|
|
1729
|
+
// `/mcp` suffix is a sub-path (not the exact bare mount) and proxies
|
|
1730
|
+
// straight to the vault backend.
|
|
1731
|
+
const h = makeHarness();
|
|
1732
|
+
const upstream = startUpstream("mcp-post");
|
|
1733
|
+
try {
|
|
1734
|
+
writeManifest(
|
|
1735
|
+
{
|
|
1736
|
+
services: [
|
|
1737
|
+
{
|
|
1738
|
+
name: "parachute-vault-aaron",
|
|
1739
|
+
port: upstream.port,
|
|
1740
|
+
paths: ["/vault/aaron"],
|
|
1741
|
+
health: "/health",
|
|
1742
|
+
version: "0.4.0",
|
|
1743
|
+
},
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
h.manifestPath,
|
|
1747
|
+
);
|
|
1748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1749
|
+
const res = await fetcher(
|
|
1750
|
+
req("/vault/aaron/mcp", {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
|
|
1753
|
+
}),
|
|
1754
|
+
);
|
|
1755
|
+
expect(res.status).toBe(200);
|
|
1756
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1757
|
+
expect(body.tag).toBe("mcp-post");
|
|
1758
|
+
expect(body.method).toBe("POST");
|
|
1759
|
+
expect(body.pathname).toBe("/vault/aaron/mcp");
|
|
1760
|
+
} finally {
|
|
1761
|
+
upstream.stop();
|
|
1762
|
+
h.cleanup();
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1633
1766
|
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1634
1767
|
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1635
1768
|
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
@@ -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);
|