@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -23,6 +23,7 @@ import {
23
23
  getDefaultOperationsRegistry,
24
24
  } from "../api-modules-ops.ts";
25
25
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
26
+ import { type ExposeState, readExposeState, writeExposeState } from "../expose-state.ts";
26
27
  import { hubDbPath, openHubDb } from "../hub-db.ts";
27
28
  import { hubFetch } from "../hub-server.ts";
28
29
  import { getSetting, setSetting } from "../hub-settings.ts";
@@ -36,6 +37,7 @@ import {
36
37
  handleSetupGet,
37
38
  handleSetupInstallPost,
38
39
  handleSetupVaultPost,
40
+ postVaultImportImpl,
39
41
  } from "../setup-wizard.ts";
40
42
  import { Supervisor } from "../supervisor.ts";
41
43
  import { createUser, getUserByUsername, userCount } from "../users.ts";
@@ -43,6 +45,20 @@ import { createUser, getUserByUsername, userCount } from "../users.ts";
43
45
  interface Harness {
44
46
  dir: string;
45
47
  manifestPath: string;
48
+ /**
49
+ * Hermetic expose-state reader scoped to the harness's tmp dir. The
50
+ * production `readExposeState()` defaults to the operator's real
51
+ * `~/.parachute/expose-state.json` (a module-load constant), so a
52
+ * wizard test that omits an injected reader would auto-seed
53
+ * `setup_expose_mode` from the developer's LIVE exposure (hub#406) and
54
+ * flip expose-step assertions nondeterministically. Threading this
55
+ * harness reader keeps every wizard test isolated from the real
56
+ * filesystem — same isolation the harness already gives DB + manifest.
57
+ * Defaults to "no live exposure" (the tmp file doesn't exist) unless a
58
+ * test writes one via `writeExposeState(state, h.exposeStatePath)`.
59
+ */
60
+ exposeStatePath: string;
61
+ readExposeStateFn: () => ExposeState | undefined;
46
62
  cleanup: () => void;
47
63
  }
48
64
 
@@ -51,9 +67,12 @@ function makeHarness(): Harness {
51
67
  writeFileSync(join(dir, "hub.html"), "<html>discovery</html>");
52
68
  const manifestPath = join(dir, "services.json");
53
69
  writeManifest({ services: [] }, manifestPath);
70
+ const exposeStatePath = join(dir, "expose-state.json");
54
71
  return {
55
72
  dir,
56
73
  manifestPath,
74
+ exposeStatePath,
75
+ readExposeStateFn: () => readExposeState(exposeStatePath),
57
76
  cleanup: () => rmSync(dir, { recursive: true, force: true }),
58
77
  };
59
78
  }
@@ -123,7 +142,11 @@ describe("deriveWizardState", () => {
123
142
  test("welcome step when no admin and no vault", () => {
124
143
  const db = openHubDb(hubDbPath(h.dir));
125
144
  try {
126
- const s = deriveWizardState({ db, manifestPath: h.manifestPath });
145
+ const s = deriveWizardState({
146
+ db,
147
+ manifestPath: h.manifestPath,
148
+ readExposeStateFn: h.readExposeStateFn,
149
+ });
127
150
  expect(s.step).toBe("welcome");
128
151
  expect(s.hasAdmin).toBe(false);
129
152
  expect(s.hasVault).toBe(false);
@@ -136,7 +159,11 @@ describe("deriveWizardState", () => {
136
159
  const db = openHubDb(hubDbPath(h.dir));
137
160
  try {
138
161
  await createUser(db, "owner", "pw");
139
- const s = deriveWizardState({ db, manifestPath: h.manifestPath });
162
+ const s = deriveWizardState({
163
+ db,
164
+ manifestPath: h.manifestPath,
165
+ readExposeStateFn: h.readExposeStateFn,
166
+ });
140
167
  expect(s.step).toBe("vault");
141
168
  expect(s.hasAdmin).toBe(true);
142
169
  expect(s.hasVault).toBe(false);
@@ -163,7 +190,11 @@ describe("deriveWizardState", () => {
163
190
  },
164
191
  h.manifestPath,
165
192
  );
166
- const s = deriveWizardState({ db, manifestPath: h.manifestPath });
193
+ const s = deriveWizardState({
194
+ db,
195
+ manifestPath: h.manifestPath,
196
+ readExposeStateFn: h.readExposeStateFn,
197
+ });
167
198
  expect(s.step).toBe("expose");
168
199
  expect(s.hasAdmin).toBe(true);
169
200
  expect(s.hasVault).toBe(true);
@@ -200,7 +231,12 @@ describe("deriveWizardState", () => {
200
231
  );
201
232
  // Simulate Render env. detectAutoExposeMode reads RENDER_EXTERNAL_URL.
202
233
  const renderEnv = { RENDER_EXTERNAL_URL: "https://parachute-hub.onrender.com" };
203
- const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: renderEnv });
234
+ const s = deriveWizardState({
235
+ db,
236
+ manifestPath: h.manifestPath,
237
+ env: renderEnv,
238
+ readExposeStateFn: h.readExposeStateFn,
239
+ });
204
240
  expect(s.step).toBe("done");
205
241
  expect(s.hasExposeMode).toBe(true);
206
242
  } finally {
@@ -226,7 +262,12 @@ describe("deriveWizardState", () => {
226
262
  },
227
263
  h.manifestPath,
228
264
  );
229
- const s = deriveWizardState({ db, manifestPath: h.manifestPath, env: {} });
265
+ const s = deriveWizardState({
266
+ db,
267
+ manifestPath: h.manifestPath,
268
+ env: {},
269
+ readExposeStateFn: h.readExposeStateFn,
270
+ });
230
271
  // Local install path — the operator still gets to choose
231
272
  expect(s.step).toBe("expose");
232
273
  expect(s.hasExposeMode).toBe(false);
@@ -235,6 +276,188 @@ describe("deriveWizardState", () => {
235
276
  }
236
277
  });
237
278
 
279
+ test("auto-seeds expose mode from a live `parachute expose tailnet` (hub#406 team-onboarding bug)", async () => {
280
+ // Team-onboarding bug: an operator ran `parachute expose tailnet`
281
+ // BEFORE opening the wizard. That writes expose-state.json
282
+ // (layer=tailnet) but never the `setup_expose_mode` hub_setting —
283
+ // the two are orthogonal axes. Pre-fix, the wizard consulted only
284
+ // the setting and re-rendered "How will this hub be reached?" though
285
+ // tailnet was already live. deriveWizardState now reads the live
286
+ // exposure layer and auto-seeds the setting, so the expose step is
287
+ // treated as satisfied and the wizard advances to done.
288
+ const db = openHubDb(hubDbPath(h.dir));
289
+ try {
290
+ await createUser(db, "owner", "pw");
291
+ writeManifest(
292
+ {
293
+ services: [
294
+ {
295
+ name: "parachute-vault",
296
+ version: "0.1.0",
297
+ port: 1940,
298
+ paths: ["/vault/default"],
299
+ health: "/health",
300
+ },
301
+ ],
302
+ },
303
+ h.manifestPath,
304
+ );
305
+ // Simulate `parachute expose tailnet`: write a real expose-state
306
+ // file (round-trips through readExposeState's validator) into the
307
+ // harness tmp path. No env signal (not Render/Fly), no setting.
308
+ writeExposeState(
309
+ {
310
+ version: 1,
311
+ layer: "tailnet",
312
+ mode: "path",
313
+ canonicalFqdn: "my-mac.tailnet-name.ts.net",
314
+ port: 1939,
315
+ funnel: false,
316
+ entries: [],
317
+ },
318
+ h.exposeStatePath,
319
+ );
320
+ const s = deriveWizardState({
321
+ db,
322
+ manifestPath: h.manifestPath,
323
+ env: {},
324
+ readExposeStateFn: h.readExposeStateFn,
325
+ });
326
+ expect(s.step).toBe("done");
327
+ expect(s.hasExposeMode).toBe(true);
328
+ // The setting was auto-seeded from the live exposure layer.
329
+ expect(getSetting(db, "setup_expose_mode")).toBe("tailnet");
330
+ } finally {
331
+ db.close();
332
+ }
333
+ });
334
+
335
+ test("auto-seeds expose mode = public from a live public exposure", async () => {
336
+ const db = openHubDb(hubDbPath(h.dir));
337
+ try {
338
+ await createUser(db, "owner", "pw");
339
+ writeManifest(
340
+ {
341
+ services: [
342
+ {
343
+ name: "parachute-vault",
344
+ version: "0.1.0",
345
+ port: 1940,
346
+ paths: ["/vault/default"],
347
+ health: "/health",
348
+ },
349
+ ],
350
+ },
351
+ h.manifestPath,
352
+ );
353
+ writeExposeState(
354
+ {
355
+ version: 1,
356
+ layer: "public",
357
+ mode: "path",
358
+ canonicalFqdn: "hub.example.com",
359
+ port: 1939,
360
+ funnel: true,
361
+ entries: [],
362
+ },
363
+ h.exposeStatePath,
364
+ );
365
+ const s = deriveWizardState({
366
+ db,
367
+ manifestPath: h.manifestPath,
368
+ env: {},
369
+ readExposeStateFn: h.readExposeStateFn,
370
+ });
371
+ expect(s.step).toBe("done");
372
+ expect(s.hasExposeMode).toBe(true);
373
+ expect(getSetting(db, "setup_expose_mode")).toBe("public");
374
+ } finally {
375
+ db.close();
376
+ }
377
+ });
378
+
379
+ test("still asks the expose step when no live exposure + no setting (unchanged)", async () => {
380
+ const db = openHubDb(hubDbPath(h.dir));
381
+ try {
382
+ await createUser(db, "owner", "pw");
383
+ writeManifest(
384
+ {
385
+ services: [
386
+ {
387
+ name: "parachute-vault",
388
+ version: "0.1.0",
389
+ port: 1940,
390
+ paths: ["/vault/default"],
391
+ health: "/health",
392
+ },
393
+ ],
394
+ },
395
+ h.manifestPath,
396
+ );
397
+ // No env signal, no expose-state file written (reader returns
398
+ // undefined), no setting → the operator still gets the expose step.
399
+ const s = deriveWizardState({
400
+ db,
401
+ manifestPath: h.manifestPath,
402
+ env: {},
403
+ readExposeStateFn: h.readExposeStateFn,
404
+ });
405
+ expect(s.step).toBe("expose");
406
+ expect(s.hasExposeMode).toBe(false);
407
+ expect(getSetting(db, "setup_expose_mode")).toBeUndefined();
408
+ } finally {
409
+ db.close();
410
+ }
411
+ });
412
+
413
+ test("an explicit setup_expose_mode wins over a live exposure (no clobber)", async () => {
414
+ // If the operator already answered the expose step (or it was seeded
415
+ // by a prior call), a later live-exposure read must not overwrite the
416
+ // recorded answer. Guards the `=== undefined` gate.
417
+ const db = openHubDb(hubDbPath(h.dir));
418
+ try {
419
+ await createUser(db, "owner", "pw");
420
+ writeManifest(
421
+ {
422
+ services: [
423
+ {
424
+ name: "parachute-vault",
425
+ version: "0.1.0",
426
+ port: 1940,
427
+ paths: ["/vault/default"],
428
+ health: "/health",
429
+ },
430
+ ],
431
+ },
432
+ h.manifestPath,
433
+ );
434
+ setSetting(db, "setup_expose_mode", "localhost");
435
+ writeExposeState(
436
+ {
437
+ version: 1,
438
+ layer: "public",
439
+ mode: "path",
440
+ canonicalFqdn: "hub.example.com",
441
+ port: 1939,
442
+ funnel: true,
443
+ entries: [],
444
+ },
445
+ h.exposeStatePath,
446
+ );
447
+ const s = deriveWizardState({
448
+ db,
449
+ manifestPath: h.manifestPath,
450
+ env: {},
451
+ readExposeStateFn: h.readExposeStateFn,
452
+ });
453
+ expect(s.step).toBe("done");
454
+ // Recorded answer is preserved, not overwritten by the live layer.
455
+ expect(getSetting(db, "setup_expose_mode")).toBe("localhost");
456
+ } finally {
457
+ db.close();
458
+ }
459
+ });
460
+
238
461
  test("done step once admin + vault + expose mode all exist", async () => {
239
462
  const db = openHubDb(hubDbPath(h.dir));
240
463
  try {
@@ -254,7 +477,11 @@ describe("deriveWizardState", () => {
254
477
  h.manifestPath,
255
478
  );
256
479
  setSetting(db, "setup_expose_mode", "localhost");
257
- const s = deriveWizardState({ db, manifestPath: h.manifestPath });
480
+ const s = deriveWizardState({
481
+ db,
482
+ manifestPath: h.manifestPath,
483
+ readExposeStateFn: h.readExposeStateFn,
484
+ });
258
485
  expect(s.step).toBe("done");
259
486
  expect(s.hasAdmin).toBe(true);
260
487
  expect(s.hasVault).toBe(true);
@@ -282,6 +509,7 @@ describe("handleSetupGet", () => {
282
509
  db,
283
510
  manifestPath: h.manifestPath,
284
511
  configDir: h.dir,
512
+ readExposeStateFn: h.readExposeStateFn,
285
513
  issuer: "https://hub.example",
286
514
  registry: getDefaultOperationsRegistry(),
287
515
  });
@@ -303,6 +531,7 @@ describe("handleSetupGet", () => {
303
531
  db,
304
532
  manifestPath: h.manifestPath,
305
533
  configDir: h.dir,
534
+ readExposeStateFn: h.readExposeStateFn,
306
535
  issuer: "https://hub.example",
307
536
  registry: getDefaultOperationsRegistry(),
308
537
  });
@@ -353,6 +582,7 @@ describe("handleSetupGet", () => {
353
582
  db,
354
583
  manifestPath: h.manifestPath,
355
584
  configDir: h.dir,
585
+ readExposeStateFn: h.readExposeStateFn,
356
586
  issuer: "https://hub.example",
357
587
  registry: getDefaultOperationsRegistry(),
358
588
  });
@@ -385,6 +615,7 @@ describe("handleSetupGet", () => {
385
615
  db,
386
616
  manifestPath: h.manifestPath,
387
617
  configDir: h.dir,
618
+ readExposeStateFn: h.readExposeStateFn,
388
619
  issuer: "https://hub.example",
389
620
  registry: getDefaultOperationsRegistry(),
390
621
  });
@@ -433,6 +664,7 @@ describe("handleSetupGet", () => {
433
664
  db,
434
665
  manifestPath: h.manifestPath,
435
666
  configDir: h.dir,
667
+ readExposeStateFn: h.readExposeStateFn,
436
668
  issuer: "https://hub.example",
437
669
  registry: getDefaultOperationsRegistry(),
438
670
  },
@@ -482,6 +714,7 @@ describe("handleSetupGet", () => {
482
714
  db,
483
715
  manifestPath: h.manifestPath,
484
716
  configDir: h.dir,
717
+ readExposeStateFn: h.readExposeStateFn,
485
718
  issuer: "https://hub.example",
486
719
  registry: getDefaultOperationsRegistry(),
487
720
  },
@@ -523,6 +756,7 @@ describe("handleSetupGet", () => {
523
756
  db,
524
757
  manifestPath: h.manifestPath,
525
758
  configDir: h.dir,
759
+ readExposeStateFn: h.readExposeStateFn,
526
760
  issuer: "https://hub.example",
527
761
  registry: getDefaultOperationsRegistry(),
528
762
  },
@@ -547,6 +781,7 @@ describe("handleSetupGet", () => {
547
781
  db,
548
782
  manifestPath: h.manifestPath,
549
783
  configDir: h.dir,
784
+ readExposeStateFn: h.readExposeStateFn,
550
785
  issuer: "https://hub.example",
551
786
  registry: reg,
552
787
  });
@@ -576,6 +811,7 @@ describe("handleSetupGet", () => {
576
811
  db,
577
812
  manifestPath: h.manifestPath,
578
813
  configDir: h.dir,
814
+ readExposeStateFn: h.readExposeStateFn,
579
815
  issuer: "https://hub.example",
580
816
  registry: reg,
581
817
  });
@@ -607,6 +843,7 @@ describe("handleSetupAccountPost", () => {
607
843
  db,
608
844
  manifestPath: h.manifestPath,
609
845
  configDir: h.dir,
846
+ readExposeStateFn: h.readExposeStateFn,
610
847
  issuer: "https://hub.example",
611
848
  registry: getDefaultOperationsRegistry(),
612
849
  });
@@ -631,6 +868,7 @@ describe("handleSetupAccountPost", () => {
631
868
  db,
632
869
  manifestPath: h.manifestPath,
633
870
  configDir: h.dir,
871
+ readExposeStateFn: h.readExposeStateFn,
634
872
  issuer: "https://hub.example",
635
873
  registry: getDefaultOperationsRegistry(),
636
874
  },
@@ -659,6 +897,7 @@ describe("handleSetupAccountPost", () => {
659
897
  db,
660
898
  manifestPath: h.manifestPath,
661
899
  configDir: h.dir,
900
+ readExposeStateFn: h.readExposeStateFn,
662
901
  issuer: "https://hub.example",
663
902
  registry: getDefaultOperationsRegistry(),
664
903
  });
@@ -679,6 +918,7 @@ describe("handleSetupAccountPost", () => {
679
918
  db,
680
919
  manifestPath: h.manifestPath,
681
920
  configDir: h.dir,
921
+ readExposeStateFn: h.readExposeStateFn,
682
922
  issuer: "https://hub.example",
683
923
  registry: getDefaultOperationsRegistry(),
684
924
  },
@@ -711,6 +951,7 @@ describe("handleSetupAccountPost", () => {
711
951
  db,
712
952
  manifestPath: h.manifestPath,
713
953
  configDir: h.dir,
954
+ readExposeStateFn: h.readExposeStateFn,
714
955
  issuer: "https://hub.example",
715
956
  registry: getDefaultOperationsRegistry(),
716
957
  },
@@ -730,6 +971,7 @@ describe("handleSetupAccountPost", () => {
730
971
  db,
731
972
  manifestPath: h.manifestPath,
732
973
  configDir: h.dir,
974
+ readExposeStateFn: h.readExposeStateFn,
733
975
  issuer: "https://hub.example",
734
976
  registry: getDefaultOperationsRegistry(),
735
977
  });
@@ -750,6 +992,7 @@ describe("handleSetupAccountPost", () => {
750
992
  db,
751
993
  manifestPath: h.manifestPath,
752
994
  configDir: h.dir,
995
+ readExposeStateFn: h.readExposeStateFn,
753
996
  issuer: "https://hub.example",
754
997
  registry: getDefaultOperationsRegistry(),
755
998
  },
@@ -774,10 +1017,15 @@ describe("handleSetupVaultPost", () => {
774
1017
  });
775
1018
  afterEach(() => h.cleanup());
776
1019
 
777
- test("requires a supervisor (CLI mode rejects)", async () => {
1020
+ test("requires a supervisor (CLI mode rejects create/import; allows skip — hub#168 Cut 2)", async () => {
778
1021
  const db = openHubDb(hubDbPath(h.dir));
779
1022
  try {
780
1023
  await createUser(db, "owner", "pw");
1024
+ // Bare POST (no CSRF, no session) still 400s, but on the new
1025
+ // CSRF-first ordering it stops at the CSRF check rather than the
1026
+ // supervisor check. That's correct posture — refuse the
1027
+ // unauthenticated request before tendering an architectural
1028
+ // explanation.
781
1029
  const post = await handleSetupVaultPost(
782
1030
  req("/admin/setup/vault", {
783
1031
  method: "POST",
@@ -788,13 +1036,15 @@ describe("handleSetupVaultPost", () => {
788
1036
  db,
789
1037
  manifestPath: h.manifestPath,
790
1038
  configDir: h.dir,
1039
+ readExposeStateFn: h.readExposeStateFn,
791
1040
  issuer: "https://hub.example",
792
1041
  registry: getDefaultOperationsRegistry(),
793
1042
  },
794
1043
  );
795
1044
  expect(post.status).toBe(400);
796
1045
  const html = await post.text();
797
- expect(html).toContain("supervisor unavailable");
1046
+ // CSRF-first: the bare request bounces at the CSRF gate.
1047
+ expect(html).toContain("Invalid form submission");
798
1048
  } finally {
799
1049
  db.close();
800
1050
  }
@@ -808,6 +1058,7 @@ describe("handleSetupVaultPost", () => {
808
1058
  db,
809
1059
  manifestPath: h.manifestPath,
810
1060
  configDir: h.dir,
1061
+ readExposeStateFn: h.readExposeStateFn,
811
1062
  issuer: "https://hub.example",
812
1063
  registry: getDefaultOperationsRegistry(),
813
1064
  });
@@ -827,6 +1078,7 @@ describe("handleSetupVaultPost", () => {
827
1078
  db,
828
1079
  manifestPath: h.manifestPath,
829
1080
  configDir: h.dir,
1081
+ readExposeStateFn: h.readExposeStateFn,
830
1082
  issuer: "https://hub.example",
831
1083
  supervisor: makeSupervisor(),
832
1084
  registry: getDefaultOperationsRegistry(),
@@ -856,6 +1108,7 @@ describe("handleSetupVaultPost", () => {
856
1108
  db,
857
1109
  manifestPath: h.manifestPath,
858
1110
  configDir: h.dir,
1111
+ readExposeStateFn: h.readExposeStateFn,
859
1112
  issuer: "https://hub.example",
860
1113
  registry: getDefaultOperationsRegistry(),
861
1114
  });
@@ -880,6 +1133,7 @@ describe("handleSetupVaultPost", () => {
880
1133
  db,
881
1134
  manifestPath: h.manifestPath,
882
1135
  configDir: h.dir,
1136
+ readExposeStateFn: h.readExposeStateFn,
883
1137
  issuer: "https://hub.example",
884
1138
  supervisor: makeSupervisor(),
885
1139
  registry: getDefaultOperationsRegistry(),
@@ -933,6 +1187,7 @@ describe("handleSetupVaultPost", () => {
933
1187
  db,
934
1188
  manifestPath: h.manifestPath,
935
1189
  configDir: h.dir,
1190
+ readExposeStateFn: h.readExposeStateFn,
936
1191
  issuer: "https://hub.example",
937
1192
  registry: getDefaultOperationsRegistry(),
938
1193
  });
@@ -959,6 +1214,7 @@ describe("handleSetupVaultPost", () => {
959
1214
  db,
960
1215
  manifestPath: h.manifestPath,
961
1216
  configDir: h.dir,
1217
+ readExposeStateFn: h.readExposeStateFn,
962
1218
  issuer: "https://hub.example",
963
1219
  supervisor: makeSupervisor(),
964
1220
  registry: getDefaultOperationsRegistry(),
@@ -1002,6 +1258,7 @@ describe("handleSetupVaultPost", () => {
1002
1258
  db,
1003
1259
  manifestPath: h.manifestPath,
1004
1260
  configDir: h.dir,
1261
+ readExposeStateFn: h.readExposeStateFn,
1005
1262
  issuer: "https://hub.example",
1006
1263
  registry: getDefaultOperationsRegistry(),
1007
1264
  });
@@ -1027,6 +1284,7 @@ describe("handleSetupVaultPost", () => {
1027
1284
  db,
1028
1285
  manifestPath: h.manifestPath,
1029
1286
  configDir: h.dir,
1287
+ readExposeStateFn: h.readExposeStateFn,
1030
1288
  issuer: "https://hub.example",
1031
1289
  supervisor: makeSupervisor(),
1032
1290
  registry: getDefaultOperationsRegistry(),
@@ -1063,6 +1321,7 @@ describe("handleSetupVaultPost", () => {
1063
1321
  db,
1064
1322
  manifestPath: h.manifestPath,
1065
1323
  configDir: h.dir,
1324
+ readExposeStateFn: h.readExposeStateFn,
1066
1325
  issuer: "https://hub.example",
1067
1326
  registry: getDefaultOperationsRegistry(),
1068
1327
  });
@@ -1092,6 +1351,7 @@ describe("handleSetupVaultPost", () => {
1092
1351
  db,
1093
1352
  manifestPath: h.manifestPath,
1094
1353
  configDir: h.dir,
1354
+ readExposeStateFn: h.readExposeStateFn,
1095
1355
  issuer: "https://hub.example",
1096
1356
  supervisor,
1097
1357
  registry: getDefaultOperationsRegistry(),
@@ -1146,6 +1406,7 @@ describe("handleSetupVaultPost", () => {
1146
1406
  db,
1147
1407
  manifestPath: h.manifestPath,
1148
1408
  configDir: h.dir,
1409
+ readExposeStateFn: h.readExposeStateFn,
1149
1410
  issuer: "https://hub.example",
1150
1411
  registry: getDefaultOperationsRegistry(),
1151
1412
  });
@@ -1171,6 +1432,7 @@ describe("handleSetupVaultPost", () => {
1171
1432
  db,
1172
1433
  manifestPath: h.manifestPath,
1173
1434
  configDir: h.dir,
1435
+ readExposeStateFn: h.readExposeStateFn,
1174
1436
  issuer: "https://hub.example",
1175
1437
  supervisor: makeSupervisor(),
1176
1438
  registry: getDefaultOperationsRegistry(),
@@ -1425,6 +1687,7 @@ describe("handleSetupExposePost", () => {
1425
1687
  db,
1426
1688
  manifestPath: h.manifestPath,
1427
1689
  configDir: h.dir,
1690
+ readExposeStateFn: h.readExposeStateFn,
1428
1691
  issuer: "https://hub.example",
1429
1692
  registry: getDefaultOperationsRegistry(),
1430
1693
  });
@@ -1453,6 +1716,7 @@ describe("handleSetupExposePost", () => {
1453
1716
  db,
1454
1717
  manifestPath: h.manifestPath,
1455
1718
  configDir: h.dir,
1719
+ readExposeStateFn: h.readExposeStateFn,
1456
1720
  issuer: "https://hub.example",
1457
1721
  registry: getDefaultOperationsRegistry(),
1458
1722
  },
@@ -1488,6 +1752,7 @@ describe("handleSetupExposePost", () => {
1488
1752
  db,
1489
1753
  manifestPath: h.manifestPath,
1490
1754
  configDir: h.dir,
1755
+ readExposeStateFn: h.readExposeStateFn,
1491
1756
  issuer: "https://hub.example",
1492
1757
  registry: getDefaultOperationsRegistry(),
1493
1758
  },
@@ -1526,6 +1791,7 @@ describe("handleSetupExposePost", () => {
1526
1791
  db,
1527
1792
  manifestPath: h.manifestPath,
1528
1793
  configDir: h.dir,
1794
+ readExposeStateFn: h.readExposeStateFn,
1529
1795
  issuer: "https://hub.example",
1530
1796
  registry: getDefaultOperationsRegistry(),
1531
1797
  },
@@ -1560,6 +1826,7 @@ describe("handleSetupExposePost", () => {
1560
1826
  db,
1561
1827
  manifestPath: h.manifestPath,
1562
1828
  configDir: h.dir,
1829
+ readExposeStateFn: h.readExposeStateFn,
1563
1830
  issuer: "https://hub.example",
1564
1831
  registry: getDefaultOperationsRegistry(),
1565
1832
  },
@@ -1596,6 +1863,7 @@ describe("handleSetupExposePost", () => {
1596
1863
  db,
1597
1864
  manifestPath: h.manifestPath,
1598
1865
  configDir: h.dir,
1866
+ readExposeStateFn: h.readExposeStateFn,
1599
1867
  issuer: "https://hub.example",
1600
1868
  registry: getDefaultOperationsRegistry(),
1601
1869
  },
@@ -1646,6 +1914,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1646
1914
  db,
1647
1915
  manifestPath: h.manifestPath,
1648
1916
  configDir: h.dir,
1917
+ readExposeStateFn: h.readExposeStateFn,
1649
1918
  issuer: "https://hub.example",
1650
1919
  registry: getDefaultOperationsRegistry(),
1651
1920
  });
@@ -1674,6 +1943,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1674
1943
  db,
1675
1944
  manifestPath: h.manifestPath,
1676
1945
  configDir: h.dir,
1946
+ readExposeStateFn: h.readExposeStateFn,
1677
1947
  issuer: "https://hub.example",
1678
1948
  registry: getDefaultOperationsRegistry(),
1679
1949
  },
@@ -1720,6 +1990,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1720
1990
  db,
1721
1991
  manifestPath: h.manifestPath,
1722
1992
  configDir: h.dir,
1993
+ readExposeStateFn: h.readExposeStateFn,
1723
1994
  issuer: "https://hub.example",
1724
1995
  registry: getDefaultOperationsRegistry(),
1725
1996
  },
@@ -1785,16 +2056,21 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1785
2056
  db,
1786
2057
  manifestPath: h.manifestPath,
1787
2058
  configDir: h.dir,
2059
+ readExposeStateFn: h.readExposeStateFn,
1788
2060
  issuer: "https://hub.example",
1789
2061
  registry: getDefaultOperationsRegistry(),
1790
2062
  },
1791
2063
  );
1792
2064
  const html = await res.text();
1793
2065
  expect(html).toContain("claude mcp add --transport http parachute-default");
1794
- // The fallback explanatory text mentions `pvt_...` as a placeholder
1795
- // but the actual `--header` flag must NOT be appended to the
1796
- // command line itself.
1797
- expect(html).toContain("Bearer pvt_");
2066
+ // The fallback explanatory text leads with the OAuth path (no token
2067
+ // needed) and, for headless clients, references a hub JWT placeholder
2068
+ // NOT the retired `pvt_*` format (gap #4). The `--header` flag must
2069
+ // also NOT be appended to the command line itself.
2070
+ expect(html).toContain("browser OAuth");
2071
+ expect(html).toContain("Bearer &lt;token&gt;");
2072
+ expect(html).not.toContain("pvt_");
2073
+ expect(html).toContain("parachute auth mint-token");
1798
2074
  expect(html).toContain("/admin/tokens");
1799
2075
  // Specifically no Copy button — that's a token-present surface.
1800
2076
  expect(html).not.toContain('id="mcp-cmd"');
@@ -1829,6 +2105,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1829
2105
  db,
1830
2106
  manifestPath: h.manifestPath,
1831
2107
  configDir: h.dir,
2108
+ readExposeStateFn: h.readExposeStateFn,
1832
2109
  issuer: "https://hub.example",
1833
2110
  registry: getDefaultOperationsRegistry(),
1834
2111
  };
@@ -1885,6 +2162,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1885
2162
  db,
1886
2163
  manifestPath: h.manifestPath,
1887
2164
  configDir: h.dir,
2165
+ readExposeStateFn: h.readExposeStateFn,
1888
2166
  issuer: "https://hub.example",
1889
2167
  registry: getDefaultOperationsRegistry(),
1890
2168
  },
@@ -1950,6 +2228,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
1950
2228
  db,
1951
2229
  manifestPath: h.manifestPath,
1952
2230
  configDir: h.dir,
2231
+ readExposeStateFn: h.readExposeStateFn,
1953
2232
  issuer: "https://hub.example",
1954
2233
  registry: getDefaultOperationsRegistry(),
1955
2234
  },
@@ -2008,6 +2287,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2008
2287
  db,
2009
2288
  manifestPath: h.manifestPath,
2010
2289
  configDir: h.dir,
2290
+ readExposeStateFn: h.readExposeStateFn,
2011
2291
  issuer: "https://hub.example",
2012
2292
  registry: getDefaultOperationsRegistry(),
2013
2293
  },
@@ -2069,6 +2349,7 @@ describe("done screen auto-minted token (hub#272 Item A)", () => {
2069
2349
  db,
2070
2350
  manifestPath: h.manifestPath,
2071
2351
  configDir: h.dir,
2352
+ readExposeStateFn: h.readExposeStateFn,
2072
2353
  issuer: "https://hub.example",
2073
2354
  registry: getDefaultOperationsRegistry(),
2074
2355
  });
@@ -2132,6 +2413,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2132
2413
  db,
2133
2414
  manifestPath: h.manifestPath,
2134
2415
  configDir: h.dir,
2416
+ readExposeStateFn: h.readExposeStateFn,
2135
2417
  issuer: "https://hub.example",
2136
2418
  registry: getDefaultOperationsRegistry(),
2137
2419
  },
@@ -2162,6 +2444,9 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2162
2444
  const db = openHubDb(hubDbPath(h.dir));
2163
2445
  try {
2164
2446
  const user = await createUser(db, "owner", "pw");
2447
+ // Seed services.json with `parachute-scribe` so the wizard's scribe
2448
+ // install tile renders the already-installed shape. Post-2026-05-27
2449
+ // CURATED trim scribe is the only non-vault install tile.
2165
2450
  writeManifest(
2166
2451
  {
2167
2452
  services: [
@@ -2172,15 +2457,12 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2172
2457
  paths: ["/vault/default"],
2173
2458
  health: "/health",
2174
2459
  },
2175
- // hub#323: app replaces notes as the wizard's first install tile.
2176
- // Seeding services.json with `parachute-app` exercises the
2177
- // already-installed render path on the wizard's first tile.
2178
2460
  {
2179
- name: "parachute-surface",
2180
- version: "0.2.0",
2181
- port: 1946,
2182
- paths: ["/app", "/.parachute"],
2183
- health: "/surface/healthz",
2461
+ name: "parachute-scribe",
2462
+ version: "0.4.4",
2463
+ port: 1943,
2464
+ paths: ["/scribe"],
2465
+ health: "/scribe/health",
2184
2466
  },
2185
2467
  ],
2186
2468
  },
@@ -2197,19 +2479,23 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2197
2479
  db,
2198
2480
  manifestPath: h.manifestPath,
2199
2481
  configDir: h.dir,
2482
+ readExposeStateFn: h.readExposeStateFn,
2200
2483
  issuer: "https://hub.example",
2201
2484
  registry: getDefaultOperationsRegistry(),
2202
2485
  },
2203
2486
  );
2204
2487
  const html = await res.text();
2205
2488
  expect(html).toContain("Already installed");
2206
- expect(html).toContain('action="/admin/setup/install/scribe"');
2489
+ // The scribe tile rendered the installed shape, not the install form.
2490
+ expect(html).not.toContain('action="/admin/setup/install/scribe"');
2491
+ // "Manage in admin" is the secondary link on the already-installed tile.
2492
+ expect(html).toContain("Manage in admin");
2207
2493
  } finally {
2208
2494
  db.close();
2209
2495
  }
2210
2496
  });
2211
2497
 
2212
- test("done screen renders op-poll panel when ?op_surface=<id> matches a registry op", async () => {
2498
+ test("done screen renders op-poll panel when ?op_scribe=<id> matches a registry op", async () => {
2213
2499
  const db = openHubDb(hubDbPath(h.dir));
2214
2500
  try {
2215
2501
  const user = await createUser(db, "owner", "pw");
@@ -2229,21 +2515,23 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2229
2515
  );
2230
2516
  setSetting(db, "setup_expose_mode", "localhost");
2231
2517
  const reg = getDefaultOperationsRegistry();
2232
- // hub#323: op-poll panel rides on the `app` tile now (app is the wizard's
2233
- // first install tile post-Notes-as-app-migration). Same shape as the
2234
- // pre-#324 `op_notes=<id>` flow.
2235
- const op = reg.create("install", "app");
2236
- reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/app@latest");
2518
+ // Post-2026-05-27 CURATED trim, scribe is the only non-vault wizard
2519
+ // install tile, so it carries the op-poll panel. Same shape as the
2520
+ // prior `op_app=<id>` / `op_notes=<id>` flows — the rendering code
2521
+ // is per-`?op_<short>=<id>` query and tile-row agnostic.
2522
+ const op = reg.create("install", "scribe");
2523
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/scribe@latest");
2237
2524
  const { createSession } = await import("../sessions.ts");
2238
2525
  const session = createSession(db, { userId: user.id });
2239
2526
  const res = handleSetupGet(
2240
- req(`/admin/setup?just_finished=1&op_surface=${op.id}`, {
2527
+ req(`/admin/setup?just_finished=1&op_scribe=${op.id}`, {
2241
2528
  headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
2242
2529
  }),
2243
2530
  {
2244
2531
  db,
2245
2532
  manifestPath: h.manifestPath,
2246
2533
  configDir: h.dir,
2534
+ readExposeStateFn: h.readExposeStateFn,
2247
2535
  issuer: "https://hub.example",
2248
2536
  registry: reg,
2249
2537
  },
@@ -2283,6 +2571,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2283
2571
  db,
2284
2572
  manifestPath: h.manifestPath,
2285
2573
  configDir: h.dir,
2574
+ readExposeStateFn: h.readExposeStateFn,
2286
2575
  issuer: "https://hub.example",
2287
2576
  registry: getDefaultOperationsRegistry(),
2288
2577
  });
@@ -2293,7 +2582,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2293
2582
  return 0;
2294
2583
  };
2295
2584
  const post = await handleSetupInstallPost(
2296
- req("/admin/setup/install/notes", {
2585
+ req("/admin/setup/install/scribe", {
2297
2586
  method: "POST",
2298
2587
  body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
2299
2588
  headers: {
@@ -2301,11 +2590,12 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2301
2590
  cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
2302
2591
  },
2303
2592
  }),
2304
- "notes",
2593
+ "scribe",
2305
2594
  {
2306
2595
  db,
2307
2596
  manifestPath: h.manifestPath,
2308
2597
  configDir: h.dir,
2598
+ readExposeStateFn: h.readExposeStateFn,
2309
2599
  issuer: "https://hub.example",
2310
2600
  supervisor: makeSupervisor(),
2311
2601
  registry: getDefaultOperationsRegistry(),
@@ -2315,10 +2605,10 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2315
2605
  );
2316
2606
  expect(post.status).toBe(303);
2317
2607
  const location = post.headers.get("location") ?? "";
2318
- expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_notes=/);
2608
+ expect(location).toMatch(/^\/admin\/setup\?just_finished=1&op_scribe=/);
2319
2609
  await new Promise((r) => setTimeout(r, 50));
2320
2610
  expect(runCalls.length).toBeGreaterThan(0);
2321
- expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/notes@latest");
2611
+ expect(runCalls[0]?.join(" ")).toContain("bun add -g @openparachute/scribe@latest");
2322
2612
  } finally {
2323
2613
  db.close();
2324
2614
  }
@@ -2334,6 +2624,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2334
2624
  db,
2335
2625
  manifestPath: h.manifestPath,
2336
2626
  configDir: h.dir,
2627
+ readExposeStateFn: h.readExposeStateFn,
2337
2628
  issuer: "https://hub.example",
2338
2629
  registry: getDefaultOperationsRegistry(),
2339
2630
  });
@@ -2352,6 +2643,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2352
2643
  db,
2353
2644
  manifestPath: h.manifestPath,
2354
2645
  configDir: h.dir,
2646
+ readExposeStateFn: h.readExposeStateFn,
2355
2647
  issuer: "https://hub.example",
2356
2648
  supervisor: makeSupervisor(),
2357
2649
  registry: getDefaultOperationsRegistry(),
@@ -2379,6 +2671,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2379
2671
  db,
2380
2672
  manifestPath: h.manifestPath,
2381
2673
  configDir: h.dir,
2674
+ readExposeStateFn: h.readExposeStateFn,
2382
2675
  issuer: "https://hub.example",
2383
2676
  supervisor: makeSupervisor(),
2384
2677
  registry: getDefaultOperationsRegistry(),
@@ -2400,12 +2693,13 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2400
2693
  db,
2401
2694
  manifestPath: h.manifestPath,
2402
2695
  configDir: h.dir,
2696
+ readExposeStateFn: h.readExposeStateFn,
2403
2697
  issuer: "https://hub.example",
2404
2698
  registry: getDefaultOperationsRegistry(),
2405
2699
  });
2406
2700
  const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
2407
2701
  const post = await handleSetupInstallPost(
2408
- req("/admin/setup/install/notes", {
2702
+ req("/admin/setup/install/scribe", {
2409
2703
  method: "POST",
2410
2704
  body: new URLSearchParams({ [CSRF_FIELD_NAME]: csrf }).toString(),
2411
2705
  headers: {
@@ -2413,11 +2707,12 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2413
2707
  cookie: `${CSRF_COOKIE_NAME}=${csrf}`,
2414
2708
  },
2415
2709
  }),
2416
- "notes",
2710
+ "scribe",
2417
2711
  {
2418
2712
  db,
2419
2713
  manifestPath: h.manifestPath,
2420
2714
  configDir: h.dir,
2715
+ readExposeStateFn: h.readExposeStateFn,
2421
2716
  issuer: "https://hub.example",
2422
2717
  supervisor: makeSupervisor(),
2423
2718
  registry: getDefaultOperationsRegistry(),
@@ -2446,6 +2741,7 @@ describe("done screen install tiles (hub#272 Item B)", () => {
2446
2741
  db,
2447
2742
  manifestPath: h.manifestPath,
2448
2743
  configDir: h.dir,
2744
+ readExposeStateFn: h.readExposeStateFn,
2449
2745
  issuer: "https://hub.example",
2450
2746
  registry: getDefaultOperationsRegistry(),
2451
2747
  },
@@ -2479,6 +2775,7 @@ describe("typed vault name (hub#267)", () => {
2479
2775
  db,
2480
2776
  manifestPath: h.manifestPath,
2481
2777
  configDir: h.dir,
2778
+ readExposeStateFn: h.readExposeStateFn,
2482
2779
  issuer: "https://hub.example",
2483
2780
  registry: getDefaultOperationsRegistry(),
2484
2781
  });
@@ -2521,6 +2818,7 @@ describe("typed vault name (hub#267)", () => {
2521
2818
  db,
2522
2819
  manifestPath: h.manifestPath,
2523
2820
  configDir: h.dir,
2821
+ readExposeStateFn: h.readExposeStateFn,
2524
2822
  issuer: "https://hub.example",
2525
2823
  supervisor,
2526
2824
  registry: getDefaultOperationsRegistry(),
@@ -2554,6 +2852,7 @@ describe("typed vault name (hub#267)", () => {
2554
2852
  db,
2555
2853
  manifestPath: h.manifestPath,
2556
2854
  configDir: h.dir,
2855
+ readExposeStateFn: h.readExposeStateFn,
2557
2856
  issuer: "https://hub.example",
2558
2857
  registry: getDefaultOperationsRegistry(),
2559
2858
  });
@@ -2574,6 +2873,7 @@ describe("typed vault name (hub#267)", () => {
2574
2873
  db,
2575
2874
  manifestPath: h.manifestPath,
2576
2875
  configDir: h.dir,
2876
+ readExposeStateFn: h.readExposeStateFn,
2577
2877
  issuer: "https://hub.example",
2578
2878
  supervisor: makeSupervisor(),
2579
2879
  registry: getDefaultOperationsRegistry(),
@@ -2599,6 +2899,7 @@ describe("typed vault name (hub#267)", () => {
2599
2899
  db,
2600
2900
  manifestPath: h.manifestPath,
2601
2901
  configDir: h.dir,
2902
+ readExposeStateFn: h.readExposeStateFn,
2602
2903
  issuer: "https://hub.example",
2603
2904
  registry: getDefaultOperationsRegistry(),
2604
2905
  });
@@ -2639,6 +2940,7 @@ describe("typed vault name (hub#267)", () => {
2639
2940
  db,
2640
2941
  manifestPath: h.manifestPath,
2641
2942
  configDir: h.dir,
2943
+ readExposeStateFn: h.readExposeStateFn,
2642
2944
  issuer: "https://hub.example",
2643
2945
  supervisor,
2644
2946
  registry: getDefaultOperationsRegistry(),
@@ -2701,6 +3003,7 @@ describe("typed vault name (hub#267)", () => {
2701
3003
  db,
2702
3004
  manifestPath: h.manifestPath,
2703
3005
  configDir: h.dir,
3006
+ readExposeStateFn: h.readExposeStateFn,
2704
3007
  issuer: "https://hub.example",
2705
3008
  registry: getDefaultOperationsRegistry(),
2706
3009
  },
@@ -2752,6 +3055,7 @@ describe("typed vault name (hub#267)", () => {
2752
3055
  db,
2753
3056
  manifestPath: h.manifestPath,
2754
3057
  configDir: h.dir,
3058
+ readExposeStateFn: h.readExposeStateFn,
2755
3059
  issuer: "https://hub.example",
2756
3060
  registry: getDefaultOperationsRegistry(),
2757
3061
  },
@@ -2803,6 +3107,7 @@ describe("typed vault name (hub#267)", () => {
2803
3107
  db,
2804
3108
  manifestPath: h.manifestPath,
2805
3109
  configDir: h.dir,
3110
+ readExposeStateFn: h.readExposeStateFn,
2806
3111
  issuer: "https://hub.example",
2807
3112
  registry: getDefaultOperationsRegistry(),
2808
3113
  },
@@ -2873,6 +3178,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2873
3178
  db,
2874
3179
  manifestPath: h.manifestPath,
2875
3180
  configDir: h.dir,
3181
+ readExposeStateFn: h.readExposeStateFn,
2876
3182
  issuer: "https://hub.example",
2877
3183
  registry: getDefaultOperationsRegistry(),
2878
3184
  });
@@ -2897,6 +3203,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2897
3203
  db,
2898
3204
  manifestPath: h.manifestPath,
2899
3205
  configDir: h.dir,
3206
+ readExposeStateFn: h.readExposeStateFn,
2900
3207
  issuer: "https://hub.example",
2901
3208
  registry: getDefaultOperationsRegistry(),
2902
3209
  });
@@ -2922,6 +3229,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2922
3229
  db,
2923
3230
  manifestPath: h.manifestPath,
2924
3231
  configDir: h.dir,
3232
+ readExposeStateFn: h.readExposeStateFn,
2925
3233
  issuer: "https://hub.example",
2926
3234
  registry: getDefaultOperationsRegistry(),
2927
3235
  });
@@ -2943,6 +3251,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2943
3251
  db,
2944
3252
  manifestPath: h.manifestPath,
2945
3253
  configDir: h.dir,
3254
+ readExposeStateFn: h.readExposeStateFn,
2946
3255
  issuer: "https://hub.example",
2947
3256
  registry: getDefaultOperationsRegistry(),
2948
3257
  },
@@ -2965,6 +3274,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2965
3274
  db,
2966
3275
  manifestPath: h.manifestPath,
2967
3276
  configDir: h.dir,
3277
+ readExposeStateFn: h.readExposeStateFn,
2968
3278
  issuer: "https://hub.example",
2969
3279
  registry: getDefaultOperationsRegistry(),
2970
3280
  });
@@ -2986,6 +3296,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
2986
3296
  db,
2987
3297
  manifestPath: h.manifestPath,
2988
3298
  configDir: h.dir,
3299
+ readExposeStateFn: h.readExposeStateFn,
2989
3300
  issuer: "https://hub.example",
2990
3301
  registry: getDefaultOperationsRegistry(),
2991
3302
  },
@@ -3015,6 +3326,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3015
3326
  db,
3016
3327
  manifestPath: h.manifestPath,
3017
3328
  configDir: h.dir,
3329
+ readExposeStateFn: h.readExposeStateFn,
3018
3330
  issuer: "https://hub.example",
3019
3331
  registry: getDefaultOperationsRegistry(),
3020
3332
  });
@@ -3036,6 +3348,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3036
3348
  db,
3037
3349
  manifestPath: h.manifestPath,
3038
3350
  configDir: h.dir,
3351
+ readExposeStateFn: h.readExposeStateFn,
3039
3352
  issuer: "https://hub.example",
3040
3353
  registry: getDefaultOperationsRegistry(),
3041
3354
  },
@@ -3063,6 +3376,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3063
3376
  db,
3064
3377
  manifestPath: h.manifestPath,
3065
3378
  configDir: h.dir,
3379
+ readExposeStateFn: h.readExposeStateFn,
3066
3380
  issuer: "https://hub.example",
3067
3381
  registry: getDefaultOperationsRegistry(),
3068
3382
  });
@@ -3084,6 +3398,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3084
3398
  db,
3085
3399
  manifestPath: h.manifestPath,
3086
3400
  configDir: h.dir,
3401
+ readExposeStateFn: h.readExposeStateFn,
3087
3402
  issuer: "https://hub.example",
3088
3403
  registry: getDefaultOperationsRegistry(),
3089
3404
  },
@@ -3109,6 +3424,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3109
3424
  db,
3110
3425
  manifestPath: h.manifestPath,
3111
3426
  configDir: h.dir,
3427
+ readExposeStateFn: h.readExposeStateFn,
3112
3428
  issuer: "https://hub.example",
3113
3429
  registry: getDefaultOperationsRegistry(),
3114
3430
  });
@@ -3129,6 +3445,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3129
3445
  db,
3130
3446
  manifestPath: h.manifestPath,
3131
3447
  configDir: h.dir,
3448
+ readExposeStateFn: h.readExposeStateFn,
3132
3449
  issuer: "https://hub.example",
3133
3450
  registry: getDefaultOperationsRegistry(),
3134
3451
  },
@@ -3175,6 +3492,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3175
3492
  db,
3176
3493
  manifestPath: h.manifestPath,
3177
3494
  configDir: h.dir,
3495
+ readExposeStateFn: h.readExposeStateFn,
3178
3496
  issuer: "https://hub.example",
3179
3497
  registry: getDefaultOperationsRegistry(),
3180
3498
  });
@@ -3196,6 +3514,7 @@ describe("bootstrap token gate (handleSetupAccountPost)", () => {
3196
3514
  db,
3197
3515
  manifestPath: h.manifestPath,
3198
3516
  configDir: h.dir,
3517
+ readExposeStateFn: h.readExposeStateFn,
3199
3518
  issuer: "https://hub.example",
3200
3519
  registry: getDefaultOperationsRegistry(),
3201
3520
  };
@@ -3279,6 +3598,7 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3279
3598
  db,
3280
3599
  manifestPath: h.manifestPath,
3281
3600
  configDir: h.dir,
3601
+ readExposeStateFn: h.readExposeStateFn,
3282
3602
  issuer: "https://hub.example",
3283
3603
  registry: getDefaultOperationsRegistry(),
3284
3604
  },
@@ -3296,7 +3616,14 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3296
3616
  }
3297
3617
  });
3298
3618
 
3299
- test("when app is also installed, the lead tile links to /surface/notes/", async () => {
3619
+ test("lead tile always points at notes.parachute.computer (canonical hosted PWA) regardless of local module installs", async () => {
3620
+ // Pre-2026-05-27 the lead tile flipped to `/surface/notes/` when the
3621
+ // Surface module was installed locally. Aaron's launch-focus
3622
+ // directive: notes.parachute.computer is the canonical user-facing
3623
+ // UI, and the wizard should always point operators at it (rather
3624
+ // than maybe-or-maybe-not-installed local Surface). This test pins
3625
+ // that the lead tile is invariant under the install state of
3626
+ // uncurated modules.
3300
3627
  const db = openHubDb(hubDbPath(h.dir));
3301
3628
  try {
3302
3629
  const user = await createUser(db, "owner", "pw");
@@ -3310,6 +3637,9 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3310
3637
  paths: ["/vault/default"],
3311
3638
  health: "/health",
3312
3639
  },
3640
+ // Even with parachute-surface installed locally (an uncurated
3641
+ // module post-trim), the lead tile must NOT flip to a local
3642
+ // path.
3313
3643
  {
3314
3644
  name: "parachute-surface",
3315
3645
  version: "0.2.0",
@@ -3332,21 +3662,34 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3332
3662
  db,
3333
3663
  manifestPath: h.manifestPath,
3334
3664
  configDir: h.dir,
3665
+ readExposeStateFn: h.readExposeStateFn,
3335
3666
  issuer: "https://hub.example",
3336
3667
  registry: getDefaultOperationsRegistry(),
3337
3668
  },
3338
3669
  );
3339
3670
  const html = await res.text();
3340
3671
  expect(html).toContain("Start using your vault");
3341
- // App installed → primary CTA links to Notes-as-UI inside App.
3342
- expect(html).toContain('href="/surface/notes/"');
3672
+ // Lead CTA always targets the hosted PWA.
3673
+ expect(html).toContain("https://notes.parachute.computer/add?url=");
3343
3674
  expect(html).toContain("Open Notes");
3675
+ // The pre-trim local-surface fallback is gone — the lead tile does
3676
+ // NOT link to /surface/notes/ anymore.
3677
+ expect(html).not.toContain('href="/surface/notes/"');
3344
3678
  } finally {
3345
3679
  db.close();
3346
3680
  }
3347
3681
  });
3348
3682
 
3349
- test("succeeded install op renders a 'Use it now' link pointing at the module's surface", async () => {
3683
+ test("succeeded install op renders 'Manage modules' link (no 'Use it now' for modules without a hosted surface)", async () => {
3684
+ // Pre-2026-05-27 the surface module had a USE_IT_NOW_URLS entry
3685
+ // pointing at `/surface/notes/`, so a succeeded surface install tile
3686
+ // rendered a primary "Use it now" link. Post-trim only scribe + vault
3687
+ // are curated; vault has its own lead tile (above the install row);
3688
+ // scribe doesn't ship a user-facing landing surface today
3689
+ // (scribe#53 tracks the eventual admin SPA), so USE_IT_NOW_URLS is
3690
+ // empty and a succeeded scribe install renders only the "Manage
3691
+ // modules" secondary affordance. Future per-module surfaces can
3692
+ // re-add an entry to that map.
3350
3693
  const db = openHubDb(hubDbPath(h.dir));
3351
3694
  try {
3352
3695
  const user = await createUser(db, "owner", "pw");
@@ -3366,35 +3709,43 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3366
3709
  );
3367
3710
  setSetting(db, "setup_expose_mode", "localhost");
3368
3711
  const reg = getDefaultOperationsRegistry();
3369
- const op = reg.create("install", "app");
3370
- reg.update(op.id, { status: "succeeded" }, "installed @openparachute/app");
3712
+ const op = reg.create("install", "scribe");
3713
+ reg.update(op.id, { status: "succeeded" }, "installed @openparachute/scribe");
3371
3714
  const { createSession } = await import("../sessions.ts");
3372
3715
  const session = createSession(db, { userId: user.id });
3373
3716
  const res = handleSetupGet(
3374
- req(`/admin/setup?just_finished=1&op_surface=${op.id}`, {
3717
+ req(`/admin/setup?just_finished=1&op_scribe=${op.id}`, {
3375
3718
  headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
3376
3719
  }),
3377
3720
  {
3378
3721
  db,
3379
3722
  manifestPath: h.manifestPath,
3380
3723
  configDir: h.dir,
3724
+ readExposeStateFn: h.readExposeStateFn,
3381
3725
  issuer: "https://hub.example",
3382
3726
  registry: reg,
3383
3727
  },
3384
3728
  );
3385
3729
  const html = await res.text();
3386
3730
  expect(html).toContain("status: succeeded");
3387
- // Primary "Use it now" link goes to the app's surface; secondary
3388
- // "Manage modules" link still present.
3389
- expect(html).toContain(">Use it now<");
3390
- expect(html).toContain('href="/surface/notes/"');
3731
+ // No "Use it now" scribe has no entry in USE_IT_NOW_URLS today.
3732
+ expect(html).not.toContain(">Use it now<");
3733
+ // "Manage modules" secondary link is always present on a terminal-
3734
+ // succeeded install tile.
3391
3735
  expect(html).toContain(">Manage modules<");
3392
3736
  } finally {
3393
3737
  db.close();
3394
3738
  }
3395
3739
  });
3396
3740
 
3397
- test("'Already installed' tile gains a 'Use it now' link too", async () => {
3741
+ test("'Already installed' tile renders without a 'Use it now' link when the module has no hosted surface", async () => {
3742
+ // Post-2026-05-27 CURATED trim, USE_IT_NOW_URLS is empty (scribe has
3743
+ // no first-class user-facing landing surface yet; vault gets its
3744
+ // own lead tile, not an install tile). The already-installed tile
3745
+ // therefore renders only the "Manage in admin" secondary link. Pre-
3746
+ // trim the surface module had a USE_IT_NOW_URLS entry that drove
3747
+ // this surface, so the test now pins the absence rather than the
3748
+ // presence.
3398
3749
  const db = openHubDb(hubDbPath(h.dir));
3399
3750
  try {
3400
3751
  const user = await createUser(db, "owner", "pw");
@@ -3409,11 +3760,11 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3409
3760
  health: "/health",
3410
3761
  },
3411
3762
  {
3412
- name: "parachute-surface",
3413
- version: "0.2.0",
3414
- port: 1946,
3415
- paths: ["/surface"],
3416
- health: "/surface/healthz",
3763
+ name: "parachute-scribe",
3764
+ version: "0.4.4",
3765
+ port: 1943,
3766
+ paths: ["/scribe"],
3767
+ health: "/scribe/health",
3417
3768
  },
3418
3769
  ],
3419
3770
  },
@@ -3430,14 +3781,17 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3430
3781
  db,
3431
3782
  manifestPath: h.manifestPath,
3432
3783
  configDir: h.dir,
3784
+ readExposeStateFn: h.readExposeStateFn,
3433
3785
  issuer: "https://hub.example",
3434
3786
  registry: getDefaultOperationsRegistry(),
3435
3787
  },
3436
3788
  );
3437
3789
  const html = await res.text();
3438
3790
  expect(html).toContain("Already installed");
3439
- // App's already-installed tile carries the Use it now link.
3440
- expect(html).toContain('href="/surface/notes/"');
3791
+ // No "Use it now" on the scribe already-installed tile.
3792
+ expect(html).not.toContain(">Use it now<");
3793
+ // Secondary affordance still present.
3794
+ expect(html).toContain("Manage in admin");
3441
3795
  } finally {
3442
3796
  db.close();
3443
3797
  }
@@ -3455,6 +3809,7 @@ describe("done screen — 'Start using your vault' tile (hub#342)", () => {
3455
3809
  db,
3456
3810
  manifestPath: h.manifestPath,
3457
3811
  configDir: h.dir,
3812
+ readExposeStateFn: h.readExposeStateFn,
3458
3813
  issuer: "https://hub.example",
3459
3814
  registry: getDefaultOperationsRegistry(),
3460
3815
  });
@@ -3529,3 +3884,228 @@ describe("detectAutoExposeMode — Fly env detection (patterns#100)", () => {
3529
3884
  ).toBe("public");
3530
3885
  });
3531
3886
  });
3887
+
3888
+ // hub#168 Cut 2/3: vault-step three branches (create/import/skip) + JSON
3889
+ // content-type acceptance. The handleSetupVaultPost handler is shared
3890
+ // between browser and CLI surfaces — branching is by mode field +
3891
+ // content-type. These tests drive the JSON surface directly to keep the
3892
+ // behavior locked.
3893
+
3894
+ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
3895
+ let h: Harness;
3896
+ beforeEach(() => {
3897
+ h = makeHarness();
3898
+ _resetOperationsRegistryForTests();
3899
+ });
3900
+ afterEach(() => h.cleanup());
3901
+
3902
+ test("GET /admin/setup returns JSON envelope when Accept: application/json", () => {
3903
+ const db = openHubDb(hubDbPath(h.dir));
3904
+ try {
3905
+ const deps = {
3906
+ db,
3907
+ manifestPath: h.manifestPath,
3908
+ configDir: h.dir,
3909
+ readExposeStateFn: h.readExposeStateFn,
3910
+ issuer: "http://127.0.0.1:1939",
3911
+ registry: getDefaultOperationsRegistry(),
3912
+ };
3913
+ const res = handleSetupGet(
3914
+ req("/admin/setup", { headers: { accept: "application/json" } }),
3915
+ deps,
3916
+ );
3917
+ expect(res.status).toBe(200);
3918
+ expect(res.headers.get("content-type")).toContain("application/json");
3919
+ } finally {
3920
+ db.close();
3921
+ }
3922
+ });
3923
+
3924
+ test("vault step skip mode short-circuits + persists setup_vault_skipped", async () => {
3925
+ const db = openHubDb(hubDbPath(h.dir));
3926
+ try {
3927
+ // Seed: admin exists so the wizard's vault step is reachable.
3928
+ await createUser(db, "owner", "pw");
3929
+ // Get a session cookie via a CSRF token GET first.
3930
+ const supervisor = makeSupervisor();
3931
+ const baseDeps = {
3932
+ db,
3933
+ manifestPath: h.manifestPath,
3934
+ configDir: h.dir,
3935
+ readExposeStateFn: h.readExposeStateFn,
3936
+ issuer: "http://127.0.0.1:1939",
3937
+ registry: getDefaultOperationsRegistry(),
3938
+ supervisor,
3939
+ };
3940
+ const getRes = handleSetupGet(
3941
+ req("/admin/setup", { headers: { accept: "application/json" } }),
3942
+ baseDeps,
3943
+ );
3944
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
3945
+ const envelope = (await getRes.json()) as { csrfToken: string };
3946
+ // Build a session for the operator (proxy what an account POST
3947
+ // would do).
3948
+ const { createSession, buildSessionCookie, SESSION_TTL_MS } = await import("../sessions.ts");
3949
+ const user = (await import("../users.ts")).getUserByUsername(db, "owner");
3950
+ if (!user) throw new Error("user missing");
3951
+ const session = createSession(db, { userId: user.id });
3952
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
3953
+ const postRes = await handleSetupVaultPost(
3954
+ req("/admin/setup/vault", {
3955
+ method: "POST",
3956
+ headers: {
3957
+ accept: "application/json",
3958
+ "content-type": "application/json",
3959
+ cookie: cookieHeader,
3960
+ },
3961
+ body: JSON.stringify({
3962
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
3963
+ mode: "skip",
3964
+ }),
3965
+ }),
3966
+ baseDeps,
3967
+ );
3968
+ expect(postRes.status).toBe(200);
3969
+ expect(postRes.headers.get("content-type")).toContain("application/json");
3970
+ const body = (await postRes.json()) as { step: string };
3971
+ expect(body.step).toBe("expose");
3972
+ // The skip flag is persisted.
3973
+ expect(getSetting(db, "setup_vault_skipped")).toBe("true");
3974
+ // deriveWizardState advances past the vault step.
3975
+ const s = deriveWizardState({
3976
+ db,
3977
+ manifestPath: h.manifestPath,
3978
+ readExposeStateFn: h.readExposeStateFn,
3979
+ });
3980
+ expect(s.hasVault).toBe(true);
3981
+ expect(s.step).toBe("expose");
3982
+ } finally {
3983
+ db.close();
3984
+ }
3985
+ });
3986
+
3987
+ test("vault step import mode requires remote_url (400 on empty)", async () => {
3988
+ const db = openHubDb(hubDbPath(h.dir));
3989
+ try {
3990
+ await createUser(db, "owner", "pw");
3991
+ const supervisor = makeSupervisor();
3992
+ const baseDeps = {
3993
+ db,
3994
+ manifestPath: h.manifestPath,
3995
+ configDir: h.dir,
3996
+ readExposeStateFn: h.readExposeStateFn,
3997
+ issuer: "http://127.0.0.1:1939",
3998
+ registry: getDefaultOperationsRegistry(),
3999
+ supervisor,
4000
+ };
4001
+ const { createSession } = await import("../sessions.ts");
4002
+ const user = (await import("../users.ts")).getUserByUsername(db, "owner");
4003
+ if (!user) throw new Error("user missing");
4004
+ const session = createSession(db, { userId: user.id });
4005
+ // Need CSRF cookie value matching the body field. Pull a token
4006
+ // through a GET first.
4007
+ const getRes = handleSetupGet(
4008
+ req("/admin/setup", { headers: { accept: "application/json" } }),
4009
+ baseDeps,
4010
+ );
4011
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
4012
+ const envelope = (await getRes.json()) as { csrfToken: string };
4013
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
4014
+ const postRes = await handleSetupVaultPost(
4015
+ req("/admin/setup/vault", {
4016
+ method: "POST",
4017
+ headers: {
4018
+ accept: "application/json",
4019
+ "content-type": "application/json",
4020
+ cookie: cookieHeader,
4021
+ },
4022
+ body: JSON.stringify({
4023
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
4024
+ mode: "import",
4025
+ vault_name: "imported",
4026
+ remote_url: "",
4027
+ }),
4028
+ }),
4029
+ baseDeps,
4030
+ );
4031
+ expect(postRes.status).toBe(400);
4032
+ const body = (await postRes.json()) as { error: string; message: string };
4033
+ expect(body.error).toContain("Remote URL required");
4034
+ } finally {
4035
+ db.close();
4036
+ }
4037
+ });
4038
+
4039
+ // hub#168 fold (PR #447 reviewer): the import POST to vault MUST carry
4040
+ // a Bearer — vault's `authenticateVaultRequest` rejects 401 before
4041
+ // scope check on missing auth. Asserts the header is present, names
4042
+ // the vault, and the body shape is intact.
4043
+ test("postVaultImportImpl sends Authorization: Bearer + correct body to vault", async () => {
4044
+ let capturedUrl: string | undefined;
4045
+ let capturedHeaders: Headers | undefined;
4046
+ let capturedBody: unknown;
4047
+ const stubFetch = (async (input: string | URL | Request, init?: RequestInit) => {
4048
+ capturedUrl = typeof input === "string" ? input : input.toString();
4049
+ capturedHeaders = new Headers(init?.headers ?? {});
4050
+ capturedBody = JSON.parse((init?.body as string) ?? "{}");
4051
+ return new Response(
4052
+ JSON.stringify({
4053
+ notes_imported: 7,
4054
+ tags_imported: 2,
4055
+ attachments_imported: 0,
4056
+ warnings: [],
4057
+ }),
4058
+ { status: 200, headers: { "content-type": "application/json" } },
4059
+ );
4060
+ }) as typeof fetch;
4061
+
4062
+ const result = await postVaultImportImpl({
4063
+ vaultName: "imported",
4064
+ vaultPort: 1940,
4065
+ bearerToken: "stub-jwt-abc",
4066
+ remoteUrl: "https://github.com/owner/repo.git",
4067
+ mode: "merge",
4068
+ pat: "ghp_stub",
4069
+ fetcher: stubFetch,
4070
+ });
4071
+
4072
+ expect(result.notes_imported).toBe(7);
4073
+ expect(capturedUrl).toBe("http://127.0.0.1:1940/vault/imported/.parachute/mirror/import");
4074
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer stub-jwt-abc");
4075
+ expect(capturedHeaders?.get("content-type")).toBe("application/json");
4076
+ expect(capturedBody).toEqual({
4077
+ remote_url: "https://github.com/owner/repo.git",
4078
+ mode: "merge",
4079
+ credentials: { kind: "pat", token: "ghp_stub" },
4080
+ });
4081
+ });
4082
+
4083
+ // No-PAT branch — public repo import. Sends `credentials: null`,
4084
+ // which vault interprets as "use stored credentials" (or none).
4085
+ // Reviewer-flagged coverage gap on the rc.8 fold.
4086
+ test("postVaultImportImpl sends credentials: null when no PAT is provided", async () => {
4087
+ let capturedBody: unknown;
4088
+ const stubFetch = (async (_: string | URL | Request, init?: RequestInit) => {
4089
+ capturedBody = JSON.parse((init?.body as string) ?? "{}");
4090
+ return new Response(JSON.stringify({ notes_imported: 1 }), {
4091
+ status: 200,
4092
+ headers: { "content-type": "application/json" },
4093
+ });
4094
+ }) as typeof fetch;
4095
+
4096
+ await postVaultImportImpl({
4097
+ vaultName: "public-import",
4098
+ vaultPort: 1940,
4099
+ bearerToken: "stub",
4100
+ remoteUrl: "https://github.com/owner/public.git",
4101
+ mode: "replace",
4102
+ fetcher: stubFetch,
4103
+ });
4104
+
4105
+ expect(capturedBody).toEqual({
4106
+ remote_url: "https://github.com/owner/public.git",
4107
+ mode: "replace",
4108
+ credentials: null,
4109
+ });
4110
+ });
4111
+ });