@openparachute/hub 0.5.7 → 0.5.10-rc.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.
Files changed (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,2089 @@
1
+ /**
2
+ * First-boot wizard (hub#259). Exercises the three-step server-rendered
3
+ * flow end-to-end at the `hubFetch` layer:
4
+ *
5
+ * 1. GET /admin/setup → account-step form
6
+ * 2. POST /admin/setup/account → admin row created + session cookie set
7
+ * 3. GET /admin/setup → vault-step form (resume)
8
+ * 4. POST /admin/setup/vault → install op enqueued, 303 to ?op=…
9
+ * 5. GET /admin/setup?op=<id> → op-poll page (with stubbed install runner)
10
+ * 6. GET /admin/setup → 301 to /login once both exist
11
+ *
12
+ * The install runner is stubbed via the operations registry +
13
+ * `run` injection so tests don't actually shell out to `bun add`. Same
14
+ * pattern api-modules-ops.test.ts uses.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import {
22
+ _resetOperationsRegistryForTests,
23
+ getDefaultOperationsRegistry,
24
+ } from "../api-modules-ops.ts";
25
+ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
26
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
27
+ import { hubFetch } from "../hub-server.ts";
28
+ import { getSetting, setSetting } from "../hub-settings.ts";
29
+ import { writeManifest } from "../services-manifest.ts";
30
+ import { SESSION_COOKIE_NAME } from "../sessions.ts";
31
+ import {
32
+ deriveWizardState,
33
+ handleSetupAccountPost,
34
+ handleSetupExposePost,
35
+ handleSetupGet,
36
+ handleSetupInstallPost,
37
+ handleSetupVaultPost,
38
+ } from "../setup-wizard.ts";
39
+ import { Supervisor } from "../supervisor.ts";
40
+ import { createUser, userCount } from "../users.ts";
41
+
42
+ interface Harness {
43
+ dir: string;
44
+ manifestPath: string;
45
+ cleanup: () => void;
46
+ }
47
+
48
+ function makeHarness(): Harness {
49
+ const dir = mkdtempSync(join(tmpdir(), "setup-wizard-"));
50
+ writeFileSync(join(dir, "hub.html"), "<html>discovery</html>");
51
+ const manifestPath = join(dir, "services.json");
52
+ writeManifest({ services: [] }, manifestPath);
53
+ return {
54
+ dir,
55
+ manifestPath,
56
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
57
+ };
58
+ }
59
+
60
+ function req(path: string, init: RequestInit = {}): Request {
61
+ return new Request(`http://127.0.0.1:1939${path}`, init);
62
+ }
63
+
64
+ function makeSupervisor(): Supervisor {
65
+ // Spawn-stub: never actually starts a child. Tests inject this so the
66
+ // wizard's runInstall path can call supervisor.start() / .get() without
67
+ // touching Bun.spawn. The returned proc shape mirrors what
68
+ // supervisor.ts's `SupervisedProc` requires — never-resolving `exited`
69
+ // promise (so the supervisor doesn't trigger the restart loop) plus
70
+ // null stdio (we're not piping output anywhere).
71
+ return new Supervisor({
72
+ output: () => {}, // swallow line prefixes in tests
73
+ spawnFn: () => ({
74
+ pid: 12345,
75
+ exited: new Promise<number | null>(() => {}),
76
+ stdout: null,
77
+ stderr: null,
78
+ kill: () => {},
79
+ }),
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Extract a single cookie's value from a Set-Cookie header. Tests need
85
+ * the session id to ride the next request and the csrf token to feed
86
+ * into form posts.
87
+ */
88
+ function setCookie(res: Response, name: string): string | undefined {
89
+ const raw = res.headers.get("set-cookie");
90
+ if (!raw) return undefined;
91
+ // Bun joins multiple Set-Cookie values with commas, with each cookie
92
+ // prefixed by `<name>=<value>`. Naively splitting on commas would
93
+ // break a cookie's own value (eg `expires=Mon, 01 Jan…`), so match
94
+ // on the cookie name + value greedily up to the next `;` or end.
95
+ const re = new RegExp(`(?:^|, )${name}=([^;]+)`);
96
+ const m = raw.match(re);
97
+ return m?.[1];
98
+ }
99
+
100
+ function formBody(fields: Record<string, string>): {
101
+ body: string;
102
+ headers: Record<string, string>;
103
+ } {
104
+ const params = new URLSearchParams();
105
+ for (const [k, v] of Object.entries(fields)) params.set(k, v);
106
+ return {
107
+ body: params.toString(),
108
+ headers: { "content-type": "application/x-www-form-urlencoded" },
109
+ };
110
+ }
111
+
112
+ // --- pure state derivation -----------------------------------------------
113
+
114
+ describe("deriveWizardState", () => {
115
+ let h: Harness;
116
+ beforeEach(() => {
117
+ h = makeHarness();
118
+ _resetOperationsRegistryForTests();
119
+ });
120
+ afterEach(() => h.cleanup());
121
+
122
+ test("welcome step when no admin and no vault", () => {
123
+ const db = openHubDb(hubDbPath(h.dir));
124
+ try {
125
+ const s = deriveWizardState({ db, manifestPath: h.manifestPath });
126
+ expect(s.step).toBe("welcome");
127
+ expect(s.hasAdmin).toBe(false);
128
+ expect(s.hasVault).toBe(false);
129
+ } finally {
130
+ db.close();
131
+ }
132
+ });
133
+
134
+ test("vault step when admin exists but vault doesn't", async () => {
135
+ const db = openHubDb(hubDbPath(h.dir));
136
+ try {
137
+ await createUser(db, "owner", "pw");
138
+ const s = deriveWizardState({ db, manifestPath: h.manifestPath });
139
+ expect(s.step).toBe("vault");
140
+ expect(s.hasAdmin).toBe(true);
141
+ expect(s.hasVault).toBe(false);
142
+ } finally {
143
+ db.close();
144
+ }
145
+ });
146
+
147
+ test("expose step when admin + vault exist but expose mode not set yet (hub#268 Item 2)", async () => {
148
+ const db = openHubDb(hubDbPath(h.dir));
149
+ try {
150
+ await createUser(db, "owner", "pw");
151
+ writeManifest(
152
+ {
153
+ services: [
154
+ {
155
+ name: "parachute-vault",
156
+ version: "0.1.0",
157
+ port: 1940,
158
+ paths: ["/vault/default"],
159
+ health: "/health",
160
+ },
161
+ ],
162
+ },
163
+ h.manifestPath,
164
+ );
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 });
195
+ expect(s.step).toBe("done");
196
+ expect(s.hasAdmin).toBe(true);
197
+ expect(s.hasVault).toBe(true);
198
+ expect(s.hasExposeMode).toBe(true);
199
+ } finally {
200
+ db.close();
201
+ }
202
+ });
203
+ });
204
+
205
+ // --- GET /admin/setup ----------------------------------------------------
206
+
207
+ describe("handleSetupGet", () => {
208
+ let h: Harness;
209
+ beforeEach(() => {
210
+ h = makeHarness();
211
+ _resetOperationsRegistryForTests();
212
+ });
213
+ afterEach(() => h.cleanup());
214
+
215
+ test("renders the account form with a CSRF token cookie on first visit", () => {
216
+ const db = openHubDb(hubDbPath(h.dir));
217
+ try {
218
+ const res = handleSetupGet(req("/admin/setup"), {
219
+ db,
220
+ manifestPath: h.manifestPath,
221
+ configDir: h.dir,
222
+ issuer: "https://hub.example",
223
+ registry: getDefaultOperationsRegistry(),
224
+ });
225
+ expect(res.status).toBe(200);
226
+ expect(res.headers.get("content-type")).toContain("text/html");
227
+ // CSRF cookie minted on first GET.
228
+ const csrf = setCookie(res, CSRF_COOKIE_NAME);
229
+ expect(csrf).toBeDefined();
230
+ } finally {
231
+ db.close();
232
+ }
233
+ });
234
+
235
+ test("renders the vault form with a vault-name input once admin exists (hub#267)", async () => {
236
+ const db = openHubDb(hubDbPath(h.dir));
237
+ try {
238
+ await createUser(db, "owner", "pw");
239
+ const res = handleSetupGet(req("/admin/setup"), {
240
+ db,
241
+ manifestPath: h.manifestPath,
242
+ configDir: h.dir,
243
+ issuer: "https://hub.example",
244
+ registry: getDefaultOperationsRegistry(),
245
+ });
246
+ expect(res.status).toBe(200);
247
+ const html = await res.text();
248
+ expect(html).toContain('action="/admin/setup/vault"');
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"');
255
+ expect(html).toContain('id="preview-vault-name">default<');
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"');
262
+ } finally {
263
+ db.close();
264
+ }
265
+ });
266
+
267
+ test("301s to /login once admin + vault + expose mode all exist", async () => {
268
+ const db = openHubDb(hubDbPath(h.dir));
269
+ try {
270
+ await createUser(db, "owner", "pw");
271
+ writeManifest(
272
+ {
273
+ services: [
274
+ {
275
+ name: "parachute-vault",
276
+ version: "0.1.0",
277
+ port: 1940,
278
+ paths: ["/vault/default"],
279
+ health: "/health",
280
+ },
281
+ ],
282
+ },
283
+ h.manifestPath,
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");
289
+ const res = handleSetupGet(req("/admin/setup"), {
290
+ db,
291
+ manifestPath: h.manifestPath,
292
+ configDir: h.dir,
293
+ issuer: "https://hub.example",
294
+ registry: getDefaultOperationsRegistry(),
295
+ });
296
+ expect(res.status).toBe(301);
297
+ expect(res.headers.get("location")).toBe("/login");
298
+ } finally {
299
+ db.close();
300
+ }
301
+ });
302
+
303
+ test("renders the expose step when admin + vault exist but no expose mode (hub#268 Item 2)", async () => {
304
+ const db = openHubDb(hubDbPath(h.dir));
305
+ try {
306
+ await createUser(db, "owner", "pw");
307
+ writeManifest(
308
+ {
309
+ services: [
310
+ {
311
+ name: "parachute-vault",
312
+ version: "0.1.0",
313
+ port: 1940,
314
+ paths: ["/vault/default"],
315
+ health: "/health",
316
+ },
317
+ ],
318
+ },
319
+ h.manifestPath,
320
+ );
321
+ const res = handleSetupGet(req("/admin/setup"), {
322
+ db,
323
+ manifestPath: h.manifestPath,
324
+ configDir: h.dir,
325
+ issuer: "https://hub.example",
326
+ registry: getDefaultOperationsRegistry(),
327
+ });
328
+ expect(res.status).toBe(200);
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();
379
+ expect(html).toContain("You're set up");
380
+ // The success page surfaces the vault name from services.json so
381
+ // the MCP install line carries the operator's actual choice.
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");
471
+ } finally {
472
+ db.close();
473
+ }
474
+ });
475
+
476
+ test("renders the op-poll page when ?op=<id> matches a tracked op", async () => {
477
+ const db = openHubDb(hubDbPath(h.dir));
478
+ try {
479
+ await createUser(db, "owner", "pw");
480
+ const reg = getDefaultOperationsRegistry();
481
+ const op = reg.create("install", "vault");
482
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
483
+ const res = handleSetupGet(req(`/admin/setup?op=${op.id}`), {
484
+ db,
485
+ manifestPath: h.manifestPath,
486
+ configDir: h.dir,
487
+ issuer: "https://hub.example",
488
+ registry: reg,
489
+ });
490
+ expect(res.status).toBe(200);
491
+ const html = await res.text();
492
+ expect(html).toContain("status: running");
493
+ expect(html).toContain("running bun add");
494
+ // Auto-refresh is wired so the browser polls without JS.
495
+ expect(html).toContain('http-equiv="refresh"');
496
+ } finally {
497
+ db.close();
498
+ }
499
+ });
500
+
501
+ test("succeeded-op page refreshes to /admin/setup?just_finished=1 (fold A)", async () => {
502
+ // Regression — without the `?just_finished=1` query the bare
503
+ // /admin/setup state derives as "done" and 301s to /login, so the
504
+ // operator never sees the success screen with the MCP install
505
+ // command. The refresh-meta must carry the query through.
506
+ const db = openHubDb(hubDbPath(h.dir));
507
+ try {
508
+ await createUser(db, "owner", "pw");
509
+ const reg = getDefaultOperationsRegistry();
510
+ const op = reg.create("install", "vault");
511
+ reg.update(op.id, { status: "succeeded" }, "vault installed + spawned");
512
+ const res = handleSetupGet(req(`/admin/setup?op=${op.id}`), {
513
+ db,
514
+ manifestPath: h.manifestPath,
515
+ configDir: h.dir,
516
+ issuer: "https://hub.example",
517
+ registry: reg,
518
+ });
519
+ expect(res.status).toBe(200);
520
+ const html = await res.text();
521
+ expect(html).toContain("Vault ready");
522
+ expect(html).toContain("url=/admin/setup?just_finished=1");
523
+ } finally {
524
+ db.close();
525
+ }
526
+ });
527
+ });
528
+
529
+ // --- POST /admin/setup/account -------------------------------------------
530
+
531
+ describe("handleSetupAccountPost", () => {
532
+ let h: Harness;
533
+ beforeEach(() => {
534
+ h = makeHarness();
535
+ _resetOperationsRegistryForTests();
536
+ });
537
+ afterEach(() => h.cleanup());
538
+
539
+ test("creates the admin row + sets session cookie on valid input", async () => {
540
+ const db = openHubDb(hubDbPath(h.dir));
541
+ try {
542
+ // Mint the CSRF token via a GET first so the cookie is in place.
543
+ const get = handleSetupGet(req("/admin/setup"), {
544
+ db,
545
+ manifestPath: h.manifestPath,
546
+ configDir: h.dir,
547
+ issuer: "https://hub.example",
548
+ registry: getDefaultOperationsRegistry(),
549
+ });
550
+ const csrf = setCookie(get, CSRF_COOKIE_NAME);
551
+ expect(csrf).toBeDefined();
552
+ const form = formBody({
553
+ username: "ops",
554
+ password: "correct horse battery",
555
+ password_confirm: "correct horse battery",
556
+ [CSRF_FIELD_NAME]: csrf ?? "",
557
+ });
558
+ const post = await handleSetupAccountPost(
559
+ req("/admin/setup/account", {
560
+ method: "POST",
561
+ body: form.body,
562
+ headers: {
563
+ ...form.headers,
564
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
565
+ },
566
+ }),
567
+ {
568
+ db,
569
+ manifestPath: h.manifestPath,
570
+ configDir: h.dir,
571
+ issuer: "https://hub.example",
572
+ registry: getDefaultOperationsRegistry(),
573
+ },
574
+ );
575
+ expect(post.status).toBe(303);
576
+ expect(post.headers.get("location")).toBe("/admin/setup");
577
+ const sessionCookie = setCookie(post, SESSION_COOKIE_NAME);
578
+ expect(sessionCookie).toBeDefined();
579
+ expect(userCount(db)).toBe(1);
580
+ } finally {
581
+ db.close();
582
+ }
583
+ });
584
+
585
+ test("rejects mismatched password confirmation", async () => {
586
+ const db = openHubDb(hubDbPath(h.dir));
587
+ try {
588
+ const get = handleSetupGet(req("/admin/setup"), {
589
+ db,
590
+ manifestPath: h.manifestPath,
591
+ configDir: h.dir,
592
+ issuer: "https://hub.example",
593
+ registry: getDefaultOperationsRegistry(),
594
+ });
595
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
596
+ const form = formBody({
597
+ username: "ops",
598
+ password: "correct horse battery",
599
+ password_confirm: "wrong",
600
+ [CSRF_FIELD_NAME]: csrf,
601
+ });
602
+ const post = await handleSetupAccountPost(
603
+ req("/admin/setup/account", {
604
+ method: "POST",
605
+ body: form.body,
606
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
607
+ }),
608
+ {
609
+ db,
610
+ manifestPath: h.manifestPath,
611
+ configDir: h.dir,
612
+ issuer: "https://hub.example",
613
+ registry: getDefaultOperationsRegistry(),
614
+ },
615
+ );
616
+ expect(post.status).toBe(400);
617
+ const html = await post.text();
618
+ expect(html).toContain("Passwords do not match");
619
+ expect(userCount(db)).toBe(0);
620
+ } finally {
621
+ db.close();
622
+ }
623
+ });
624
+
625
+ test("rejects missing or wrong CSRF token", async () => {
626
+ const db = openHubDb(hubDbPath(h.dir));
627
+ try {
628
+ const form = formBody({
629
+ username: "ops",
630
+ password: "correct horse battery",
631
+ password_confirm: "correct horse battery",
632
+ [CSRF_FIELD_NAME]: "wrong",
633
+ });
634
+ const post = await handleSetupAccountPost(
635
+ req("/admin/setup/account", {
636
+ method: "POST",
637
+ body: form.body,
638
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=expected` },
639
+ }),
640
+ {
641
+ db,
642
+ manifestPath: h.manifestPath,
643
+ configDir: h.dir,
644
+ issuer: "https://hub.example",
645
+ registry: getDefaultOperationsRegistry(),
646
+ },
647
+ );
648
+ expect(post.status).toBe(400);
649
+ expect(userCount(db)).toBe(0);
650
+ } finally {
651
+ db.close();
652
+ }
653
+ });
654
+
655
+ test("redirects without creating a second user when one already exists", async () => {
656
+ const db = openHubDb(hubDbPath(h.dir));
657
+ try {
658
+ await createUser(db, "owner", "pw");
659
+ const get = handleSetupGet(req("/admin/setup"), {
660
+ db,
661
+ manifestPath: h.manifestPath,
662
+ configDir: h.dir,
663
+ issuer: "https://hub.example",
664
+ registry: getDefaultOperationsRegistry(),
665
+ });
666
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
667
+ const form = formBody({
668
+ username: "interloper",
669
+ password: "another password",
670
+ password_confirm: "another password",
671
+ [CSRF_FIELD_NAME]: csrf,
672
+ });
673
+ const post = await handleSetupAccountPost(
674
+ req("/admin/setup/account", {
675
+ method: "POST",
676
+ body: form.body,
677
+ headers: { ...form.headers, cookie: `${CSRF_COOKIE_NAME}=${csrf}` },
678
+ }),
679
+ {
680
+ db,
681
+ manifestPath: h.manifestPath,
682
+ configDir: h.dir,
683
+ issuer: "https://hub.example",
684
+ registry: getDefaultOperationsRegistry(),
685
+ },
686
+ );
687
+ expect(post.status).toBe(303);
688
+ expect(post.headers.get("location")).toBe("/admin/setup");
689
+ // Idempotent — no second user got minted.
690
+ expect(userCount(db)).toBe(1);
691
+ } finally {
692
+ db.close();
693
+ }
694
+ });
695
+ });
696
+
697
+ // --- POST /admin/setup/vault ---------------------------------------------
698
+
699
+ describe("handleSetupVaultPost", () => {
700
+ let h: Harness;
701
+ beforeEach(() => {
702
+ h = makeHarness();
703
+ _resetOperationsRegistryForTests();
704
+ });
705
+ afterEach(() => h.cleanup());
706
+
707
+ test("requires a supervisor (CLI mode rejects)", async () => {
708
+ const db = openHubDb(hubDbPath(h.dir));
709
+ try {
710
+ await createUser(db, "owner", "pw");
711
+ const post = await handleSetupVaultPost(
712
+ req("/admin/setup/vault", {
713
+ method: "POST",
714
+ body: new URLSearchParams({}).toString(),
715
+ headers: { "content-type": "application/x-www-form-urlencoded" },
716
+ }),
717
+ {
718
+ db,
719
+ manifestPath: h.manifestPath,
720
+ configDir: h.dir,
721
+ issuer: "https://hub.example",
722
+ registry: getDefaultOperationsRegistry(),
723
+ },
724
+ );
725
+ expect(post.status).toBe(400);
726
+ const html = await post.text();
727
+ expect(html).toContain("supervisor unavailable");
728
+ } finally {
729
+ db.close();
730
+ }
731
+ });
732
+
733
+ test("rejects without an admin session cookie", async () => {
734
+ const db = openHubDb(hubDbPath(h.dir));
735
+ try {
736
+ await createUser(db, "owner", "pw");
737
+ const get = handleSetupGet(req("/admin/setup"), {
738
+ db,
739
+ manifestPath: h.manifestPath,
740
+ configDir: h.dir,
741
+ issuer: "https://hub.example",
742
+ registry: getDefaultOperationsRegistry(),
743
+ });
744
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
745
+ const post = await handleSetupVaultPost(
746
+ req("/admin/setup/vault", {
747
+ method: "POST",
748
+ body: new URLSearchParams({
749
+ [CSRF_FIELD_NAME]: csrf,
750
+ }).toString(),
751
+ headers: {
752
+ "content-type": "application/x-www-form-urlencoded",
753
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
754
+ },
755
+ }),
756
+ {
757
+ db,
758
+ manifestPath: h.manifestPath,
759
+ configDir: h.dir,
760
+ issuer: "https://hub.example",
761
+ supervisor: makeSupervisor(),
762
+ registry: getDefaultOperationsRegistry(),
763
+ },
764
+ );
765
+ expect(post.status).toBe(400);
766
+ const html = await post.text();
767
+ expect(html).toContain("No admin session");
768
+ } finally {
769
+ db.close();
770
+ }
771
+ });
772
+
773
+ test("enqueues an install op + redirects to ?op=<id> on valid post", async () => {
774
+ const db = openHubDb(hubDbPath(h.dir));
775
+ try {
776
+ // Stand up admin + an active session row so the wizard's session
777
+ // gate sees a real cookie.
778
+ const user = await createUser(db, "owner", "pw");
779
+ const {
780
+ createSession,
781
+ SESSION_COOKIE_NAME: SC,
782
+ SESSION_TTL_MS,
783
+ } = await import("../sessions.ts");
784
+ const session = createSession(db, { userId: user.id });
785
+ const get = handleSetupGet(req("/admin/setup"), {
786
+ db,
787
+ manifestPath: h.manifestPath,
788
+ configDir: h.dir,
789
+ issuer: "https://hub.example",
790
+ registry: getDefaultOperationsRegistry(),
791
+ });
792
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
793
+ const runCalls: string[][] = [];
794
+ const stubbedRun = async (cmd: readonly string[]) => {
795
+ runCalls.push([...cmd]);
796
+ return 0;
797
+ };
798
+ const post = await handleSetupVaultPost(
799
+ req("/admin/setup/vault", {
800
+ method: "POST",
801
+ body: new URLSearchParams({
802
+ [CSRF_FIELD_NAME]: csrf,
803
+ }).toString(),
804
+ headers: {
805
+ "content-type": "application/x-www-form-urlencoded",
806
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
807
+ },
808
+ }),
809
+ {
810
+ db,
811
+ manifestPath: h.manifestPath,
812
+ configDir: h.dir,
813
+ issuer: "https://hub.example",
814
+ supervisor: makeSupervisor(),
815
+ registry: getDefaultOperationsRegistry(),
816
+ run: stubbedRun,
817
+ },
818
+ );
819
+ expect(post.status).toBe(303);
820
+ const location = post.headers.get("location") ?? "";
821
+ expect(location).toMatch(/^\/admin\/setup\?op=/);
822
+ // Yield long enough for the background runInstall promise to call
823
+ // through to the stubbed runner. The stub itself is synchronous
824
+ // (returns a resolved promise) so one microtask tick is enough,
825
+ // but the runInstall body has a few awaits before reaching it.
826
+ await new Promise((r) => setTimeout(r, 50));
827
+ expect(runCalls.length).toBeGreaterThan(0);
828
+ expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/vault@latest");
829
+ // Sanity-check on SESSION_TTL_MS: we used it implicitly to keep
830
+ // the freshly-created session non-expired. Asserting a positive
831
+ // number flags a future migration that removes the constant.
832
+ expect(SESSION_TTL_MS).toBeGreaterThan(0);
833
+ } finally {
834
+ db.close();
835
+ }
836
+ });
837
+
838
+ test("idempotent — second POST while supervisor is running doesn't fire a second `bun add` (N2)", async () => {
839
+ // Reviewer-flagged race: two concurrent POSTs before either seeds
840
+ // services.json both pass `state.hasVault === false` and each fire
841
+ // `runInstall` → each fires `bun add -g`. The wizard mirrors the
842
+ // `handleInstall` guard pattern: if the supervisor already has a
843
+ // live (starting/running/restarting) state for vault, mark the new
844
+ // op succeeded synchronously and skip the second install.
845
+ const db = openHubDb(hubDbPath(h.dir));
846
+ try {
847
+ const user = await createUser(db, "owner", "pw");
848
+ const { createSession, SESSION_COOKIE_NAME: SC } = await import("../sessions.ts");
849
+ const session = createSession(db, { userId: user.id });
850
+ const get = handleSetupGet(req("/admin/setup"), {
851
+ db,
852
+ manifestPath: h.manifestPath,
853
+ configDir: h.dir,
854
+ issuer: "https://hub.example",
855
+ registry: getDefaultOperationsRegistry(),
856
+ });
857
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
858
+ // Real supervisor with the never-exits spawn stub from makeSupervisor.
859
+ // Pre-spawn vault so `supervisor.get("vault").status === "starting"`
860
+ // by the time the wizard's POST runs.
861
+ const supervisor = makeSupervisor();
862
+ await supervisor.start({ short: "vault", cmd: ["bun", "noop"] });
863
+ const runCalls: string[][] = [];
864
+ const stubbedRun = async (cmd: readonly string[]) => {
865
+ runCalls.push([...cmd]);
866
+ return 0;
867
+ };
868
+ const post = await handleSetupVaultPost(
869
+ req("/admin/setup/vault", {
870
+ method: "POST",
871
+ body: new URLSearchParams({
872
+ [CSRF_FIELD_NAME]: csrf,
873
+ }).toString(),
874
+ headers: {
875
+ "content-type": "application/x-www-form-urlencoded",
876
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
877
+ },
878
+ }),
879
+ {
880
+ db,
881
+ manifestPath: h.manifestPath,
882
+ configDir: h.dir,
883
+ issuer: "https://hub.example",
884
+ supervisor,
885
+ registry: getDefaultOperationsRegistry(),
886
+ run: stubbedRun,
887
+ },
888
+ );
889
+ expect(post.status).toBe(303);
890
+ const location = post.headers.get("location") ?? "";
891
+ expect(location).toMatch(/^\/admin\/setup\?op=/);
892
+ // Yield enough for any background runInstall promise to fire if
893
+ // the guard failed. Then assert: no `bun add` was invoked, and
894
+ // the op went straight to `succeeded` with the canonical
895
+ // "already supervised" log line.
896
+ await new Promise((r) => setTimeout(r, 50));
897
+ expect(runCalls.length).toBe(0);
898
+ const opId = new URL(location, "http://x").searchParams.get("op") ?? "";
899
+ const op = getDefaultOperationsRegistry().get(opId);
900
+ expect(op?.status).toBe("succeeded");
901
+ expect(op?.log.join("\n")).toContain("already supervised");
902
+ } finally {
903
+ db.close();
904
+ }
905
+ });
906
+ });
907
+
908
+ // --- end-to-end through hubFetch -----------------------------------------
909
+
910
+ describe("setup wizard end-to-end via hubFetch", () => {
911
+ let h: Harness;
912
+ beforeEach(() => {
913
+ h = makeHarness();
914
+ _resetOperationsRegistryForTests();
915
+ });
916
+ afterEach(() => h.cleanup());
917
+
918
+ test("redirects to /login once admin + vault + expose mode are all set", async () => {
919
+ const db = openHubDb(hubDbPath(h.dir));
920
+ try {
921
+ await createUser(db, "owner", "pw");
922
+ writeManifest(
923
+ {
924
+ services: [
925
+ {
926
+ name: "parachute-vault",
927
+ version: "0.1.0",
928
+ port: 1940,
929
+ paths: ["/vault/default"],
930
+ health: "/health",
931
+ },
932
+ ],
933
+ },
934
+ h.manifestPath,
935
+ );
936
+ setSetting(db, "setup_expose_mode", "localhost");
937
+ const res = await hubFetch(h.dir, {
938
+ getDb: () => db,
939
+ manifestPath: h.manifestPath,
940
+ })(req("/admin/setup"));
941
+ expect(res.status).toBe(301);
942
+ expect(res.headers.get("location")).toBe("/login");
943
+ } finally {
944
+ db.close();
945
+ }
946
+ });
947
+
948
+ test("POST /admin/setup/account through hubFetch creates the admin row", async () => {
949
+ const db = openHubDb(hubDbPath(h.dir));
950
+ try {
951
+ // Bootstrap CSRF cookie via GET.
952
+ const getRes = await hubFetch(h.dir, {
953
+ getDb: () => db,
954
+ manifestPath: h.manifestPath,
955
+ })(req("/admin/setup"));
956
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
957
+ expect(csrf).not.toBe("");
958
+ const body = new URLSearchParams({
959
+ username: "ops",
960
+ password: "correct horse",
961
+ password_confirm: "correct horse",
962
+ [CSRF_FIELD_NAME]: csrf,
963
+ }).toString();
964
+ const postRes = await hubFetch(h.dir, {
965
+ getDb: () => db,
966
+ manifestPath: h.manifestPath,
967
+ })(
968
+ req("/admin/setup/account", {
969
+ method: "POST",
970
+ body,
971
+ headers: {
972
+ "content-type": "application/x-www-form-urlencoded",
973
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
974
+ },
975
+ }),
976
+ );
977
+ expect(postRes.status).toBe(303);
978
+ expect(postRes.headers.get("location")).toBe("/admin/setup");
979
+ expect(userCount(db)).toBe(1);
980
+ } finally {
981
+ db.close();
982
+ }
983
+ });
984
+
985
+ test("POST /admin/setup/account rejects non-POST methods", async () => {
986
+ const db = openHubDb(hubDbPath(h.dir));
987
+ try {
988
+ const res = await hubFetch(h.dir, {
989
+ getDb: () => db,
990
+ manifestPath: h.manifestPath,
991
+ })(req("/admin/setup/account"));
992
+ expect(res.status).toBe(405);
993
+ } finally {
994
+ db.close();
995
+ }
996
+ });
997
+ });
998
+
999
+ // --- POST /admin/setup/expose (hub#268 Item 2 + Item 3) ------------------
1000
+
1001
+ describe("handleSetupExposePost", () => {
1002
+ let h: Harness;
1003
+ beforeEach(() => {
1004
+ h = makeHarness();
1005
+ _resetOperationsRegistryForTests();
1006
+ });
1007
+ afterEach(() => h.cleanup());
1008
+
1009
+ /**
1010
+ * Helper: bring the wizard to step 4 (expose). Creates an admin row,
1011
+ * seeds the vault entry, mints a session cookie + CSRF token. Returns
1012
+ * everything callers need to drive the POST.
1013
+ */
1014
+ async function bringWizardToExposeStep(db: ReturnType<typeof openHubDb>) {
1015
+ const user = await createUser(db, "owner", "pw");
1016
+ writeManifest(
1017
+ {
1018
+ services: [
1019
+ {
1020
+ name: "parachute-vault",
1021
+ version: "0.1.0",
1022
+ port: 1940,
1023
+ paths: ["/vault/default"],
1024
+ health: "/health",
1025
+ },
1026
+ ],
1027
+ },
1028
+ h.manifestPath,
1029
+ );
1030
+ const { createSession } = await import("../sessions.ts");
1031
+ const session = createSession(db, { userId: user.id });
1032
+ // Get the wizard's expose step to mint the CSRF cookie.
1033
+ const get = handleSetupGet(req("/admin/setup"), {
1034
+ db,
1035
+ manifestPath: h.manifestPath,
1036
+ configDir: h.dir,
1037
+ issuer: "https://hub.example",
1038
+ registry: getDefaultOperationsRegistry(),
1039
+ });
1040
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1041
+ return { user, session, csrf };
1042
+ }
1043
+
1044
+ test("persists a valid expose_mode + opens the auto-approve window + redirects to ?just_finished=1", async () => {
1045
+ const db = openHubDb(hubDbPath(h.dir));
1046
+ try {
1047
+ const { session, csrf } = await bringWizardToExposeStep(db);
1048
+ const form = new URLSearchParams({
1049
+ expose_mode: "tailnet",
1050
+ [CSRF_FIELD_NAME]: csrf,
1051
+ }).toString();
1052
+ const res = await handleSetupExposePost(
1053
+ req("/admin/setup/expose", {
1054
+ method: "POST",
1055
+ body: form,
1056
+ headers: {
1057
+ "content-type": "application/x-www-form-urlencoded",
1058
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1059
+ },
1060
+ }),
1061
+ {
1062
+ db,
1063
+ manifestPath: h.manifestPath,
1064
+ configDir: h.dir,
1065
+ issuer: "https://hub.example",
1066
+ registry: getDefaultOperationsRegistry(),
1067
+ },
1068
+ );
1069
+ expect(res.status).toBe(303);
1070
+ expect(res.headers.get("location")).toBe("/admin/setup?just_finished=1");
1071
+ expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
1072
+ // hub#268 Item 3: the auto-approve window is opened on this transition.
1073
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
1074
+ } finally {
1075
+ db.close();
1076
+ }
1077
+ });
1078
+
1079
+ test("rejects an invalid expose_mode (renders the form with an error banner)", async () => {
1080
+ const db = openHubDb(hubDbPath(h.dir));
1081
+ try {
1082
+ const { session, csrf } = await bringWizardToExposeStep(db);
1083
+ const form = new URLSearchParams({
1084
+ expose_mode: "garbage",
1085
+ [CSRF_FIELD_NAME]: csrf,
1086
+ }).toString();
1087
+ const res = await handleSetupExposePost(
1088
+ req("/admin/setup/expose", {
1089
+ method: "POST",
1090
+ body: form,
1091
+ headers: {
1092
+ "content-type": "application/x-www-form-urlencoded",
1093
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1094
+ },
1095
+ }),
1096
+ {
1097
+ db,
1098
+ manifestPath: h.manifestPath,
1099
+ configDir: h.dir,
1100
+ issuer: "https://hub.example",
1101
+ registry: getDefaultOperationsRegistry(),
1102
+ },
1103
+ );
1104
+ expect(res.status).toBe(400);
1105
+ const html = await res.text();
1106
+ expect(html).toContain("Pick one of");
1107
+ // No expose-mode persisted on rejection.
1108
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
1109
+ // No auto-approve window opened on rejection.
1110
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
1111
+ } finally {
1112
+ db.close();
1113
+ }
1114
+ });
1115
+
1116
+ test("rejects without an admin session cookie", async () => {
1117
+ const db = openHubDb(hubDbPath(h.dir));
1118
+ try {
1119
+ const { csrf } = await bringWizardToExposeStep(db);
1120
+ const form = new URLSearchParams({
1121
+ expose_mode: "localhost",
1122
+ [CSRF_FIELD_NAME]: csrf,
1123
+ }).toString();
1124
+ // Note: no session cookie sent.
1125
+ const res = await handleSetupExposePost(
1126
+ req("/admin/setup/expose", {
1127
+ method: "POST",
1128
+ body: form,
1129
+ headers: {
1130
+ "content-type": "application/x-www-form-urlencoded",
1131
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
1132
+ },
1133
+ }),
1134
+ {
1135
+ db,
1136
+ manifestPath: h.manifestPath,
1137
+ configDir: h.dir,
1138
+ issuer: "https://hub.example",
1139
+ registry: getDefaultOperationsRegistry(),
1140
+ },
1141
+ );
1142
+ expect(res.status).toBe(400);
1143
+ const html = await res.text();
1144
+ expect(html).toContain("No admin session");
1145
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
1146
+ } finally {
1147
+ db.close();
1148
+ }
1149
+ });
1150
+
1151
+ test("rejects missing or wrong CSRF token", async () => {
1152
+ const db = openHubDb(hubDbPath(h.dir));
1153
+ try {
1154
+ const { session, csrf } = await bringWizardToExposeStep(db);
1155
+ const form = new URLSearchParams({
1156
+ expose_mode: "localhost",
1157
+ [CSRF_FIELD_NAME]: "wrong-token",
1158
+ }).toString();
1159
+ const res = await handleSetupExposePost(
1160
+ req("/admin/setup/expose", {
1161
+ method: "POST",
1162
+ body: form,
1163
+ headers: {
1164
+ "content-type": "application/x-www-form-urlencoded",
1165
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1166
+ },
1167
+ }),
1168
+ {
1169
+ db,
1170
+ manifestPath: h.manifestPath,
1171
+ configDir: h.dir,
1172
+ issuer: "https://hub.example",
1173
+ registry: getDefaultOperationsRegistry(),
1174
+ },
1175
+ );
1176
+ expect(res.status).toBe(400);
1177
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
1178
+ } finally {
1179
+ db.close();
1180
+ }
1181
+ });
1182
+
1183
+ test("idempotent: second POST after already done short-circuits without re-opening the window", async () => {
1184
+ const db = openHubDb(hubDbPath(h.dir));
1185
+ try {
1186
+ const { session, csrf } = await bringWizardToExposeStep(db);
1187
+ // Pre-seed expose_mode + an OLD window timestamp so we can verify
1188
+ // the second POST doesn't bump it.
1189
+ setSetting(db, "setup_expose_mode", "localhost");
1190
+ setSetting(db, "pending_first_client_auto_approve_until", "2020-01-01T00:00:00.000Z");
1191
+ const form = new URLSearchParams({
1192
+ expose_mode: "tailnet",
1193
+ [CSRF_FIELD_NAME]: csrf,
1194
+ }).toString();
1195
+ const res = await handleSetupExposePost(
1196
+ req("/admin/setup/expose", {
1197
+ method: "POST",
1198
+ body: form,
1199
+ headers: {
1200
+ "content-type": "application/x-www-form-urlencoded",
1201
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1202
+ },
1203
+ }),
1204
+ {
1205
+ db,
1206
+ manifestPath: h.manifestPath,
1207
+ configDir: h.dir,
1208
+ issuer: "https://hub.example",
1209
+ registry: getDefaultOperationsRegistry(),
1210
+ },
1211
+ );
1212
+ expect(res.status).toBe(303);
1213
+ expect(res.headers.get("location")).toBe("/admin/setup?just_finished=1");
1214
+ // expose_mode is NOT overwritten (the wizard considers itself done).
1215
+ expect(getSetting(db, "setup_expose_mode")).toBe("localhost");
1216
+ // auto-approve window NOT re-opened — still the old stale stamp.
1217
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBe(
1218
+ "2020-01-01T00:00:00.000Z",
1219
+ );
1220
+ } finally {
1221
+ db.close();
1222
+ }
1223
+ });
1224
+ });
1225
+
1226
+ // --- hub#272 Item A: auto-mint operator token + MCP command rendering ---
1227
+
1228
+ describe("done screen auto-minted token (hub#272 Item A)", () => {
1229
+ let h: Harness;
1230
+ beforeEach(() => {
1231
+ h = makeHarness();
1232
+ _resetOperationsRegistryForTests();
1233
+ });
1234
+ afterEach(() => h.cleanup());
1235
+
1236
+ async function bringWizardToExposeStep(db: ReturnType<typeof openHubDb>) {
1237
+ const user = await createUser(db, "owner", "pw");
1238
+ writeManifest(
1239
+ {
1240
+ services: [
1241
+ {
1242
+ name: "parachute-vault",
1243
+ version: "0.1.0",
1244
+ port: 1940,
1245
+ paths: ["/vault/default"],
1246
+ health: "/health",
1247
+ },
1248
+ ],
1249
+ },
1250
+ h.manifestPath,
1251
+ );
1252
+ const { createSession } = await import("../sessions.ts");
1253
+ const session = createSession(db, { userId: user.id });
1254
+ const get = handleSetupGet(req("/admin/setup"), {
1255
+ db,
1256
+ manifestPath: h.manifestPath,
1257
+ configDir: h.dir,
1258
+ issuer: "https://hub.example",
1259
+ registry: getDefaultOperationsRegistry(),
1260
+ });
1261
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1262
+ return { user, session, csrf };
1263
+ }
1264
+
1265
+ test("expose POST mints + stores an operator token in hub_settings (setup_minted_token)", async () => {
1266
+ const db = openHubDb(hubDbPath(h.dir));
1267
+ try {
1268
+ const { session, csrf } = await bringWizardToExposeStep(db);
1269
+ const form = new URLSearchParams({
1270
+ expose_mode: "localhost",
1271
+ [CSRF_FIELD_NAME]: csrf,
1272
+ }).toString();
1273
+ const res = await handleSetupExposePost(
1274
+ req("/admin/setup/expose", {
1275
+ method: "POST",
1276
+ body: form,
1277
+ headers: {
1278
+ "content-type": "application/x-www-form-urlencoded",
1279
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1280
+ },
1281
+ }),
1282
+ {
1283
+ db,
1284
+ manifestPath: h.manifestPath,
1285
+ configDir: h.dir,
1286
+ issuer: "https://hub.example",
1287
+ registry: getDefaultOperationsRegistry(),
1288
+ },
1289
+ );
1290
+ expect(res.status).toBe(303);
1291
+ // Token is a JWT (three base64url segments). We don't assert the
1292
+ // exact value — the load-bearing surface is "a non-empty token
1293
+ // exists" so the done-step renderer has something to inject.
1294
+ const stored = getSetting(db, "setup_minted_token");
1295
+ expect(stored).toBeDefined();
1296
+ expect(stored?.split(".").length).toBe(3);
1297
+ } finally {
1298
+ db.close();
1299
+ }
1300
+ });
1301
+
1302
+ test("done screen renders the MCP command with a Bearer header when a minted token exists", async () => {
1303
+ const db = openHubDb(hubDbPath(h.dir));
1304
+ try {
1305
+ const user = await createUser(db, "owner", "pw");
1306
+ writeManifest(
1307
+ {
1308
+ services: [
1309
+ {
1310
+ name: "parachute-vault",
1311
+ version: "0.1.0",
1312
+ port: 1940,
1313
+ paths: ["/vault/default"],
1314
+ health: "/health",
1315
+ },
1316
+ ],
1317
+ },
1318
+ h.manifestPath,
1319
+ );
1320
+ setSetting(db, "setup_expose_mode", "localhost");
1321
+ setSetting(db, "setup_minted_token", "test-jwt-token-abc");
1322
+ const { createSession } = await import("../sessions.ts");
1323
+ const session = createSession(db, { userId: user.id });
1324
+ const res = handleSetupGet(
1325
+ req("/admin/setup?just_finished=1", {
1326
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1327
+ }),
1328
+ {
1329
+ db,
1330
+ manifestPath: h.manifestPath,
1331
+ configDir: h.dir,
1332
+ issuer: "https://hub.example",
1333
+ registry: getDefaultOperationsRegistry(),
1334
+ },
1335
+ );
1336
+ expect(res.status).toBe(200);
1337
+ const html = await res.text();
1338
+ expect(html).toContain("--header &quot;Authorization: Bearer test-jwt-token-abc&quot;");
1339
+ expect(html).toContain('data-target="mcp-cmd"');
1340
+ expect(html).toContain('id="mcp-cmd"');
1341
+ expect(html).toContain("/admin/tokens");
1342
+ // The token is single-use — consumed on first render.
1343
+ expect(getSetting(db, "setup_minted_token")).toBeUndefined();
1344
+ } finally {
1345
+ db.close();
1346
+ }
1347
+ });
1348
+
1349
+ test("done screen falls back to bare MCP command + admin/tokens hint when no minted token", async () => {
1350
+ const db = openHubDb(hubDbPath(h.dir));
1351
+ try {
1352
+ const user = await createUser(db, "owner", "pw");
1353
+ writeManifest(
1354
+ {
1355
+ services: [
1356
+ {
1357
+ name: "parachute-vault",
1358
+ version: "0.1.0",
1359
+ port: 1940,
1360
+ paths: ["/vault/default"],
1361
+ health: "/health",
1362
+ },
1363
+ ],
1364
+ },
1365
+ h.manifestPath,
1366
+ );
1367
+ setSetting(db, "setup_expose_mode", "localhost");
1368
+ const { createSession } = await import("../sessions.ts");
1369
+ const session = createSession(db, { userId: user.id });
1370
+ const res = handleSetupGet(
1371
+ req("/admin/setup?just_finished=1", {
1372
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1373
+ }),
1374
+ {
1375
+ db,
1376
+ manifestPath: h.manifestPath,
1377
+ configDir: h.dir,
1378
+ issuer: "https://hub.example",
1379
+ registry: getDefaultOperationsRegistry(),
1380
+ },
1381
+ );
1382
+ const html = await res.text();
1383
+ expect(html).toContain("claude mcp add --transport http parachute-default");
1384
+ // The fallback explanatory text mentions `pvt_...` as a placeholder
1385
+ // but the actual `--header` flag must NOT be appended to the
1386
+ // command line itself.
1387
+ expect(html).toContain("Bearer pvt_");
1388
+ expect(html).toContain("/admin/tokens");
1389
+ // Specifically no Copy button — that's a token-present surface.
1390
+ expect(html).not.toContain('id="mcp-cmd"');
1391
+ } finally {
1392
+ db.close();
1393
+ }
1394
+ });
1395
+
1396
+ test("minted token is consumed after first render — refresh shows the fallback shape", async () => {
1397
+ const db = openHubDb(hubDbPath(h.dir));
1398
+ try {
1399
+ const user = await createUser(db, "owner", "pw");
1400
+ writeManifest(
1401
+ {
1402
+ services: [
1403
+ {
1404
+ name: "parachute-vault",
1405
+ version: "0.1.0",
1406
+ port: 1940,
1407
+ paths: ["/vault/default"],
1408
+ health: "/health",
1409
+ },
1410
+ ],
1411
+ },
1412
+ h.manifestPath,
1413
+ );
1414
+ setSetting(db, "setup_expose_mode", "localhost");
1415
+ setSetting(db, "setup_minted_token", "test-token-xyz");
1416
+ const { createSession } = await import("../sessions.ts");
1417
+ const session = createSession(db, { userId: user.id });
1418
+ const deps = {
1419
+ db,
1420
+ manifestPath: h.manifestPath,
1421
+ configDir: h.dir,
1422
+ issuer: "https://hub.example",
1423
+ registry: getDefaultOperationsRegistry(),
1424
+ };
1425
+ const sessionedReq = () =>
1426
+ req("/admin/setup?just_finished=1", {
1427
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1428
+ });
1429
+ const first = handleSetupGet(sessionedReq(), deps);
1430
+ const firstHtml = await first.text();
1431
+ expect(firstHtml).toContain("test-token-xyz");
1432
+ const second = handleSetupGet(sessionedReq(), deps);
1433
+ const secondHtml = await second.text();
1434
+ expect(secondHtml).not.toContain("test-token-xyz");
1435
+ // The MCP command tile has no Copy button on the fallback shape.
1436
+ expect(secondHtml).not.toContain('id="mcp-cmd"');
1437
+ } finally {
1438
+ db.close();
1439
+ }
1440
+ });
1441
+
1442
+ test("GET /admin/setup?just_finished=1 without a session does NOT consume the minted token (hub#274 security fold)", async () => {
1443
+ // Regression — without the session gate, any HTTP client racing the
1444
+ // operator's browser between the expose POST (which mints + stores)
1445
+ // and the done GET (which reads + consumes) walks off with a
1446
+ // full-scope operator JWT. The gate sends sessionless GETs to
1447
+ // /login + leaves the row in place so the operator's subsequent
1448
+ // legitimate GET still surfaces the token.
1449
+ const db = openHubDb(hubDbPath(h.dir));
1450
+ try {
1451
+ await createUser(db, "owner", "pw");
1452
+ writeManifest(
1453
+ {
1454
+ services: [
1455
+ {
1456
+ name: "parachute-vault",
1457
+ version: "0.1.0",
1458
+ port: 1940,
1459
+ paths: ["/vault/default"],
1460
+ health: "/health",
1461
+ },
1462
+ ],
1463
+ },
1464
+ h.manifestPath,
1465
+ );
1466
+ setSetting(db, "setup_expose_mode", "localhost");
1467
+ setSetting(db, "setup_minted_token", "test-secret-token-must-not-leak");
1468
+ // No session cookie on this request — simulating a drive-by GET
1469
+ // from an attacker or a stale bookmark in a different browser
1470
+ // tab that doesn't carry the wizard's session.
1471
+ const res = handleSetupGet(req("/admin/setup?just_finished=1"), {
1472
+ db,
1473
+ manifestPath: h.manifestPath,
1474
+ configDir: h.dir,
1475
+ issuer: "https://hub.example",
1476
+ registry: getDefaultOperationsRegistry(),
1477
+ });
1478
+ // The gate redirects to /login (302) rather than rendering the
1479
+ // done screen. Body must NOT contain the token.
1480
+ expect(res.status).toBe(302);
1481
+ expect(res.headers.get("location")).toBe("/login");
1482
+ // The setup_minted_token row is STILL present — the unauthed GET
1483
+ // didn't consume it, so the legitimate operator's session-bearing
1484
+ // GET will still see the token on the done screen.
1485
+ expect(getSetting(db, "setup_minted_token")).toBe("test-secret-token-must-not-leak");
1486
+ } finally {
1487
+ db.close();
1488
+ }
1489
+ });
1490
+ });
1491
+
1492
+ // --- hub#272 Item B: install-tile rendering + install POST --------------
1493
+
1494
+ describe("done screen install tiles (hub#272 Item B)", () => {
1495
+ let h: Harness;
1496
+ beforeEach(() => {
1497
+ h = makeHarness();
1498
+ _resetOperationsRegistryForTests();
1499
+ });
1500
+ afterEach(() => h.cleanup());
1501
+
1502
+ test("done screen renders Install Notes + Install Scribe tiles when neither is installed", async () => {
1503
+ const db = openHubDb(hubDbPath(h.dir));
1504
+ try {
1505
+ const user = await createUser(db, "owner", "pw");
1506
+ writeManifest(
1507
+ {
1508
+ services: [
1509
+ {
1510
+ name: "parachute-vault",
1511
+ version: "0.1.0",
1512
+ port: 1940,
1513
+ paths: ["/vault/default"],
1514
+ health: "/health",
1515
+ },
1516
+ ],
1517
+ },
1518
+ h.manifestPath,
1519
+ );
1520
+ setSetting(db, "setup_expose_mode", "localhost");
1521
+ const { createSession } = await import("../sessions.ts");
1522
+ const session = createSession(db, { userId: user.id });
1523
+ const res = handleSetupGet(
1524
+ req("/admin/setup?just_finished=1", {
1525
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1526
+ }),
1527
+ {
1528
+ db,
1529
+ manifestPath: h.manifestPath,
1530
+ configDir: h.dir,
1531
+ issuer: "https://hub.example",
1532
+ registry: getDefaultOperationsRegistry(),
1533
+ },
1534
+ );
1535
+ const html = await res.text();
1536
+ expect(html).toContain("What's next?");
1537
+ expect(html).toContain("Install Notes");
1538
+ expect(html).toContain("Install Scribe");
1539
+ expect(html).toContain('action="/admin/setup/install/notes"');
1540
+ expect(html).toContain('action="/admin/setup/install/scribe"');
1541
+ } finally {
1542
+ db.close();
1543
+ }
1544
+ });
1545
+
1546
+ test("tile shows 'Already installed' when a curated module is in services.json", async () => {
1547
+ const db = openHubDb(hubDbPath(h.dir));
1548
+ try {
1549
+ const user = await createUser(db, "owner", "pw");
1550
+ writeManifest(
1551
+ {
1552
+ services: [
1553
+ {
1554
+ name: "parachute-vault",
1555
+ version: "0.1.0",
1556
+ port: 1940,
1557
+ paths: ["/vault/default"],
1558
+ health: "/health",
1559
+ },
1560
+ {
1561
+ name: "parachute-notes",
1562
+ version: "0.1.0",
1563
+ port: 1942,
1564
+ paths: ["/notes"],
1565
+ health: "/notes/health",
1566
+ },
1567
+ ],
1568
+ },
1569
+ h.manifestPath,
1570
+ );
1571
+ setSetting(db, "setup_expose_mode", "localhost");
1572
+ const { createSession } = await import("../sessions.ts");
1573
+ const session = createSession(db, { userId: user.id });
1574
+ const res = handleSetupGet(
1575
+ req("/admin/setup?just_finished=1", {
1576
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1577
+ }),
1578
+ {
1579
+ db,
1580
+ manifestPath: h.manifestPath,
1581
+ configDir: h.dir,
1582
+ issuer: "https://hub.example",
1583
+ registry: getDefaultOperationsRegistry(),
1584
+ },
1585
+ );
1586
+ const html = await res.text();
1587
+ expect(html).toContain("Already installed");
1588
+ expect(html).toContain('action="/admin/setup/install/scribe"');
1589
+ } finally {
1590
+ db.close();
1591
+ }
1592
+ });
1593
+
1594
+ test("done screen renders op-poll panel when ?op_notes=<id> matches a registry op", async () => {
1595
+ const db = openHubDb(hubDbPath(h.dir));
1596
+ try {
1597
+ const user = await createUser(db, "owner", "pw");
1598
+ writeManifest(
1599
+ {
1600
+ services: [
1601
+ {
1602
+ name: "parachute-vault",
1603
+ version: "0.1.0",
1604
+ port: 1940,
1605
+ paths: ["/vault/default"],
1606
+ health: "/health",
1607
+ },
1608
+ ],
1609
+ },
1610
+ h.manifestPath,
1611
+ );
1612
+ setSetting(db, "setup_expose_mode", "localhost");
1613
+ const reg = getDefaultOperationsRegistry();
1614
+ const op = reg.create("install", "notes");
1615
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/notes@latest");
1616
+ const { createSession } = await import("../sessions.ts");
1617
+ const session = createSession(db, { userId: user.id });
1618
+ const res = handleSetupGet(
1619
+ req(`/admin/setup?just_finished=1&op_notes=${op.id}`, {
1620
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
1621
+ }),
1622
+ {
1623
+ db,
1624
+ manifestPath: h.manifestPath,
1625
+ configDir: h.dir,
1626
+ issuer: "https://hub.example",
1627
+ registry: reg,
1628
+ },
1629
+ );
1630
+ const html = await res.text();
1631
+ expect(html).toContain("status: running");
1632
+ expect(html).toContain("running bun add");
1633
+ // Auto-refresh wired so the next tick re-fetches.
1634
+ expect(html).toContain('http-equiv="refresh"');
1635
+ } finally {
1636
+ db.close();
1637
+ }
1638
+ });
1639
+
1640
+ test("install POST enqueues an op + redirects to ?op_<short>=<id>", async () => {
1641
+ const db = openHubDb(hubDbPath(h.dir));
1642
+ try {
1643
+ const user = await createUser(db, "owner", "pw");
1644
+ writeManifest(
1645
+ {
1646
+ services: [
1647
+ {
1648
+ name: "parachute-vault",
1649
+ version: "0.1.0",
1650
+ port: 1940,
1651
+ paths: ["/vault/default"],
1652
+ health: "/health",
1653
+ },
1654
+ ],
1655
+ },
1656
+ h.manifestPath,
1657
+ );
1658
+ setSetting(db, "setup_expose_mode", "localhost");
1659
+ const { createSession } = await import("../sessions.ts");
1660
+ const session = createSession(db, { userId: user.id });
1661
+ const get = handleSetupGet(req("/admin/setup?just_finished=1"), {
1662
+ db,
1663
+ manifestPath: h.manifestPath,
1664
+ configDir: h.dir,
1665
+ issuer: "https://hub.example",
1666
+ registry: getDefaultOperationsRegistry(),
1667
+ });
1668
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1669
+ const runCalls: string[][] = [];
1670
+ const stubbedRun = async (cmd: readonly string[]) => {
1671
+ runCalls.push([...cmd]);
1672
+ return 0;
1673
+ };
1674
+ const post = await handleSetupInstallPost(
1675
+ req("/admin/setup/install/notes", {
1676
+ method: "POST",
1677
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
1678
+ headers: {
1679
+ "content-type": "application/x-www-form-urlencoded",
1680
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1681
+ },
1682
+ }),
1683
+ "notes",
1684
+ {
1685
+ db,
1686
+ manifestPath: h.manifestPath,
1687
+ configDir: h.dir,
1688
+ issuer: "https://hub.example",
1689
+ supervisor: makeSupervisor(),
1690
+ registry: getDefaultOperationsRegistry(),
1691
+ run: stubbedRun,
1692
+ },
1693
+ );
1694
+ expect(post.status).toBe(303);
1695
+ const location = post.headers.get("location") ?? "";
1696
+ expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_notes=/);
1697
+ await new Promise((r) => setTimeout(r, 50));
1698
+ expect(runCalls.length).toBeGreaterThan(0);
1699
+ expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/notes@latest");
1700
+ } finally {
1701
+ db.close();
1702
+ }
1703
+ });
1704
+
1705
+ test("install POST rejects 'vault' short (the wizard's own step owns that)", async () => {
1706
+ const db = openHubDb(hubDbPath(h.dir));
1707
+ try {
1708
+ const user = await createUser(db, "owner", "pw");
1709
+ const { createSession } = await import("../sessions.ts");
1710
+ const session = createSession(db, { userId: user.id });
1711
+ const get = handleSetupGet(req("/admin/setup"), {
1712
+ db,
1713
+ manifestPath: h.manifestPath,
1714
+ configDir: h.dir,
1715
+ issuer: "https://hub.example",
1716
+ registry: getDefaultOperationsRegistry(),
1717
+ });
1718
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1719
+ const post = await handleSetupInstallPost(
1720
+ req("/admin/setup/install/vault", {
1721
+ method: "POST",
1722
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
1723
+ headers: {
1724
+ "content-type": "application/x-www-form-urlencoded",
1725
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1726
+ },
1727
+ }),
1728
+ "vault",
1729
+ {
1730
+ db,
1731
+ manifestPath: h.manifestPath,
1732
+ configDir: h.dir,
1733
+ issuer: "https://hub.example",
1734
+ supervisor: makeSupervisor(),
1735
+ registry: getDefaultOperationsRegistry(),
1736
+ },
1737
+ );
1738
+ expect(post.status).toBe(400);
1739
+ const html = await post.text();
1740
+ expect(html).toContain("not an installable wizard module");
1741
+ } finally {
1742
+ db.close();
1743
+ }
1744
+ });
1745
+
1746
+ test("install POST rejects unknown short", async () => {
1747
+ const db = openHubDb(hubDbPath(h.dir));
1748
+ try {
1749
+ const post = await handleSetupInstallPost(
1750
+ req("/admin/setup/install/bogus", {
1751
+ method: "POST",
1752
+ body: new URLSearchParams({}).toString(),
1753
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1754
+ }),
1755
+ "bogus",
1756
+ {
1757
+ db,
1758
+ manifestPath: h.manifestPath,
1759
+ configDir: h.dir,
1760
+ issuer: "https://hub.example",
1761
+ supervisor: makeSupervisor(),
1762
+ registry: getDefaultOperationsRegistry(),
1763
+ },
1764
+ );
1765
+ expect(post.status).toBe(400);
1766
+ const html = await post.text();
1767
+ expect(html).toContain("not an installable wizard module");
1768
+ } finally {
1769
+ db.close();
1770
+ }
1771
+ });
1772
+
1773
+ test("install POST without admin session is rejected", async () => {
1774
+ const db = openHubDb(hubDbPath(h.dir));
1775
+ try {
1776
+ await createUser(db, "owner", "pw");
1777
+ const get = handleSetupGet(req("/admin/setup"), {
1778
+ db,
1779
+ manifestPath: h.manifestPath,
1780
+ configDir: h.dir,
1781
+ issuer: "https://hub.example",
1782
+ registry: getDefaultOperationsRegistry(),
1783
+ });
1784
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1785
+ const post = await handleSetupInstallPost(
1786
+ req("/admin/setup/install/notes", {
1787
+ method: "POST",
1788
+ body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
1789
+ headers: {
1790
+ "content-type": "application/x-www-form-urlencoded",
1791
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
1792
+ },
1793
+ }),
1794
+ "notes",
1795
+ {
1796
+ db,
1797
+ manifestPath: h.manifestPath,
1798
+ configDir: h.dir,
1799
+ issuer: "https://hub.example",
1800
+ supervisor: makeSupervisor(),
1801
+ registry: getDefaultOperationsRegistry(),
1802
+ },
1803
+ );
1804
+ expect(post.status).toBe(400);
1805
+ const html = await post.text();
1806
+ expect(html).toContain("No admin session");
1807
+ } finally {
1808
+ db.close();
1809
+ }
1810
+ });
1811
+
1812
+ test("install POST without supervisor (CLI mode) is rejected", async () => {
1813
+ const db = openHubDb(hubDbPath(h.dir));
1814
+ try {
1815
+ await createUser(db, "owner", "pw");
1816
+ const post = await handleSetupInstallPost(
1817
+ req("/admin/setup/install/notes", {
1818
+ method: "POST",
1819
+ body: new URLSearchParams({}).toString(),
1820
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1821
+ }),
1822
+ "notes",
1823
+ {
1824
+ db,
1825
+ manifestPath: h.manifestPath,
1826
+ configDir: h.dir,
1827
+ issuer: "https://hub.example",
1828
+ registry: getDefaultOperationsRegistry(),
1829
+ },
1830
+ );
1831
+ expect(post.status).toBe(400);
1832
+ const html = await post.text();
1833
+ expect(html).toContain("supervisor unavailable");
1834
+ } finally {
1835
+ db.close();
1836
+ }
1837
+ });
1838
+ });
1839
+
1840
+ // --- hub#267: typed vault name threading --------------------------------
1841
+
1842
+ describe("typed vault name (hub#267)", () => {
1843
+ let h: Harness;
1844
+ beforeEach(() => {
1845
+ h = makeHarness();
1846
+ _resetOperationsRegistryForTests();
1847
+ });
1848
+ afterEach(() => h.cleanup());
1849
+
1850
+ test("vault POST accepts a valid typed name + passes PARACHUTE_VAULT_NAME via env to supervisor", async () => {
1851
+ const db = openHubDb(hubDbPath(h.dir));
1852
+ try {
1853
+ const user = await createUser(db, "owner", "pw");
1854
+ const { createSession } = await import("../sessions.ts");
1855
+ const session = createSession(db, { userId: user.id });
1856
+ const get = handleSetupGet(req("/admin/setup"), {
1857
+ db,
1858
+ manifestPath: h.manifestPath,
1859
+ configDir: h.dir,
1860
+ issuer: "https://hub.example",
1861
+ registry: getDefaultOperationsRegistry(),
1862
+ });
1863
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1864
+ // Capture supervisor spawn requests so we can assert env passthrough.
1865
+ const spawnRequests: Array<{
1866
+ short: string;
1867
+ env?: Record<string, string>;
1868
+ }> = [];
1869
+ const supervisor = new Supervisor({
1870
+ output: () => {},
1871
+ spawnFn: (sreq) => {
1872
+ spawnRequests.push({
1873
+ short: sreq.short,
1874
+ ...(sreq.env ? { env: sreq.env } : {}),
1875
+ });
1876
+ return {
1877
+ pid: 22222,
1878
+ exited: new Promise<number | null>(() => {}),
1879
+ stdout: null,
1880
+ stderr: null,
1881
+ kill: () => {},
1882
+ };
1883
+ },
1884
+ });
1885
+ const stubbedRun = async (_cmd: readonly string[]) => 0;
1886
+ const post = await handleSetupVaultPost(
1887
+ req("/admin/setup/vault", {
1888
+ method: "POST",
1889
+ body: new URLSearchParams({
1890
+ [CSRF_FIELD_NAME]: csrf,
1891
+ vault_name: "smoke-1940",
1892
+ }).toString(),
1893
+ headers: {
1894
+ "content-type": "application/x-www-form-urlencoded",
1895
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1896
+ },
1897
+ }),
1898
+ {
1899
+ db,
1900
+ manifestPath: h.manifestPath,
1901
+ configDir: h.dir,
1902
+ issuer: "https://hub.example",
1903
+ supervisor,
1904
+ registry: getDefaultOperationsRegistry(),
1905
+ run: stubbedRun,
1906
+ },
1907
+ );
1908
+ expect(post.status).toBe(303);
1909
+ expect(getSetting(db, "setup_vault_name")).toBe("smoke-1940");
1910
+ // Yield long enough for runInstall → spawnSupervised → supervisor.start
1911
+ await new Promise((r) => setTimeout(r, 50));
1912
+ expect(spawnRequests.length).toBeGreaterThan(0);
1913
+ const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
1914
+ expect(vaultSpawn).toBeDefined();
1915
+ expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("smoke-1940");
1916
+ } finally {
1917
+ db.close();
1918
+ }
1919
+ });
1920
+
1921
+ test("vault POST rejects an invalid name (uppercase) with a 400 + error banner + preserved input", async () => {
1922
+ const db = openHubDb(hubDbPath(h.dir));
1923
+ try {
1924
+ const user = await createUser(db, "owner", "pw");
1925
+ const { createSession } = await import("../sessions.ts");
1926
+ const session = createSession(db, { userId: user.id });
1927
+ const get = handleSetupGet(req("/admin/setup"), {
1928
+ db,
1929
+ manifestPath: h.manifestPath,
1930
+ configDir: h.dir,
1931
+ issuer: "https://hub.example",
1932
+ registry: getDefaultOperationsRegistry(),
1933
+ });
1934
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1935
+ const post = await handleSetupVaultPost(
1936
+ req("/admin/setup/vault", {
1937
+ method: "POST",
1938
+ body: new URLSearchParams({
1939
+ [CSRF_FIELD_NAME]: csrf,
1940
+ vault_name: "BAD-NAME",
1941
+ }).toString(),
1942
+ headers: {
1943
+ "content-type": "application/x-www-form-urlencoded",
1944
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
1945
+ },
1946
+ }),
1947
+ {
1948
+ db,
1949
+ manifestPath: h.manifestPath,
1950
+ configDir: h.dir,
1951
+ issuer: "https://hub.example",
1952
+ supervisor: makeSupervisor(),
1953
+ registry: getDefaultOperationsRegistry(),
1954
+ },
1955
+ );
1956
+ expect(post.status).toBe(400);
1957
+ const html = await post.text();
1958
+ expect(html).toContain("lowercase alphanumeric");
1959
+ expect(html).toContain('value="BAD-NAME"');
1960
+ expect(getSetting(db, "setup_vault_name")).toBeUndefined();
1961
+ } finally {
1962
+ db.close();
1963
+ }
1964
+ });
1965
+
1966
+ test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
1967
+ const db = openHubDb(hubDbPath(h.dir));
1968
+ try {
1969
+ const user = await createUser(db, "owner", "pw");
1970
+ const { createSession } = await import("../sessions.ts");
1971
+ const session = createSession(db, { userId: user.id });
1972
+ const get = handleSetupGet(req("/admin/setup"), {
1973
+ db,
1974
+ manifestPath: h.manifestPath,
1975
+ configDir: h.dir,
1976
+ issuer: "https://hub.example",
1977
+ registry: getDefaultOperationsRegistry(),
1978
+ });
1979
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
1980
+ const spawnRequests: Array<{
1981
+ short: string;
1982
+ env?: Record<string, string>;
1983
+ }> = [];
1984
+ const supervisor = new Supervisor({
1985
+ output: () => {},
1986
+ spawnFn: (sreq) => {
1987
+ spawnRequests.push({
1988
+ short: sreq.short,
1989
+ ...(sreq.env ? { env: sreq.env } : {}),
1990
+ });
1991
+ return {
1992
+ pid: 33333,
1993
+ exited: new Promise<number | null>(() => {}),
1994
+ stdout: null,
1995
+ stderr: null,
1996
+ kill: () => {},
1997
+ };
1998
+ },
1999
+ });
2000
+ const post = await handleSetupVaultPost(
2001
+ req("/admin/setup/vault", {
2002
+ method: "POST",
2003
+ body: new URLSearchParams({
2004
+ [CSRF_FIELD_NAME]: csrf,
2005
+ vault_name: "",
2006
+ }).toString(),
2007
+ headers: {
2008
+ "content-type": "application/x-www-form-urlencoded",
2009
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
2010
+ },
2011
+ }),
2012
+ {
2013
+ db,
2014
+ manifestPath: h.manifestPath,
2015
+ configDir: h.dir,
2016
+ issuer: "https://hub.example",
2017
+ supervisor,
2018
+ registry: getDefaultOperationsRegistry(),
2019
+ run: async () => 0,
2020
+ },
2021
+ );
2022
+ expect(post.status).toBe(303);
2023
+ expect(getSetting(db, "setup_vault_name")).toBe("default");
2024
+ await new Promise((r) => setTimeout(r, 50));
2025
+ const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
2026
+ expect(vaultSpawn).toBeDefined();
2027
+ // No env override on the default-name path (vault's
2028
+ // resolveFirstBootVaultName already defaults to "default" when the
2029
+ // env var is absent, so the override would be redundant).
2030
+ expect(vaultSpawn?.env).toBeUndefined();
2031
+ } finally {
2032
+ db.close();
2033
+ }
2034
+ });
2035
+
2036
+ test("done screen surfaces the typed name in the MCP command", async () => {
2037
+ const db = openHubDb(hubDbPath(h.dir));
2038
+ try {
2039
+ const user = await createUser(db, "owner", "pw");
2040
+ writeManifest(
2041
+ {
2042
+ services: [
2043
+ {
2044
+ name: "parachute-vault",
2045
+ version: "0.1.0",
2046
+ port: 1940,
2047
+ paths: ["/vault/default"],
2048
+ health: "/health",
2049
+ },
2050
+ ],
2051
+ },
2052
+ h.manifestPath,
2053
+ );
2054
+ setSetting(db, "setup_expose_mode", "localhost");
2055
+ setSetting(db, "setup_vault_name", "my-personal-vault");
2056
+ const { createSession } = await import("../sessions.ts");
2057
+ const session = createSession(db, { userId: user.id });
2058
+ const res = handleSetupGet(
2059
+ req("/admin/setup?just_finished=1", {
2060
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2061
+ }),
2062
+ {
2063
+ db,
2064
+ manifestPath: h.manifestPath,
2065
+ configDir: h.dir,
2066
+ issuer: "https://hub.example",
2067
+ registry: getDefaultOperationsRegistry(),
2068
+ },
2069
+ );
2070
+ const html = await res.text();
2071
+ expect(html).toContain("parachute-my-personal-vault");
2072
+ expect(html).toContain("/vault/my-personal-vault/mcp");
2073
+ } finally {
2074
+ db.close();
2075
+ }
2076
+ });
2077
+
2078
+ test("vault step pre-fills the prior typed value after a validation error", async () => {
2079
+ const { renderVaultStep } = await import("../setup-wizard.ts");
2080
+ const html = renderVaultStep({
2081
+ csrfToken: "csrf-test",
2082
+ vaultName: "BAD",
2083
+ errorMessage: "vault names must be lowercase alphanumeric with hyphens or underscores.",
2084
+ });
2085
+ expect(html).toContain('value="BAD"');
2086
+ expect(html).toContain("lowercase alphanumeric");
2087
+ expect(html).toContain('id="preview-vault-name">BAD<');
2088
+ });
2089
+ });