@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
|
@@ -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({
|
package/src/account-home-ui.ts
CHANGED
|
@@ -327,8 +327,31 @@ function renderOnboardingChecklist(opts: OnboardingChecklistOpts): string {
|
|
|
327
327
|
const safeEndpoint = escapeHtml(endpoint);
|
|
328
328
|
const safeAddCmd = escapeHtml(addCmd);
|
|
329
329
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
package/src/api-account.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
// "
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
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
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/commands/wizard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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");
|