@openparachute/hub 0.5.10-rc.6 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -25,16 +25,19 @@ import {
|
|
|
25
25
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
26
26
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
27
27
|
import { hubFetch } from "../hub-server.ts";
|
|
28
|
+
import { getSetting, setSetting } from "../hub-settings.ts";
|
|
28
29
|
import { writeManifest } from "../services-manifest.ts";
|
|
29
30
|
import { SESSION_COOKIE_NAME } from "../sessions.ts";
|
|
30
31
|
import {
|
|
31
32
|
deriveWizardState,
|
|
32
33
|
handleSetupAccountPost,
|
|
34
|
+
handleSetupExposePost,
|
|
33
35
|
handleSetupGet,
|
|
36
|
+
handleSetupInstallPost,
|
|
34
37
|
handleSetupVaultPost,
|
|
35
38
|
} from "../setup-wizard.ts";
|
|
36
39
|
import { Supervisor } from "../supervisor.ts";
|
|
37
|
-
import { createUser, userCount } from "../users.ts";
|
|
40
|
+
import { createUser, getUserByUsername, userCount } from "../users.ts";
|
|
38
41
|
|
|
39
42
|
interface Harness {
|
|
40
43
|
dir: string;
|
|
@@ -141,7 +144,7 @@ describe("deriveWizardState", () => {
|
|
|
141
144
|
}
|
|
142
145
|
});
|
|
143
146
|
|
|
144
|
-
test("
|
|
147
|
+
test("expose step when admin + vault exist but expose mode not set yet (hub#268 Item 2)", async () => {
|
|
145
148
|
const db = openHubDb(hubDbPath(h.dir));
|
|
146
149
|
try {
|
|
147
150
|
await createUser(db, "owner", "pw");
|
|
@@ -160,9 +163,39 @@ describe("deriveWizardState", () => {
|
|
|
160
163
|
h.manifestPath,
|
|
161
164
|
);
|
|
162
165
|
const s = deriveWizardState({ db, manifestPath: h.manifestPath });
|
|
166
|
+
expect(s.step).toBe("expose");
|
|
167
|
+
expect(s.hasAdmin).toBe(true);
|
|
168
|
+
expect(s.hasVault).toBe(true);
|
|
169
|
+
expect(s.hasExposeMode).toBe(false);
|
|
170
|
+
} finally {
|
|
171
|
+
db.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("done step once admin + vault + expose mode all exist", async () => {
|
|
176
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
177
|
+
try {
|
|
178
|
+
await createUser(db, "owner", "pw");
|
|
179
|
+
writeManifest(
|
|
180
|
+
{
|
|
181
|
+
services: [
|
|
182
|
+
{
|
|
183
|
+
name: "parachute-vault",
|
|
184
|
+
version: "0.1.0",
|
|
185
|
+
port: 1940,
|
|
186
|
+
paths: ["/vault/default"],
|
|
187
|
+
health: "/health",
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
h.manifestPath,
|
|
192
|
+
);
|
|
193
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
194
|
+
const s = deriveWizardState({ db, manifestPath: h.manifestPath });
|
|
163
195
|
expect(s.step).toBe("done");
|
|
164
196
|
expect(s.hasAdmin).toBe(true);
|
|
165
197
|
expect(s.hasVault).toBe(true);
|
|
198
|
+
expect(s.hasExposeMode).toBe(true);
|
|
166
199
|
} finally {
|
|
167
200
|
db.close();
|
|
168
201
|
}
|
|
@@ -199,7 +232,7 @@ describe("handleSetupGet", () => {
|
|
|
199
232
|
}
|
|
200
233
|
});
|
|
201
234
|
|
|
202
|
-
test("renders the vault form once admin exists (
|
|
235
|
+
test("renders the vault form with a vault-name input once admin exists (hub#267)", async () => {
|
|
203
236
|
const db = openHubDb(hubDbPath(h.dir));
|
|
204
237
|
try {
|
|
205
238
|
await createUser(db, "owner", "pw");
|
|
@@ -213,18 +246,25 @@ describe("handleSetupGet", () => {
|
|
|
213
246
|
expect(res.status).toBe(200);
|
|
214
247
|
const html = await res.text();
|
|
215
248
|
expect(html).toContain('action="/admin/setup/vault"');
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
249
|
+
// hub#267: the vault-name text input is back. Default placeholder
|
|
250
|
+
// is "default" + the preview card mirrors the placeholder; the
|
|
251
|
+
// operator can leave the field blank and still get a working
|
|
252
|
+
// vault.
|
|
253
|
+
expect(html).toContain('name="vault_name"');
|
|
254
|
+
expect(html).toContain('placeholder="default"');
|
|
219
255
|
expect(html).toContain('id="preview-vault-name">default<');
|
|
220
|
-
|
|
221
|
-
|
|
256
|
+
// The input enforces vault's contract (lowercase alphanumeric +
|
|
257
|
+
// -/_, 2-32 chars) at the HTML5 layer too so an over-eager
|
|
258
|
+
// browser surfaces the error before POST.
|
|
259
|
+
expect(html).toContain('pattern="[a-z0-9_-]+"');
|
|
260
|
+
expect(html).toContain('minlength="2"');
|
|
261
|
+
expect(html).toContain('maxlength="32"');
|
|
222
262
|
} finally {
|
|
223
263
|
db.close();
|
|
224
264
|
}
|
|
225
265
|
});
|
|
226
266
|
|
|
227
|
-
test("301s to /login once
|
|
267
|
+
test("301s to /login once admin + vault + expose mode all exist", async () => {
|
|
228
268
|
const db = openHubDb(hubDbPath(h.dir));
|
|
229
269
|
try {
|
|
230
270
|
await createUser(db, "owner", "pw");
|
|
@@ -242,6 +282,10 @@ describe("handleSetupGet", () => {
|
|
|
242
282
|
},
|
|
243
283
|
h.manifestPath,
|
|
244
284
|
);
|
|
285
|
+
// hub#268 Item 2: the expose-mode answer is the third gate of
|
|
286
|
+
// "wizard is fully done." Without it the GET renders the expose
|
|
287
|
+
// step rather than 301-ing.
|
|
288
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
245
289
|
const res = handleSetupGet(req("/admin/setup"), {
|
|
246
290
|
db,
|
|
247
291
|
manifestPath: h.manifestPath,
|
|
@@ -256,7 +300,7 @@ describe("handleSetupGet", () => {
|
|
|
256
300
|
}
|
|
257
301
|
});
|
|
258
302
|
|
|
259
|
-
test("renders the
|
|
303
|
+
test("renders the expose step when admin + vault exist but no expose mode (hub#268 Item 2)", async () => {
|
|
260
304
|
const db = openHubDb(hubDbPath(h.dir));
|
|
261
305
|
try {
|
|
262
306
|
await createUser(db, "owner", "pw");
|
|
@@ -267,14 +311,14 @@ describe("handleSetupGet", () => {
|
|
|
267
311
|
name: "parachute-vault",
|
|
268
312
|
version: "0.1.0",
|
|
269
313
|
port: 1940,
|
|
270
|
-
paths: ["/vault/
|
|
314
|
+
paths: ["/vault/default"],
|
|
271
315
|
health: "/health",
|
|
272
316
|
},
|
|
273
317
|
],
|
|
274
318
|
},
|
|
275
319
|
h.manifestPath,
|
|
276
320
|
);
|
|
277
|
-
const res = handleSetupGet(req("/admin/setup
|
|
321
|
+
const res = handleSetupGet(req("/admin/setup"), {
|
|
278
322
|
db,
|
|
279
323
|
manifestPath: h.manifestPath,
|
|
280
324
|
configDir: h.dir,
|
|
@@ -283,10 +327,147 @@ describe("handleSetupGet", () => {
|
|
|
283
327
|
});
|
|
284
328
|
expect(res.status).toBe(200);
|
|
285
329
|
const html = await res.text();
|
|
330
|
+
// Three radio options + the form action are the load-bearing
|
|
331
|
+
// surface; everything else is presentational.
|
|
332
|
+
expect(html).toContain('action="/admin/setup/expose"');
|
|
333
|
+
expect(html).toContain('value="localhost"');
|
|
334
|
+
expect(html).toContain('value="tailnet"');
|
|
335
|
+
expect(html).toContain('value="public"');
|
|
336
|
+
// localhost is the safe default selection.
|
|
337
|
+
expect(html).toContain('value="localhost" checked');
|
|
338
|
+
} finally {
|
|
339
|
+
db.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("renders the success page once with ?just_finished=1 query", async () => {
|
|
344
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
345
|
+
try {
|
|
346
|
+
const user = await createUser(db, "owner", "pw");
|
|
347
|
+
writeManifest(
|
|
348
|
+
{
|
|
349
|
+
services: [
|
|
350
|
+
{
|
|
351
|
+
name: "parachute-vault",
|
|
352
|
+
version: "0.1.0",
|
|
353
|
+
port: 1940,
|
|
354
|
+
paths: ["/vault/myvault"],
|
|
355
|
+
health: "/health",
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
h.manifestPath,
|
|
360
|
+
);
|
|
361
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
362
|
+
// hub#274 security fold: done-screen GET is session-gated.
|
|
363
|
+
const { createSession } = await import("../sessions.ts");
|
|
364
|
+
const session = createSession(db, { userId: user.id });
|
|
365
|
+
const res = handleSetupGet(
|
|
366
|
+
req("/admin/setup?just_finished=1", {
|
|
367
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
368
|
+
}),
|
|
369
|
+
{
|
|
370
|
+
db,
|
|
371
|
+
manifestPath: h.manifestPath,
|
|
372
|
+
configDir: h.dir,
|
|
373
|
+
issuer: "https://hub.example",
|
|
374
|
+
registry: getDefaultOperationsRegistry(),
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(200);
|
|
378
|
+
const html = await res.text();
|
|
286
379
|
expect(html).toContain("You're set up");
|
|
287
380
|
// The success page surfaces the vault name from services.json so
|
|
288
381
|
// the MCP install line carries the operator's actual choice.
|
|
289
382
|
expect(html).toContain("myvault");
|
|
383
|
+
// hub#268 Item 2: the reachable tile reflects the operator's
|
|
384
|
+
// expose-mode choice. Localhost mode mentions the loopback URL
|
|
385
|
+
// and the upgrade path to tailnet.
|
|
386
|
+
expect(html).toContain("Your hub is reachable at");
|
|
387
|
+
expect(html).toContain("Local to this machine only");
|
|
388
|
+
} finally {
|
|
389
|
+
db.close();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("success page reachable tile reflects the tailnet expose mode (hub#268 Item 2)", async () => {
|
|
394
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
395
|
+
try {
|
|
396
|
+
const user = await createUser(db, "owner", "pw");
|
|
397
|
+
writeManifest(
|
|
398
|
+
{
|
|
399
|
+
services: [
|
|
400
|
+
{
|
|
401
|
+
name: "parachute-vault",
|
|
402
|
+
version: "0.1.0",
|
|
403
|
+
port: 1940,
|
|
404
|
+
paths: ["/vault/default"],
|
|
405
|
+
health: "/health",
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
h.manifestPath,
|
|
410
|
+
);
|
|
411
|
+
setSetting(db, "setup_expose_mode", "tailnet");
|
|
412
|
+
const { createSession } = await import("../sessions.ts");
|
|
413
|
+
const session = createSession(db, { userId: user.id });
|
|
414
|
+
const res = handleSetupGet(
|
|
415
|
+
req("/admin/setup?just_finished=1", {
|
|
416
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
417
|
+
}),
|
|
418
|
+
{
|
|
419
|
+
db,
|
|
420
|
+
manifestPath: h.manifestPath,
|
|
421
|
+
configDir: h.dir,
|
|
422
|
+
issuer: "https://hub.example",
|
|
423
|
+
registry: getDefaultOperationsRegistry(),
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
expect(res.status).toBe(200);
|
|
427
|
+
const html = await res.text();
|
|
428
|
+
expect(html).toContain("tailscale serve --bg --https=1939");
|
|
429
|
+
} finally {
|
|
430
|
+
db.close();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("success page reachable tile reflects the public expose mode (hub#268 Item 2)", async () => {
|
|
435
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
436
|
+
try {
|
|
437
|
+
const user = await createUser(db, "owner", "pw");
|
|
438
|
+
writeManifest(
|
|
439
|
+
{
|
|
440
|
+
services: [
|
|
441
|
+
{
|
|
442
|
+
name: "parachute-vault",
|
|
443
|
+
version: "0.1.0",
|
|
444
|
+
port: 1940,
|
|
445
|
+
paths: ["/vault/default"],
|
|
446
|
+
health: "/health",
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
h.manifestPath,
|
|
451
|
+
);
|
|
452
|
+
setSetting(db, "setup_expose_mode", "public");
|
|
453
|
+
const { createSession } = await import("../sessions.ts");
|
|
454
|
+
const session = createSession(db, { userId: user.id });
|
|
455
|
+
const res = handleSetupGet(
|
|
456
|
+
req("/admin/setup?just_finished=1", {
|
|
457
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
458
|
+
}),
|
|
459
|
+
{
|
|
460
|
+
db,
|
|
461
|
+
manifestPath: h.manifestPath,
|
|
462
|
+
configDir: h.dir,
|
|
463
|
+
issuer: "https://hub.example",
|
|
464
|
+
registry: getDefaultOperationsRegistry(),
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
expect(res.status).toBe(200);
|
|
468
|
+
const html = await res.text();
|
|
469
|
+
expect(html).toContain("PARACHUTE_HUB_ORIGIN");
|
|
470
|
+
expect(html).toContain("parachute.computer/docs/deploy");
|
|
290
471
|
} finally {
|
|
291
472
|
db.close();
|
|
292
473
|
}
|
|
@@ -396,6 +577,13 @@ describe("handleSetupAccountPost", () => {
|
|
|
396
577
|
const sessionCookie = setCookie(post, SESSION_COOKIE_NAME);
|
|
397
578
|
expect(sessionCookie).toBeDefined();
|
|
398
579
|
expect(userCount(db)).toBe(1);
|
|
580
|
+
// Multi-user Phase 1: the wizard's first admin chose their password
|
|
581
|
+
// via this very form, so skip the force-change-password redirect on
|
|
582
|
+
// first sign-in (`password_changed=1`). `assigned_vault` stays NULL
|
|
583
|
+
// — admin posture (no per-vault restriction).
|
|
584
|
+
const created = getUserByUsername(db, "ops");
|
|
585
|
+
expect(created?.passwordChanged).toBe(true);
|
|
586
|
+
expect(created?.assignedVault).toBeNull();
|
|
399
587
|
} finally {
|
|
400
588
|
db.close();
|
|
401
589
|
}
|
|
@@ -734,7 +922,7 @@ describe("setup wizard end-to-end via hubFetch", () => {
|
|
|
734
922
|
});
|
|
735
923
|
afterEach(() => h.cleanup());
|
|
736
924
|
|
|
737
|
-
test("redirects to /login once admin + vault are
|
|
925
|
+
test("redirects to /login once admin + vault + expose mode are all set", async () => {
|
|
738
926
|
const db = openHubDb(hubDbPath(h.dir));
|
|
739
927
|
try {
|
|
740
928
|
await createUser(db, "owner", "pw");
|
|
@@ -752,6 +940,7 @@ describe("setup wizard end-to-end via hubFetch", () => {
|
|
|
752
940
|
},
|
|
753
941
|
h.manifestPath,
|
|
754
942
|
);
|
|
943
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
755
944
|
const res = await hubFetch(h.dir, {
|
|
756
945
|
getDb: () => db,
|
|
757
946
|
manifestPath: h.manifestPath,
|
|
@@ -813,3 +1002,1301 @@ describe("setup wizard end-to-end via hubFetch", () => {
|
|
|
813
1002
|
}
|
|
814
1003
|
});
|
|
815
1004
|
});
|
|
1005
|
+
|
|
1006
|
+
// --- POST /admin/setup/expose (hub#268 Item 2 + Item 3) ------------------
|
|
1007
|
+
|
|
1008
|
+
describe("handleSetupExposePost", () => {
|
|
1009
|
+
let h: Harness;
|
|
1010
|
+
beforeEach(() => {
|
|
1011
|
+
h = makeHarness();
|
|
1012
|
+
_resetOperationsRegistryForTests();
|
|
1013
|
+
});
|
|
1014
|
+
afterEach(() => h.cleanup());
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Helper: bring the wizard to step 4 (expose). Creates an admin row,
|
|
1018
|
+
* seeds the vault entry, mints a session cookie + CSRF token. Returns
|
|
1019
|
+
* everything callers need to drive the POST.
|
|
1020
|
+
*/
|
|
1021
|
+
async function bringWizardToExposeStep(db: ReturnType<typeof openHubDb>) {
|
|
1022
|
+
const user = await createUser(db, "owner", "pw");
|
|
1023
|
+
writeManifest(
|
|
1024
|
+
{
|
|
1025
|
+
services: [
|
|
1026
|
+
{
|
|
1027
|
+
name: "parachute-vault",
|
|
1028
|
+
version: "0.1.0",
|
|
1029
|
+
port: 1940,
|
|
1030
|
+
paths: ["/vault/default"],
|
|
1031
|
+
health: "/health",
|
|
1032
|
+
},
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
1035
|
+
h.manifestPath,
|
|
1036
|
+
);
|
|
1037
|
+
const { createSession } = await import("../sessions.ts");
|
|
1038
|
+
const session = createSession(db, { userId: user.id });
|
|
1039
|
+
// Get the wizard's expose step to mint the CSRF cookie.
|
|
1040
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
1041
|
+
db,
|
|
1042
|
+
manifestPath: h.manifestPath,
|
|
1043
|
+
configDir: h.dir,
|
|
1044
|
+
issuer: "https://hub.example",
|
|
1045
|
+
registry: getDefaultOperationsRegistry(),
|
|
1046
|
+
});
|
|
1047
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1048
|
+
return { user, session, csrf };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
test("persists a valid expose_mode + opens the auto-approve window + redirects to ?just_finished=1", async () => {
|
|
1052
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1053
|
+
try {
|
|
1054
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
1055
|
+
const form = new URLSearchParams({
|
|
1056
|
+
expose_mode: "tailnet",
|
|
1057
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1058
|
+
}).toString();
|
|
1059
|
+
const res = await handleSetupExposePost(
|
|
1060
|
+
req("/admin/setup/expose", {
|
|
1061
|
+
method: "POST",
|
|
1062
|
+
body: form,
|
|
1063
|
+
headers: {
|
|
1064
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1065
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1066
|
+
},
|
|
1067
|
+
}),
|
|
1068
|
+
{
|
|
1069
|
+
db,
|
|
1070
|
+
manifestPath: h.manifestPath,
|
|
1071
|
+
configDir: h.dir,
|
|
1072
|
+
issuer: "https://hub.example",
|
|
1073
|
+
registry: getDefaultOperationsRegistry(),
|
|
1074
|
+
},
|
|
1075
|
+
);
|
|
1076
|
+
expect(res.status).toBe(303);
|
|
1077
|
+
expect(res.headers.get("location")).toBe("/admin/setup?just_finished=1");
|
|
1078
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
|
|
1079
|
+
// hub#268 Item 3: the auto-approve window is opened on this transition.
|
|
1080
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
1081
|
+
} finally {
|
|
1082
|
+
db.close();
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("rejects an invalid expose_mode (renders the form with an error banner)", async () => {
|
|
1087
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1088
|
+
try {
|
|
1089
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
1090
|
+
const form = new URLSearchParams({
|
|
1091
|
+
expose_mode: "garbage",
|
|
1092
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1093
|
+
}).toString();
|
|
1094
|
+
const res = await handleSetupExposePost(
|
|
1095
|
+
req("/admin/setup/expose", {
|
|
1096
|
+
method: "POST",
|
|
1097
|
+
body: form,
|
|
1098
|
+
headers: {
|
|
1099
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1100
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1101
|
+
},
|
|
1102
|
+
}),
|
|
1103
|
+
{
|
|
1104
|
+
db,
|
|
1105
|
+
manifestPath: h.manifestPath,
|
|
1106
|
+
configDir: h.dir,
|
|
1107
|
+
issuer: "https://hub.example",
|
|
1108
|
+
registry: getDefaultOperationsRegistry(),
|
|
1109
|
+
},
|
|
1110
|
+
);
|
|
1111
|
+
expect(res.status).toBe(400);
|
|
1112
|
+
const html = await res.text();
|
|
1113
|
+
expect(html).toContain("Pick one of");
|
|
1114
|
+
// No expose-mode persisted on rejection.
|
|
1115
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
1116
|
+
// No auto-approve window opened on rejection.
|
|
1117
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
1118
|
+
} finally {
|
|
1119
|
+
db.close();
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test("rejects without an admin session cookie", async () => {
|
|
1124
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1125
|
+
try {
|
|
1126
|
+
const { csrf } = await bringWizardToExposeStep(db);
|
|
1127
|
+
const form = new URLSearchParams({
|
|
1128
|
+
expose_mode: "localhost",
|
|
1129
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1130
|
+
}).toString();
|
|
1131
|
+
// Note: no session cookie sent.
|
|
1132
|
+
const res = await handleSetupExposePost(
|
|
1133
|
+
req("/admin/setup/expose", {
|
|
1134
|
+
method: "POST",
|
|
1135
|
+
body: form,
|
|
1136
|
+
headers: {
|
|
1137
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1138
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
|
|
1139
|
+
},
|
|
1140
|
+
}),
|
|
1141
|
+
{
|
|
1142
|
+
db,
|
|
1143
|
+
manifestPath: h.manifestPath,
|
|
1144
|
+
configDir: h.dir,
|
|
1145
|
+
issuer: "https://hub.example",
|
|
1146
|
+
registry: getDefaultOperationsRegistry(),
|
|
1147
|
+
},
|
|
1148
|
+
);
|
|
1149
|
+
expect(res.status).toBe(400);
|
|
1150
|
+
const html = await res.text();
|
|
1151
|
+
expect(html).toContain("No admin session");
|
|
1152
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
1153
|
+
} finally {
|
|
1154
|
+
db.close();
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("rejects missing or wrong CSRF token", async () => {
|
|
1159
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1160
|
+
try {
|
|
1161
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
1162
|
+
const form = new URLSearchParams({
|
|
1163
|
+
expose_mode: "localhost",
|
|
1164
|
+
[CSRF_FIELD_NAME]: "wrong-token",
|
|
1165
|
+
}).toString();
|
|
1166
|
+
const res = await handleSetupExposePost(
|
|
1167
|
+
req("/admin/setup/expose", {
|
|
1168
|
+
method: "POST",
|
|
1169
|
+
body: form,
|
|
1170
|
+
headers: {
|
|
1171
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1172
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1173
|
+
},
|
|
1174
|
+
}),
|
|
1175
|
+
{
|
|
1176
|
+
db,
|
|
1177
|
+
manifestPath: h.manifestPath,
|
|
1178
|
+
configDir: h.dir,
|
|
1179
|
+
issuer: "https://hub.example",
|
|
1180
|
+
registry: getDefaultOperationsRegistry(),
|
|
1181
|
+
},
|
|
1182
|
+
);
|
|
1183
|
+
expect(res.status).toBe(400);
|
|
1184
|
+
expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
|
|
1185
|
+
} finally {
|
|
1186
|
+
db.close();
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test("idempotent: second POST after already done short-circuits without re-opening the window", async () => {
|
|
1191
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1192
|
+
try {
|
|
1193
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
1194
|
+
// Pre-seed expose_mode + an OLD window timestamp so we can verify
|
|
1195
|
+
// the second POST doesn't bump it.
|
|
1196
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1197
|
+
setSetting(db, "pending_first_client_auto_approve_until", "2020-01-01T00:00:00.000Z");
|
|
1198
|
+
const form = new URLSearchParams({
|
|
1199
|
+
expose_mode: "tailnet",
|
|
1200
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1201
|
+
}).toString();
|
|
1202
|
+
const res = await handleSetupExposePost(
|
|
1203
|
+
req("/admin/setup/expose", {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
body: form,
|
|
1206
|
+
headers: {
|
|
1207
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1208
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1209
|
+
},
|
|
1210
|
+
}),
|
|
1211
|
+
{
|
|
1212
|
+
db,
|
|
1213
|
+
manifestPath: h.manifestPath,
|
|
1214
|
+
configDir: h.dir,
|
|
1215
|
+
issuer: "https://hub.example",
|
|
1216
|
+
registry: getDefaultOperationsRegistry(),
|
|
1217
|
+
},
|
|
1218
|
+
);
|
|
1219
|
+
expect(res.status).toBe(303);
|
|
1220
|
+
expect(res.headers.get("location")).toBe("/admin/setup?just_finished=1");
|
|
1221
|
+
// expose_mode is NOT overwritten (the wizard considers itself done).
|
|
1222
|
+
expect(getSetting(db, "setup_expose_mode")).toBe("localhost");
|
|
1223
|
+
// auto-approve window NOT re-opened — still the old stale stamp.
|
|
1224
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBe(
|
|
1225
|
+
"2020-01-01T00:00:00.000Z",
|
|
1226
|
+
);
|
|
1227
|
+
} finally {
|
|
1228
|
+
db.close();
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// --- hub#272 Item A: auto-mint operator token + MCP command rendering ---
|
|
1234
|
+
|
|
1235
|
+
describe("done screen auto-minted token (hub#272 Item A)", () => {
|
|
1236
|
+
let h: Harness;
|
|
1237
|
+
beforeEach(() => {
|
|
1238
|
+
h = makeHarness();
|
|
1239
|
+
_resetOperationsRegistryForTests();
|
|
1240
|
+
});
|
|
1241
|
+
afterEach(() => h.cleanup());
|
|
1242
|
+
|
|
1243
|
+
async function bringWizardToExposeStep(db: ReturnType<typeof openHubDb>) {
|
|
1244
|
+
const user = await createUser(db, "owner", "pw");
|
|
1245
|
+
writeManifest(
|
|
1246
|
+
{
|
|
1247
|
+
services: [
|
|
1248
|
+
{
|
|
1249
|
+
name: "parachute-vault",
|
|
1250
|
+
version: "0.1.0",
|
|
1251
|
+
port: 1940,
|
|
1252
|
+
paths: ["/vault/default"],
|
|
1253
|
+
health: "/health",
|
|
1254
|
+
},
|
|
1255
|
+
],
|
|
1256
|
+
},
|
|
1257
|
+
h.manifestPath,
|
|
1258
|
+
);
|
|
1259
|
+
const { createSession } = await import("../sessions.ts");
|
|
1260
|
+
const session = createSession(db, { userId: user.id });
|
|
1261
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
1262
|
+
db,
|
|
1263
|
+
manifestPath: h.manifestPath,
|
|
1264
|
+
configDir: h.dir,
|
|
1265
|
+
issuer: "https://hub.example",
|
|
1266
|
+
registry: getDefaultOperationsRegistry(),
|
|
1267
|
+
});
|
|
1268
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1269
|
+
return { user, session, csrf };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
test("expose POST mints + stores an operator token in hub_settings (setup_minted_token)", async () => {
|
|
1273
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1274
|
+
try {
|
|
1275
|
+
const { session, csrf } = await bringWizardToExposeStep(db);
|
|
1276
|
+
const form = new URLSearchParams({
|
|
1277
|
+
expose_mode: "localhost",
|
|
1278
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1279
|
+
}).toString();
|
|
1280
|
+
const res = await handleSetupExposePost(
|
|
1281
|
+
req("/admin/setup/expose", {
|
|
1282
|
+
method: "POST",
|
|
1283
|
+
body: form,
|
|
1284
|
+
headers: {
|
|
1285
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1286
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1287
|
+
},
|
|
1288
|
+
}),
|
|
1289
|
+
{
|
|
1290
|
+
db,
|
|
1291
|
+
manifestPath: h.manifestPath,
|
|
1292
|
+
configDir: h.dir,
|
|
1293
|
+
issuer: "https://hub.example",
|
|
1294
|
+
registry: getDefaultOperationsRegistry(),
|
|
1295
|
+
},
|
|
1296
|
+
);
|
|
1297
|
+
expect(res.status).toBe(303);
|
|
1298
|
+
// Token is a JWT (three base64url segments). We don't assert the
|
|
1299
|
+
// exact value — the load-bearing surface is "a non-empty token
|
|
1300
|
+
// exists" so the done-step renderer has something to inject.
|
|
1301
|
+
const stored = getSetting(db, "setup_minted_token");
|
|
1302
|
+
expect(stored).toBeDefined();
|
|
1303
|
+
expect(stored?.split(".").length).toBe(3);
|
|
1304
|
+
} finally {
|
|
1305
|
+
db.close();
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("done screen renders the MCP command with a Bearer header when a minted token exists", async () => {
|
|
1310
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1311
|
+
try {
|
|
1312
|
+
const user = await createUser(db, "owner", "pw");
|
|
1313
|
+
writeManifest(
|
|
1314
|
+
{
|
|
1315
|
+
services: [
|
|
1316
|
+
{
|
|
1317
|
+
name: "parachute-vault",
|
|
1318
|
+
version: "0.1.0",
|
|
1319
|
+
port: 1940,
|
|
1320
|
+
paths: ["/vault/default"],
|
|
1321
|
+
health: "/health",
|
|
1322
|
+
},
|
|
1323
|
+
],
|
|
1324
|
+
},
|
|
1325
|
+
h.manifestPath,
|
|
1326
|
+
);
|
|
1327
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1328
|
+
setSetting(db, "setup_minted_token", "test-jwt-token-abc");
|
|
1329
|
+
const { createSession } = await import("../sessions.ts");
|
|
1330
|
+
const session = createSession(db, { userId: user.id });
|
|
1331
|
+
const res = handleSetupGet(
|
|
1332
|
+
req("/admin/setup?just_finished=1", {
|
|
1333
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1334
|
+
}),
|
|
1335
|
+
{
|
|
1336
|
+
db,
|
|
1337
|
+
manifestPath: h.manifestPath,
|
|
1338
|
+
configDir: h.dir,
|
|
1339
|
+
issuer: "https://hub.example",
|
|
1340
|
+
registry: getDefaultOperationsRegistry(),
|
|
1341
|
+
},
|
|
1342
|
+
);
|
|
1343
|
+
expect(res.status).toBe(200);
|
|
1344
|
+
const html = await res.text();
|
|
1345
|
+
// Real token rides in the hidden script-tag stash as JSON-encoded
|
|
1346
|
+
// text — script element content is raw-text per the HTML spec
|
|
1347
|
+
// (entities aren't parsed), so JSON encoding round-trips through
|
|
1348
|
+
// textContent + JSON.parse without `"` polluting the copied
|
|
1349
|
+
// command. Verify the JSON-encoded form appears in the document.
|
|
1350
|
+
expect(html).toContain(
|
|
1351
|
+
'"claude mcp add --transport http parachute-default https://hub.example/vault/default/mcp --header \\"Authorization: Bearer test-jwt-token-abc\\""',
|
|
1352
|
+
);
|
|
1353
|
+
expect(html).toContain('id="mcp-cmd"');
|
|
1354
|
+
expect(html).toContain('id="mcp-cmd-real"');
|
|
1355
|
+
// The hidden stash is `<script type="application/json">` so the
|
|
1356
|
+
// browser doesn't execute it but textContent is still readable.
|
|
1357
|
+
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
1358
|
+
// The visible default state is masked: the <pre> body is wrapped
|
|
1359
|
+
// with data-state="masked" and renders • placeholder characters
|
|
1360
|
+
// rather than the live token. Verified by the masked Bearer
|
|
1361
|
+
// header substring (• repeated).
|
|
1362
|
+
expect(html).toContain('data-state="masked"');
|
|
1363
|
+
expect(html).toMatch(/Bearer •+/);
|
|
1364
|
+
// Show button + Copy button both present.
|
|
1365
|
+
expect(html).toContain('id="mcp-cmd-show"');
|
|
1366
|
+
expect(html).toContain('id="mcp-cmd-copy"');
|
|
1367
|
+
expect(html).toContain("/admin/tokens");
|
|
1368
|
+
// The token is single-use — consumed on first render.
|
|
1369
|
+
expect(getSetting(db, "setup_minted_token")).toBeUndefined();
|
|
1370
|
+
} finally {
|
|
1371
|
+
db.close();
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test("done screen falls back to bare MCP command + admin/tokens hint when no minted token", async () => {
|
|
1376
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1377
|
+
try {
|
|
1378
|
+
const user = await createUser(db, "owner", "pw");
|
|
1379
|
+
writeManifest(
|
|
1380
|
+
{
|
|
1381
|
+
services: [
|
|
1382
|
+
{
|
|
1383
|
+
name: "parachute-vault",
|
|
1384
|
+
version: "0.1.0",
|
|
1385
|
+
port: 1940,
|
|
1386
|
+
paths: ["/vault/default"],
|
|
1387
|
+
health: "/health",
|
|
1388
|
+
},
|
|
1389
|
+
],
|
|
1390
|
+
},
|
|
1391
|
+
h.manifestPath,
|
|
1392
|
+
);
|
|
1393
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1394
|
+
const { createSession } = await import("../sessions.ts");
|
|
1395
|
+
const session = createSession(db, { userId: user.id });
|
|
1396
|
+
const res = handleSetupGet(
|
|
1397
|
+
req("/admin/setup?just_finished=1", {
|
|
1398
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1399
|
+
}),
|
|
1400
|
+
{
|
|
1401
|
+
db,
|
|
1402
|
+
manifestPath: h.manifestPath,
|
|
1403
|
+
configDir: h.dir,
|
|
1404
|
+
issuer: "https://hub.example",
|
|
1405
|
+
registry: getDefaultOperationsRegistry(),
|
|
1406
|
+
},
|
|
1407
|
+
);
|
|
1408
|
+
const html = await res.text();
|
|
1409
|
+
expect(html).toContain("claude mcp add --transport http parachute-default");
|
|
1410
|
+
// The fallback explanatory text mentions `pvt_...` as a placeholder
|
|
1411
|
+
// but the actual `--header` flag must NOT be appended to the
|
|
1412
|
+
// command line itself.
|
|
1413
|
+
expect(html).toContain("Bearer pvt_");
|
|
1414
|
+
expect(html).toContain("/admin/tokens");
|
|
1415
|
+
// Specifically no Copy button — that's a token-present surface.
|
|
1416
|
+
expect(html).not.toContain('id="mcp-cmd"');
|
|
1417
|
+
} finally {
|
|
1418
|
+
db.close();
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test("minted token is consumed after first render — refresh shows the fallback shape", async () => {
|
|
1423
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1424
|
+
try {
|
|
1425
|
+
const user = await createUser(db, "owner", "pw");
|
|
1426
|
+
writeManifest(
|
|
1427
|
+
{
|
|
1428
|
+
services: [
|
|
1429
|
+
{
|
|
1430
|
+
name: "parachute-vault",
|
|
1431
|
+
version: "0.1.0",
|
|
1432
|
+
port: 1940,
|
|
1433
|
+
paths: ["/vault/default"],
|
|
1434
|
+
health: "/health",
|
|
1435
|
+
},
|
|
1436
|
+
],
|
|
1437
|
+
},
|
|
1438
|
+
h.manifestPath,
|
|
1439
|
+
);
|
|
1440
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1441
|
+
setSetting(db, "setup_minted_token", "test-token-xyz");
|
|
1442
|
+
const { createSession } = await import("../sessions.ts");
|
|
1443
|
+
const session = createSession(db, { userId: user.id });
|
|
1444
|
+
const deps = {
|
|
1445
|
+
db,
|
|
1446
|
+
manifestPath: h.manifestPath,
|
|
1447
|
+
configDir: h.dir,
|
|
1448
|
+
issuer: "https://hub.example",
|
|
1449
|
+
registry: getDefaultOperationsRegistry(),
|
|
1450
|
+
};
|
|
1451
|
+
const sessionedReq = () =>
|
|
1452
|
+
req("/admin/setup?just_finished=1", {
|
|
1453
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1454
|
+
});
|
|
1455
|
+
const first = handleSetupGet(sessionedReq(), deps);
|
|
1456
|
+
const firstHtml = await first.text();
|
|
1457
|
+
expect(firstHtml).toContain("test-token-xyz");
|
|
1458
|
+
const second = handleSetupGet(sessionedReq(), deps);
|
|
1459
|
+
const secondHtml = await second.text();
|
|
1460
|
+
expect(secondHtml).not.toContain("test-token-xyz");
|
|
1461
|
+
// The MCP command tile has no Copy button on the fallback shape.
|
|
1462
|
+
expect(secondHtml).not.toContain('id="mcp-cmd"');
|
|
1463
|
+
} finally {
|
|
1464
|
+
db.close();
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
// rc.11 — token visible by default on the done screen was a
|
|
1469
|
+
// shoulder-surf hazard. The fix: render the visible command with
|
|
1470
|
+
// a masked Bearer token, stash the real command in a
|
|
1471
|
+
// hidden script tag, and surface a Show button + Copy button. Copy
|
|
1472
|
+
// ALWAYS pulls the real command from the script tag so the
|
|
1473
|
+
// operator's terminal paste never breaks regardless of mask state.
|
|
1474
|
+
test("done screen masks the Bearer token in the visible <pre> by default", async () => {
|
|
1475
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1476
|
+
try {
|
|
1477
|
+
const user = await createUser(db, "owner", "pw");
|
|
1478
|
+
writeManifest(
|
|
1479
|
+
{
|
|
1480
|
+
services: [
|
|
1481
|
+
{
|
|
1482
|
+
name: "parachute-vault",
|
|
1483
|
+
version: "0.1.0",
|
|
1484
|
+
port: 1940,
|
|
1485
|
+
paths: ["/vault/default"],
|
|
1486
|
+
health: "/health",
|
|
1487
|
+
},
|
|
1488
|
+
],
|
|
1489
|
+
},
|
|
1490
|
+
h.manifestPath,
|
|
1491
|
+
);
|
|
1492
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1493
|
+
setSetting(db, "setup_minted_token", "pvt_super_secret_token_payload");
|
|
1494
|
+
const { createSession } = await import("../sessions.ts");
|
|
1495
|
+
const session = createSession(db, { userId: user.id });
|
|
1496
|
+
const res = handleSetupGet(
|
|
1497
|
+
req("/admin/setup?just_finished=1", {
|
|
1498
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1499
|
+
}),
|
|
1500
|
+
{
|
|
1501
|
+
db,
|
|
1502
|
+
manifestPath: h.manifestPath,
|
|
1503
|
+
configDir: h.dir,
|
|
1504
|
+
issuer: "https://hub.example",
|
|
1505
|
+
registry: getDefaultOperationsRegistry(),
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
const html = await res.text();
|
|
1509
|
+
// Extract the visible <pre id="mcp-cmd"> text only — the masked
|
|
1510
|
+
// shape must live there, with no occurrence of the literal token
|
|
1511
|
+
// string. The real token still appears elsewhere (the hidden
|
|
1512
|
+
// script tag) so a plain `toContain` would miss the leak.
|
|
1513
|
+
const preMatch = html.match(/<pre id="mcp-cmd">([^<]*)<\/pre>/);
|
|
1514
|
+
expect(preMatch).not.toBeNull();
|
|
1515
|
+
const preBody = preMatch?.[1] ?? "";
|
|
1516
|
+
expect(preBody).not.toContain("pvt_super_secret_token_payload");
|
|
1517
|
+
// Masked Bearer header is present in the <pre> text.
|
|
1518
|
+
expect(preBody).toMatch(/Bearer •+/);
|
|
1519
|
+
// Real command still in the document (hidden JSON stash) so the
|
|
1520
|
+
// Copy handler can read it.
|
|
1521
|
+
expect(html).toContain('<script type="application/json" id="mcp-cmd-real">');
|
|
1522
|
+
expect(html).toContain("pvt_super_secret_token_payload");
|
|
1523
|
+
// Default state is masked.
|
|
1524
|
+
expect(html).toContain('data-state="masked"');
|
|
1525
|
+
} finally {
|
|
1526
|
+
db.close();
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
test("done screen JSON-encodes the stashed command so `</script>` in a token can't break out", async () => {
|
|
1531
|
+
// Defense-in-depth: an attacker-shaped token containing `</script>`
|
|
1532
|
+
// would prematurely close the stash tag if we just dropped it into
|
|
1533
|
+
// the HTML. The renderer JSON-encodes the command AND replaces
|
|
1534
|
+
// `</` with `<\/` inside the encoded string so the sequence can't
|
|
1535
|
+
// appear in the document. Decode round-trips via JSON.parse.
|
|
1536
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1537
|
+
try {
|
|
1538
|
+
const user = await createUser(db, "owner", "pw");
|
|
1539
|
+
writeManifest(
|
|
1540
|
+
{
|
|
1541
|
+
services: [
|
|
1542
|
+
{
|
|
1543
|
+
name: "parachute-vault",
|
|
1544
|
+
version: "0.1.0",
|
|
1545
|
+
port: 1940,
|
|
1546
|
+
paths: ["/vault/default"],
|
|
1547
|
+
health: "/health",
|
|
1548
|
+
},
|
|
1549
|
+
],
|
|
1550
|
+
},
|
|
1551
|
+
h.manifestPath,
|
|
1552
|
+
);
|
|
1553
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1554
|
+
// Token contains characters that would be load-bearing in the
|
|
1555
|
+
// HTML/JS layer if mis-encoded: a quote (would close the JSON
|
|
1556
|
+
// string) and `</script>` (would close the stash tag).
|
|
1557
|
+
const hostileToken = `weird-token-with-"-and-</script>-inside`;
|
|
1558
|
+
setSetting(db, "setup_minted_token", hostileToken);
|
|
1559
|
+
const { createSession } = await import("../sessions.ts");
|
|
1560
|
+
const session = createSession(db, { userId: user.id });
|
|
1561
|
+
const res = handleSetupGet(
|
|
1562
|
+
req("/admin/setup?just_finished=1", {
|
|
1563
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1564
|
+
}),
|
|
1565
|
+
{
|
|
1566
|
+
db,
|
|
1567
|
+
manifestPath: h.manifestPath,
|
|
1568
|
+
configDir: h.dir,
|
|
1569
|
+
issuer: "https://hub.example",
|
|
1570
|
+
registry: getDefaultOperationsRegistry(),
|
|
1571
|
+
},
|
|
1572
|
+
);
|
|
1573
|
+
const html = await res.text();
|
|
1574
|
+
// `</script>` must NOT appear inside the stash element. We
|
|
1575
|
+
// verify by extracting the stash text via the literal HTML
|
|
1576
|
+
// boundaries and asserting no close-tag escape escaped the
|
|
1577
|
+
// encoder.
|
|
1578
|
+
const stashMatch = html.match(
|
|
1579
|
+
/<script type="application\/json" id="mcp-cmd-real">([\s\S]*?)<\/script>/,
|
|
1580
|
+
);
|
|
1581
|
+
expect(stashMatch).not.toBeNull();
|
|
1582
|
+
const stashBody = stashMatch?.[1] ?? "";
|
|
1583
|
+
// The encoder replaces `</` with `<\/` inside the JSON, so the
|
|
1584
|
+
// raw bytes between the opening and the first `</script>` should
|
|
1585
|
+
// not contain `</`.
|
|
1586
|
+
expect(stashBody).not.toContain("</");
|
|
1587
|
+
// Round-trips: `<\/` decodes back to `</` after JSON.parse +
|
|
1588
|
+
// the script-end-sequence escape — the operator's clipboard
|
|
1589
|
+
// gets the original bytes.
|
|
1590
|
+
const decoded = JSON.parse(stashBody) as string;
|
|
1591
|
+
expect(decoded).toContain(hostileToken);
|
|
1592
|
+
} finally {
|
|
1593
|
+
db.close();
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test("done screen wires Show + Copy buttons that read from the hidden real-command stash", async () => {
|
|
1598
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1599
|
+
try {
|
|
1600
|
+
const user = await createUser(db, "owner", "pw");
|
|
1601
|
+
writeManifest(
|
|
1602
|
+
{
|
|
1603
|
+
services: [
|
|
1604
|
+
{
|
|
1605
|
+
name: "parachute-vault",
|
|
1606
|
+
version: "0.1.0",
|
|
1607
|
+
port: 1940,
|
|
1608
|
+
paths: ["/vault/default"],
|
|
1609
|
+
health: "/health",
|
|
1610
|
+
},
|
|
1611
|
+
],
|
|
1612
|
+
},
|
|
1613
|
+
h.manifestPath,
|
|
1614
|
+
);
|
|
1615
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1616
|
+
setSetting(db, "setup_minted_token", "live-token-AAA");
|
|
1617
|
+
const { createSession } = await import("../sessions.ts");
|
|
1618
|
+
const session = createSession(db, { userId: user.id });
|
|
1619
|
+
const res = handleSetupGet(
|
|
1620
|
+
req("/admin/setup?just_finished=1", {
|
|
1621
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1622
|
+
}),
|
|
1623
|
+
{
|
|
1624
|
+
db,
|
|
1625
|
+
manifestPath: h.manifestPath,
|
|
1626
|
+
configDir: h.dir,
|
|
1627
|
+
issuer: "https://hub.example",
|
|
1628
|
+
registry: getDefaultOperationsRegistry(),
|
|
1629
|
+
},
|
|
1630
|
+
);
|
|
1631
|
+
const html = await res.text();
|
|
1632
|
+
// Both buttons present, both wired via addEventListener (no
|
|
1633
|
+
// inline onclick — the script runs in a single IIFE).
|
|
1634
|
+
expect(html).toContain('id="mcp-cmd-show"');
|
|
1635
|
+
expect(html).toContain('id="mcp-cmd-copy"');
|
|
1636
|
+
expect(html).toContain("'click'");
|
|
1637
|
+
// The Copy handler reads from the hidden script tag, not from
|
|
1638
|
+
// the visible <pre>. Regression: this was the load-bearing
|
|
1639
|
+
// contract Aaron called out ("Copy still works without reveal").
|
|
1640
|
+
expect(html).toContain("getElementById('mcp-cmd-real')");
|
|
1641
|
+
// The stash holds JSON-encoded text and the handler decodes via
|
|
1642
|
+
// JSON.parse so the clipboard receives the exact byte sequence of
|
|
1643
|
+
// the command — `"`-style HTML entities can't survive into
|
|
1644
|
+
// the operator's shell because script-element content is raw text
|
|
1645
|
+
// (the HTML parser doesn't decode entities inside <script>).
|
|
1646
|
+
expect(html).toContain("JSON.parse(real.textContent");
|
|
1647
|
+
// Auto-hide timer present so a stray reveal doesn't leak into a
|
|
1648
|
+
// subsequent screencast capture.
|
|
1649
|
+
expect(html).toContain("setTimeout(setMasked, 10000)");
|
|
1650
|
+
} finally {
|
|
1651
|
+
db.close();
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
test("GET /admin/setup?just_finished=1 without a session does NOT consume the minted token (hub#274 security fold)", async () => {
|
|
1656
|
+
// Regression — without the session gate, any HTTP client racing the
|
|
1657
|
+
// operator's browser between the expose POST (which mints + stores)
|
|
1658
|
+
// and the done GET (which reads + consumes) walks off with a
|
|
1659
|
+
// full-scope operator JWT. The gate sends sessionless GETs to
|
|
1660
|
+
// /login + leaves the row in place so the operator's subsequent
|
|
1661
|
+
// legitimate GET still surfaces the token.
|
|
1662
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1663
|
+
try {
|
|
1664
|
+
await createUser(db, "owner", "pw");
|
|
1665
|
+
writeManifest(
|
|
1666
|
+
{
|
|
1667
|
+
services: [
|
|
1668
|
+
{
|
|
1669
|
+
name: "parachute-vault",
|
|
1670
|
+
version: "0.1.0",
|
|
1671
|
+
port: 1940,
|
|
1672
|
+
paths: ["/vault/default"],
|
|
1673
|
+
health: "/health",
|
|
1674
|
+
},
|
|
1675
|
+
],
|
|
1676
|
+
},
|
|
1677
|
+
h.manifestPath,
|
|
1678
|
+
);
|
|
1679
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1680
|
+
setSetting(db, "setup_minted_token", "test-secret-token-must-not-leak");
|
|
1681
|
+
// No session cookie on this request — simulating a drive-by GET
|
|
1682
|
+
// from an attacker or a stale bookmark in a different browser
|
|
1683
|
+
// tab that doesn't carry the wizard's session.
|
|
1684
|
+
const res = handleSetupGet(req("/admin/setup?just_finished=1"), {
|
|
1685
|
+
db,
|
|
1686
|
+
manifestPath: h.manifestPath,
|
|
1687
|
+
configDir: h.dir,
|
|
1688
|
+
issuer: "https://hub.example",
|
|
1689
|
+
registry: getDefaultOperationsRegistry(),
|
|
1690
|
+
});
|
|
1691
|
+
// The gate redirects to /login (302) rather than rendering the
|
|
1692
|
+
// done screen. Body must NOT contain the token.
|
|
1693
|
+
expect(res.status).toBe(302);
|
|
1694
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
1695
|
+
// The setup_minted_token row is STILL present — the unauthed GET
|
|
1696
|
+
// didn't consume it, so the legitimate operator's session-bearing
|
|
1697
|
+
// GET will still see the token on the done screen.
|
|
1698
|
+
expect(getSetting(db, "setup_minted_token")).toBe("test-secret-token-must-not-leak");
|
|
1699
|
+
} finally {
|
|
1700
|
+
db.close();
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
// --- hub#272 Item B: install-tile rendering + install POST --------------
|
|
1706
|
+
|
|
1707
|
+
describe("done screen install tiles (hub#272 Item B)", () => {
|
|
1708
|
+
let h: Harness;
|
|
1709
|
+
beforeEach(() => {
|
|
1710
|
+
h = makeHarness();
|
|
1711
|
+
_resetOperationsRegistryForTests();
|
|
1712
|
+
});
|
|
1713
|
+
afterEach(() => h.cleanup());
|
|
1714
|
+
|
|
1715
|
+
test("done screen renders Install Notes + Install Scribe tiles when neither is installed", async () => {
|
|
1716
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1717
|
+
try {
|
|
1718
|
+
const user = await createUser(db, "owner", "pw");
|
|
1719
|
+
writeManifest(
|
|
1720
|
+
{
|
|
1721
|
+
services: [
|
|
1722
|
+
{
|
|
1723
|
+
name: "parachute-vault",
|
|
1724
|
+
version: "0.1.0",
|
|
1725
|
+
port: 1940,
|
|
1726
|
+
paths: ["/vault/default"],
|
|
1727
|
+
health: "/health",
|
|
1728
|
+
},
|
|
1729
|
+
],
|
|
1730
|
+
},
|
|
1731
|
+
h.manifestPath,
|
|
1732
|
+
);
|
|
1733
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1734
|
+
const { createSession } = await import("../sessions.ts");
|
|
1735
|
+
const session = createSession(db, { userId: user.id });
|
|
1736
|
+
const res = handleSetupGet(
|
|
1737
|
+
req("/admin/setup?just_finished=1", {
|
|
1738
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1739
|
+
}),
|
|
1740
|
+
{
|
|
1741
|
+
db,
|
|
1742
|
+
manifestPath: h.manifestPath,
|
|
1743
|
+
configDir: h.dir,
|
|
1744
|
+
issuer: "https://hub.example",
|
|
1745
|
+
registry: getDefaultOperationsRegistry(),
|
|
1746
|
+
},
|
|
1747
|
+
);
|
|
1748
|
+
const html = await res.text();
|
|
1749
|
+
expect(html).toContain("What's next?");
|
|
1750
|
+
expect(html).toContain("Install Notes");
|
|
1751
|
+
expect(html).toContain("Install Scribe");
|
|
1752
|
+
expect(html).toContain('action="/admin/setup/install/notes"');
|
|
1753
|
+
expect(html).toContain('action="/admin/setup/install/scribe"');
|
|
1754
|
+
} finally {
|
|
1755
|
+
db.close();
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
test("tile shows 'Already installed' when a curated module is in services.json", async () => {
|
|
1760
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1761
|
+
try {
|
|
1762
|
+
const user = await createUser(db, "owner", "pw");
|
|
1763
|
+
writeManifest(
|
|
1764
|
+
{
|
|
1765
|
+
services: [
|
|
1766
|
+
{
|
|
1767
|
+
name: "parachute-vault",
|
|
1768
|
+
version: "0.1.0",
|
|
1769
|
+
port: 1940,
|
|
1770
|
+
paths: ["/vault/default"],
|
|
1771
|
+
health: "/health",
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
name: "parachute-notes",
|
|
1775
|
+
version: "0.1.0",
|
|
1776
|
+
port: 1942,
|
|
1777
|
+
paths: ["/notes"],
|
|
1778
|
+
health: "/notes/health",
|
|
1779
|
+
},
|
|
1780
|
+
],
|
|
1781
|
+
},
|
|
1782
|
+
h.manifestPath,
|
|
1783
|
+
);
|
|
1784
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1785
|
+
const { createSession } = await import("../sessions.ts");
|
|
1786
|
+
const session = createSession(db, { userId: user.id });
|
|
1787
|
+
const res = handleSetupGet(
|
|
1788
|
+
req("/admin/setup?just_finished=1", {
|
|
1789
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1790
|
+
}),
|
|
1791
|
+
{
|
|
1792
|
+
db,
|
|
1793
|
+
manifestPath: h.manifestPath,
|
|
1794
|
+
configDir: h.dir,
|
|
1795
|
+
issuer: "https://hub.example",
|
|
1796
|
+
registry: getDefaultOperationsRegistry(),
|
|
1797
|
+
},
|
|
1798
|
+
);
|
|
1799
|
+
const html = await res.text();
|
|
1800
|
+
expect(html).toContain("Already installed");
|
|
1801
|
+
expect(html).toContain('action="/admin/setup/install/scribe"');
|
|
1802
|
+
} finally {
|
|
1803
|
+
db.close();
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("done screen renders op-poll panel when ?op_notes=<id> matches a registry op", async () => {
|
|
1808
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1809
|
+
try {
|
|
1810
|
+
const user = await createUser(db, "owner", "pw");
|
|
1811
|
+
writeManifest(
|
|
1812
|
+
{
|
|
1813
|
+
services: [
|
|
1814
|
+
{
|
|
1815
|
+
name: "parachute-vault",
|
|
1816
|
+
version: "0.1.0",
|
|
1817
|
+
port: 1940,
|
|
1818
|
+
paths: ["/vault/default"],
|
|
1819
|
+
health: "/health",
|
|
1820
|
+
},
|
|
1821
|
+
],
|
|
1822
|
+
},
|
|
1823
|
+
h.manifestPath,
|
|
1824
|
+
);
|
|
1825
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1826
|
+
const reg = getDefaultOperationsRegistry();
|
|
1827
|
+
const op = reg.create("install", "notes");
|
|
1828
|
+
reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/notes@latest");
|
|
1829
|
+
const { createSession } = await import("../sessions.ts");
|
|
1830
|
+
const session = createSession(db, { userId: user.id });
|
|
1831
|
+
const res = handleSetupGet(
|
|
1832
|
+
req(`/admin/setup?just_finished=1&op_notes=${op.id}`, {
|
|
1833
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
1834
|
+
}),
|
|
1835
|
+
{
|
|
1836
|
+
db,
|
|
1837
|
+
manifestPath: h.manifestPath,
|
|
1838
|
+
configDir: h.dir,
|
|
1839
|
+
issuer: "https://hub.example",
|
|
1840
|
+
registry: reg,
|
|
1841
|
+
},
|
|
1842
|
+
);
|
|
1843
|
+
const html = await res.text();
|
|
1844
|
+
expect(html).toContain("status: running");
|
|
1845
|
+
expect(html).toContain("running bun add");
|
|
1846
|
+
// Auto-refresh wired so the next tick re-fetches.
|
|
1847
|
+
expect(html).toContain('http-equiv="refresh"');
|
|
1848
|
+
} finally {
|
|
1849
|
+
db.close();
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test("install POST enqueues an op + redirects to ?op_<short>=<id>", async () => {
|
|
1854
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1855
|
+
try {
|
|
1856
|
+
const user = await createUser(db, "owner", "pw");
|
|
1857
|
+
writeManifest(
|
|
1858
|
+
{
|
|
1859
|
+
services: [
|
|
1860
|
+
{
|
|
1861
|
+
name: "parachute-vault",
|
|
1862
|
+
version: "0.1.0",
|
|
1863
|
+
port: 1940,
|
|
1864
|
+
paths: ["/vault/default"],
|
|
1865
|
+
health: "/health",
|
|
1866
|
+
},
|
|
1867
|
+
],
|
|
1868
|
+
},
|
|
1869
|
+
h.manifestPath,
|
|
1870
|
+
);
|
|
1871
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1872
|
+
const { createSession } = await import("../sessions.ts");
|
|
1873
|
+
const session = createSession(db, { userId: user.id });
|
|
1874
|
+
const get = handleSetupGet(req("/admin/setup?just_finished=1"), {
|
|
1875
|
+
db,
|
|
1876
|
+
manifestPath: h.manifestPath,
|
|
1877
|
+
configDir: h.dir,
|
|
1878
|
+
issuer: "https://hub.example",
|
|
1879
|
+
registry: getDefaultOperationsRegistry(),
|
|
1880
|
+
});
|
|
1881
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1882
|
+
const runCalls: string[][] = [];
|
|
1883
|
+
const stubbedRun = async (cmd: readonly string[]) => {
|
|
1884
|
+
runCalls.push([...cmd]);
|
|
1885
|
+
return 0;
|
|
1886
|
+
};
|
|
1887
|
+
const post = await handleSetupInstallPost(
|
|
1888
|
+
req("/admin/setup/install/notes", {
|
|
1889
|
+
method: "POST",
|
|
1890
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
|
|
1891
|
+
headers: {
|
|
1892
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1893
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1894
|
+
},
|
|
1895
|
+
}),
|
|
1896
|
+
"notes",
|
|
1897
|
+
{
|
|
1898
|
+
db,
|
|
1899
|
+
manifestPath: h.manifestPath,
|
|
1900
|
+
configDir: h.dir,
|
|
1901
|
+
issuer: "https://hub.example",
|
|
1902
|
+
supervisor: makeSupervisor(),
|
|
1903
|
+
registry: getDefaultOperationsRegistry(),
|
|
1904
|
+
run: stubbedRun,
|
|
1905
|
+
},
|
|
1906
|
+
);
|
|
1907
|
+
expect(post.status).toBe(303);
|
|
1908
|
+
const location = post.headers.get("location") ?? "";
|
|
1909
|
+
expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_notes=/);
|
|
1910
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1911
|
+
expect(runCalls.length).toBeGreaterThan(0);
|
|
1912
|
+
expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/notes@latest");
|
|
1913
|
+
} finally {
|
|
1914
|
+
db.close();
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
test("install POST rejects 'vault' short (the wizard's own step owns that)", async () => {
|
|
1919
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1920
|
+
try {
|
|
1921
|
+
const user = await createUser(db, "owner", "pw");
|
|
1922
|
+
const { createSession } = await import("../sessions.ts");
|
|
1923
|
+
const session = createSession(db, { userId: user.id });
|
|
1924
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
1925
|
+
db,
|
|
1926
|
+
manifestPath: h.manifestPath,
|
|
1927
|
+
configDir: h.dir,
|
|
1928
|
+
issuer: "https://hub.example",
|
|
1929
|
+
registry: getDefaultOperationsRegistry(),
|
|
1930
|
+
});
|
|
1931
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1932
|
+
const post = await handleSetupInstallPost(
|
|
1933
|
+
req("/admin/setup/install/vault", {
|
|
1934
|
+
method: "POST",
|
|
1935
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
|
|
1936
|
+
headers: {
|
|
1937
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1938
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1939
|
+
},
|
|
1940
|
+
}),
|
|
1941
|
+
"vault",
|
|
1942
|
+
{
|
|
1943
|
+
db,
|
|
1944
|
+
manifestPath: h.manifestPath,
|
|
1945
|
+
configDir: h.dir,
|
|
1946
|
+
issuer: "https://hub.example",
|
|
1947
|
+
supervisor: makeSupervisor(),
|
|
1948
|
+
registry: getDefaultOperationsRegistry(),
|
|
1949
|
+
},
|
|
1950
|
+
);
|
|
1951
|
+
expect(post.status).toBe(400);
|
|
1952
|
+
const html = await post.text();
|
|
1953
|
+
expect(html).toContain("not an installable wizard module");
|
|
1954
|
+
} finally {
|
|
1955
|
+
db.close();
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
test("install POST rejects unknown short", async () => {
|
|
1960
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1961
|
+
try {
|
|
1962
|
+
const post = await handleSetupInstallPost(
|
|
1963
|
+
req("/admin/setup/install/bogus", {
|
|
1964
|
+
method: "POST",
|
|
1965
|
+
body: new URLSearchParams({}).toString(),
|
|
1966
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1967
|
+
}),
|
|
1968
|
+
"bogus",
|
|
1969
|
+
{
|
|
1970
|
+
db,
|
|
1971
|
+
manifestPath: h.manifestPath,
|
|
1972
|
+
configDir: h.dir,
|
|
1973
|
+
issuer: "https://hub.example",
|
|
1974
|
+
supervisor: makeSupervisor(),
|
|
1975
|
+
registry: getDefaultOperationsRegistry(),
|
|
1976
|
+
},
|
|
1977
|
+
);
|
|
1978
|
+
expect(post.status).toBe(400);
|
|
1979
|
+
const html = await post.text();
|
|
1980
|
+
expect(html).toContain("not an installable wizard module");
|
|
1981
|
+
} finally {
|
|
1982
|
+
db.close();
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test("install POST without admin session is rejected", async () => {
|
|
1987
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1988
|
+
try {
|
|
1989
|
+
await createUser(db, "owner", "pw");
|
|
1990
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
1991
|
+
db,
|
|
1992
|
+
manifestPath: h.manifestPath,
|
|
1993
|
+
configDir: h.dir,
|
|
1994
|
+
issuer: "https://hub.example",
|
|
1995
|
+
registry: getDefaultOperationsRegistry(),
|
|
1996
|
+
});
|
|
1997
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1998
|
+
const post = await handleSetupInstallPost(
|
|
1999
|
+
req("/admin/setup/install/notes", {
|
|
2000
|
+
method: "POST",
|
|
2001
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
|
|
2002
|
+
headers: {
|
|
2003
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2004
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
|
|
2005
|
+
},
|
|
2006
|
+
}),
|
|
2007
|
+
"notes",
|
|
2008
|
+
{
|
|
2009
|
+
db,
|
|
2010
|
+
manifestPath: h.manifestPath,
|
|
2011
|
+
configDir: h.dir,
|
|
2012
|
+
issuer: "https://hub.example",
|
|
2013
|
+
supervisor: makeSupervisor(),
|
|
2014
|
+
registry: getDefaultOperationsRegistry(),
|
|
2015
|
+
},
|
|
2016
|
+
);
|
|
2017
|
+
expect(post.status).toBe(400);
|
|
2018
|
+
const html = await post.text();
|
|
2019
|
+
expect(html).toContain("No admin session");
|
|
2020
|
+
} finally {
|
|
2021
|
+
db.close();
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
test("install POST without supervisor (CLI mode) is rejected", async () => {
|
|
2026
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2027
|
+
try {
|
|
2028
|
+
await createUser(db, "owner", "pw");
|
|
2029
|
+
const post = await handleSetupInstallPost(
|
|
2030
|
+
req("/admin/setup/install/notes", {
|
|
2031
|
+
method: "POST",
|
|
2032
|
+
body: new URLSearchParams({}).toString(),
|
|
2033
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2034
|
+
}),
|
|
2035
|
+
"notes",
|
|
2036
|
+
{
|
|
2037
|
+
db,
|
|
2038
|
+
manifestPath: h.manifestPath,
|
|
2039
|
+
configDir: h.dir,
|
|
2040
|
+
issuer: "https://hub.example",
|
|
2041
|
+
registry: getDefaultOperationsRegistry(),
|
|
2042
|
+
},
|
|
2043
|
+
);
|
|
2044
|
+
expect(post.status).toBe(400);
|
|
2045
|
+
const html = await post.text();
|
|
2046
|
+
expect(html).toContain("supervisor unavailable");
|
|
2047
|
+
} finally {
|
|
2048
|
+
db.close();
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
// --- hub#267: typed vault name threading --------------------------------
|
|
2054
|
+
|
|
2055
|
+
describe("typed vault name (hub#267)", () => {
|
|
2056
|
+
let h: Harness;
|
|
2057
|
+
beforeEach(() => {
|
|
2058
|
+
h = makeHarness();
|
|
2059
|
+
_resetOperationsRegistryForTests();
|
|
2060
|
+
});
|
|
2061
|
+
afterEach(() => h.cleanup());
|
|
2062
|
+
|
|
2063
|
+
test("vault POST accepts a valid typed name + passes PARACHUTE_VAULT_NAME via env to supervisor", async () => {
|
|
2064
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2065
|
+
try {
|
|
2066
|
+
const user = await createUser(db, "owner", "pw");
|
|
2067
|
+
const { createSession } = await import("../sessions.ts");
|
|
2068
|
+
const session = createSession(db, { userId: user.id });
|
|
2069
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2070
|
+
db,
|
|
2071
|
+
manifestPath: h.manifestPath,
|
|
2072
|
+
configDir: h.dir,
|
|
2073
|
+
issuer: "https://hub.example",
|
|
2074
|
+
registry: getDefaultOperationsRegistry(),
|
|
2075
|
+
});
|
|
2076
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2077
|
+
// Capture supervisor spawn requests so we can assert env passthrough.
|
|
2078
|
+
const spawnRequests: Array<{
|
|
2079
|
+
short: string;
|
|
2080
|
+
env?: Record<string, string>;
|
|
2081
|
+
}> = [];
|
|
2082
|
+
const supervisor = new Supervisor({
|
|
2083
|
+
output: () => {},
|
|
2084
|
+
spawnFn: (sreq) => {
|
|
2085
|
+
spawnRequests.push({
|
|
2086
|
+
short: sreq.short,
|
|
2087
|
+
...(sreq.env ? { env: sreq.env } : {}),
|
|
2088
|
+
});
|
|
2089
|
+
return {
|
|
2090
|
+
pid: 22222,
|
|
2091
|
+
exited: new Promise<number | null>(() => {}),
|
|
2092
|
+
stdout: null,
|
|
2093
|
+
stderr: null,
|
|
2094
|
+
kill: () => {},
|
|
2095
|
+
};
|
|
2096
|
+
},
|
|
2097
|
+
});
|
|
2098
|
+
const stubbedRun = async (_cmd: readonly string[]) => 0;
|
|
2099
|
+
const post = await handleSetupVaultPost(
|
|
2100
|
+
req("/admin/setup/vault", {
|
|
2101
|
+
method: "POST",
|
|
2102
|
+
body: new URLSearchParams({
|
|
2103
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2104
|
+
vault_name: "smoke-1940",
|
|
2105
|
+
}).toString(),
|
|
2106
|
+
headers: {
|
|
2107
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2108
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
2109
|
+
},
|
|
2110
|
+
}),
|
|
2111
|
+
{
|
|
2112
|
+
db,
|
|
2113
|
+
manifestPath: h.manifestPath,
|
|
2114
|
+
configDir: h.dir,
|
|
2115
|
+
issuer: "https://hub.example",
|
|
2116
|
+
supervisor,
|
|
2117
|
+
registry: getDefaultOperationsRegistry(),
|
|
2118
|
+
run: stubbedRun,
|
|
2119
|
+
},
|
|
2120
|
+
);
|
|
2121
|
+
expect(post.status).toBe(303);
|
|
2122
|
+
expect(getSetting(db, "setup_vault_name")).toBe("smoke-1940");
|
|
2123
|
+
// Yield long enough for runInstall → spawnSupervised → supervisor.start
|
|
2124
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2125
|
+
expect(spawnRequests.length).toBeGreaterThan(0);
|
|
2126
|
+
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2127
|
+
expect(vaultSpawn).toBeDefined();
|
|
2128
|
+
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("smoke-1940");
|
|
2129
|
+
} finally {
|
|
2130
|
+
db.close();
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("vault POST rejects an invalid name (uppercase) with a 400 + error banner + preserved input", async () => {
|
|
2135
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2136
|
+
try {
|
|
2137
|
+
const user = await createUser(db, "owner", "pw");
|
|
2138
|
+
const { createSession } = await import("../sessions.ts");
|
|
2139
|
+
const session = createSession(db, { userId: user.id });
|
|
2140
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2141
|
+
db,
|
|
2142
|
+
manifestPath: h.manifestPath,
|
|
2143
|
+
configDir: h.dir,
|
|
2144
|
+
issuer: "https://hub.example",
|
|
2145
|
+
registry: getDefaultOperationsRegistry(),
|
|
2146
|
+
});
|
|
2147
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2148
|
+
const post = await handleSetupVaultPost(
|
|
2149
|
+
req("/admin/setup/vault", {
|
|
2150
|
+
method: "POST",
|
|
2151
|
+
body: new URLSearchParams({
|
|
2152
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2153
|
+
vault_name: "BAD-NAME",
|
|
2154
|
+
}).toString(),
|
|
2155
|
+
headers: {
|
|
2156
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2157
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
2158
|
+
},
|
|
2159
|
+
}),
|
|
2160
|
+
{
|
|
2161
|
+
db,
|
|
2162
|
+
manifestPath: h.manifestPath,
|
|
2163
|
+
configDir: h.dir,
|
|
2164
|
+
issuer: "https://hub.example",
|
|
2165
|
+
supervisor: makeSupervisor(),
|
|
2166
|
+
registry: getDefaultOperationsRegistry(),
|
|
2167
|
+
},
|
|
2168
|
+
);
|
|
2169
|
+
expect(post.status).toBe(400);
|
|
2170
|
+
const html = await post.text();
|
|
2171
|
+
expect(html).toContain("lowercase alphanumeric");
|
|
2172
|
+
expect(html).toContain('value="BAD-NAME"');
|
|
2173
|
+
expect(getSetting(db, "setup_vault_name")).toBeUndefined();
|
|
2174
|
+
} finally {
|
|
2175
|
+
db.close();
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
|
|
2180
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2181
|
+
try {
|
|
2182
|
+
const user = await createUser(db, "owner", "pw");
|
|
2183
|
+
const { createSession } = await import("../sessions.ts");
|
|
2184
|
+
const session = createSession(db, { userId: user.id });
|
|
2185
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
2186
|
+
db,
|
|
2187
|
+
manifestPath: h.manifestPath,
|
|
2188
|
+
configDir: h.dir,
|
|
2189
|
+
issuer: "https://hub.example",
|
|
2190
|
+
registry: getDefaultOperationsRegistry(),
|
|
2191
|
+
});
|
|
2192
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
2193
|
+
const spawnRequests: Array<{
|
|
2194
|
+
short: string;
|
|
2195
|
+
env?: Record<string, string>;
|
|
2196
|
+
}> = [];
|
|
2197
|
+
const supervisor = new Supervisor({
|
|
2198
|
+
output: () => {},
|
|
2199
|
+
spawnFn: (sreq) => {
|
|
2200
|
+
spawnRequests.push({
|
|
2201
|
+
short: sreq.short,
|
|
2202
|
+
...(sreq.env ? { env: sreq.env } : {}),
|
|
2203
|
+
});
|
|
2204
|
+
return {
|
|
2205
|
+
pid: 33333,
|
|
2206
|
+
exited: new Promise<number | null>(() => {}),
|
|
2207
|
+
stdout: null,
|
|
2208
|
+
stderr: null,
|
|
2209
|
+
kill: () => {},
|
|
2210
|
+
};
|
|
2211
|
+
},
|
|
2212
|
+
});
|
|
2213
|
+
const post = await handleSetupVaultPost(
|
|
2214
|
+
req("/admin/setup/vault", {
|
|
2215
|
+
method: "POST",
|
|
2216
|
+
body: new URLSearchParams({
|
|
2217
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
2218
|
+
vault_name: "",
|
|
2219
|
+
}).toString(),
|
|
2220
|
+
headers: {
|
|
2221
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
2222
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
|
|
2223
|
+
},
|
|
2224
|
+
}),
|
|
2225
|
+
{
|
|
2226
|
+
db,
|
|
2227
|
+
manifestPath: h.manifestPath,
|
|
2228
|
+
configDir: h.dir,
|
|
2229
|
+
issuer: "https://hub.example",
|
|
2230
|
+
supervisor,
|
|
2231
|
+
registry: getDefaultOperationsRegistry(),
|
|
2232
|
+
run: async () => 0,
|
|
2233
|
+
},
|
|
2234
|
+
);
|
|
2235
|
+
expect(post.status).toBe(303);
|
|
2236
|
+
expect(getSetting(db, "setup_vault_name")).toBe("default");
|
|
2237
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2238
|
+
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2239
|
+
expect(vaultSpawn).toBeDefined();
|
|
2240
|
+
// No env override on the default-name path (vault's
|
|
2241
|
+
// resolveFirstBootVaultName already defaults to "default" when the
|
|
2242
|
+
// env var is absent, so the override would be redundant).
|
|
2243
|
+
expect(vaultSpawn?.env).toBeUndefined();
|
|
2244
|
+
} finally {
|
|
2245
|
+
db.close();
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("done screen surfaces the typed name in the MCP command", async () => {
|
|
2250
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
2251
|
+
try {
|
|
2252
|
+
const user = await createUser(db, "owner", "pw");
|
|
2253
|
+
writeManifest(
|
|
2254
|
+
{
|
|
2255
|
+
services: [
|
|
2256
|
+
{
|
|
2257
|
+
name: "parachute-vault",
|
|
2258
|
+
version: "0.1.0",
|
|
2259
|
+
port: 1940,
|
|
2260
|
+
paths: ["/vault/default"],
|
|
2261
|
+
health: "/health",
|
|
2262
|
+
},
|
|
2263
|
+
],
|
|
2264
|
+
},
|
|
2265
|
+
h.manifestPath,
|
|
2266
|
+
);
|
|
2267
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
2268
|
+
setSetting(db, "setup_vault_name", "my-personal-vault");
|
|
2269
|
+
const { createSession } = await import("../sessions.ts");
|
|
2270
|
+
const session = createSession(db, { userId: user.id });
|
|
2271
|
+
const res = handleSetupGet(
|
|
2272
|
+
req("/admin/setup?just_finished=1", {
|
|
2273
|
+
headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
|
|
2274
|
+
}),
|
|
2275
|
+
{
|
|
2276
|
+
db,
|
|
2277
|
+
manifestPath: h.manifestPath,
|
|
2278
|
+
configDir: h.dir,
|
|
2279
|
+
issuer: "https://hub.example",
|
|
2280
|
+
registry: getDefaultOperationsRegistry(),
|
|
2281
|
+
},
|
|
2282
|
+
);
|
|
2283
|
+
const html = await res.text();
|
|
2284
|
+
expect(html).toContain("parachute-my-personal-vault");
|
|
2285
|
+
expect(html).toContain("/vault/my-personal-vault/mcp");
|
|
2286
|
+
} finally {
|
|
2287
|
+
db.close();
|
|
2288
|
+
}
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
test("vault step pre-fills the prior typed value after a validation error", async () => {
|
|
2292
|
+
const { renderVaultStep } = await import("../setup-wizard.ts");
|
|
2293
|
+
const html = renderVaultStep({
|
|
2294
|
+
csrfToken: "csrf-test",
|
|
2295
|
+
vaultName: "BAD",
|
|
2296
|
+
errorMessage: "vault names must be lowercase alphanumeric with hyphens or underscores.",
|
|
2297
|
+
});
|
|
2298
|
+
expect(html).toContain('value="BAD"');
|
|
2299
|
+
expect(html).toContain("lowercase alphanumeric");
|
|
2300
|
+
expect(html).toContain('id="preview-vault-name">BAD<');
|
|
2301
|
+
});
|
|
2302
|
+
});
|