@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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. 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("done step when both admin and vault exist", async () => {
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 (fold B: shows 'default' as static)", async () => {
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
- // The vault name is hard-bound to "default" pending hub#267 — the
217
- // form has no name input, just a submit button + a preview card
218
- // showing the canonical name + the follow-up issue link.
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
- expect(html).not.toContain('name="vault_name"');
221
- expect(html).toContain("hub#267");
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 both admin and vault exist", async () => {
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 success page once with ?just_finished=1 query", async () => {
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/myvault"],
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?just_finished=1"), {
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 both present", async () => {
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 `&quot;` 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 — `&quot;`-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
+ });