@openparachute/hub 0.7.6 → 0.7.7-rc.13

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 (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. package/src/admin-agent-token.ts +0 -147
@@ -17,7 +17,7 @@ import {
17
17
  resolveClientIp,
18
18
  stripHopByHopHeaders,
19
19
  } from "../hub-server.ts";
20
- import { setNotesRedirectDisabled } from "../hub-settings.ts";
20
+ import { setNotesRedirectDisabled, setRootMode } from "../hub-settings.ts";
21
21
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
22
22
  import { mintOperatorToken } from "../operator-token.ts";
23
23
  import { pidPath } from "../process-state.ts";
@@ -993,6 +993,29 @@ describe("hubFetch routing", () => {
993
993
  }
994
994
  });
995
995
 
996
+ test("retired Agent token endpoints neither mint nor redirect", async () => {
997
+ const h = makeHarness();
998
+ try {
999
+ const dist = join(h.dir, "dist");
1000
+ mkdirIfMissing(dist);
1001
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>admin shell</title>");
1002
+ const fetcher = hubFetch(h.dir, { spaDistDir: dist });
1003
+
1004
+ for (const path of ["/admin/agent-token", "/admin/channel-token"]) {
1005
+ const post = await fetcher(req(path, { method: "POST" }));
1006
+ expect(post.status).toBe(405);
1007
+ expect(post.headers.get("location")).toBeNull();
1008
+
1009
+ const get = await fetcher(req(path));
1010
+ expect(get.status).toBe(200);
1011
+ expect(get.headers.get("location")).toBeNull();
1012
+ expect(await get.text()).toContain("admin shell");
1013
+ }
1014
+ } finally {
1015
+ h.cleanup();
1016
+ }
1017
+ });
1018
+
996
1019
  // 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
997
1020
  // 301-redirect to the new /admin/* mount. Tests cover every entry in the
998
1021
  // dispatch — operator bookmarks landing on any of these still work.
@@ -1249,6 +1272,20 @@ describe("hubFetch routing", () => {
1249
1272
  }
1250
1273
  });
1251
1274
 
1275
+ test("retired /channel/* paths no longer redirect to /agent/*", async () => {
1276
+ const h = makeHarness();
1277
+ try {
1278
+ writeManifest({ services: [] }, h.manifestPath);
1279
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
1280
+ req("/channel/mcp/default?q=1"),
1281
+ );
1282
+ expect(res.status).toBe(404);
1283
+ expect(res.headers.get("location")).toBeNull();
1284
+ } finally {
1285
+ h.cleanup();
1286
+ }
1287
+ });
1288
+
1252
1289
  test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
1253
1290
  const h = makeHarness();
1254
1291
  try {
@@ -6381,3 +6418,287 @@ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6381
6418
  }
6382
6419
  });
6383
6420
  });
6421
+
6422
+ // root_mode = serve-app: the hub serves the installed app's dist AT `/`, with
6423
+ // the fresh-hub funnel + pre-admin 503 + every hub-owned route preempting it.
6424
+ describe("hubFetch — root_mode serve-app", () => {
6425
+ // A fixture app dist (index.html + assets). Distinct marker text so a test can
6426
+ // tell an app-served body from an admin-SPA / discovery body.
6427
+ function makeAppDist(dir: string): string {
6428
+ const dist = join(dir, "app-dist");
6429
+ mkdirSync(join(dist, "assets"), { recursive: true });
6430
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>PARACHUTE-APP-SHELL</title>");
6431
+ writeFileSync(join(dist, "assets", "app-xyz.js"), "console.log('serve-app')");
6432
+ writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
6433
+ return dist;
6434
+ }
6435
+
6436
+ // A distinct admin-SPA fixture so `/admin` reachability is provable (its body
6437
+ // must NOT be the app shell).
6438
+ function makeAdminDist(dir: string): string {
6439
+ const dist = join(dir, "admin-dist");
6440
+ mkdirSync(dist, { recursive: true });
6441
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>ADMIN-SPA</title>");
6442
+ return dist;
6443
+ }
6444
+
6445
+ // Seed admin + a vault so the fresh-hub wizard funnel is bypassed (the
6446
+ // "setup complete" state where serve-app should actually engage).
6447
+ async function setupCompleteDb(h: Harness): Promise<ReturnType<typeof openHubDb>> {
6448
+ writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
6449
+ const db = openHubDb(hubDbPath(h.dir));
6450
+ await createUser(db, "owner", "pw");
6451
+ return db;
6452
+ }
6453
+
6454
+ test("serves the app index.html at `/` (200, text/html, no redirect, no chrome)", async () => {
6455
+ const h = makeHarness();
6456
+ const db = await setupCompleteDb(h);
6457
+ try {
6458
+ setRootMode(db, "serve-app");
6459
+ const dist = makeAppDist(h.dir);
6460
+ const res = await hubFetch(h.dir, {
6461
+ getDb: () => db,
6462
+ manifestPath: h.manifestPath,
6463
+ resolveAppDist: () => dist,
6464
+ })(req("/"));
6465
+ expect(res.status).toBe(200);
6466
+ expect(res.headers.get("content-type")).toContain("text/html");
6467
+ const body = await res.text();
6468
+ expect(body).toContain("PARACHUTE-APP-SHELL");
6469
+ // No identity chrome injected on the root-served app HTML.
6470
+ expect(body).not.toContain('class="auth-indicator"');
6471
+ } finally {
6472
+ db.close();
6473
+ h.cleanup();
6474
+ }
6475
+ });
6476
+
6477
+ test("serves an app asset from dist (/assets/*) with an inferred content-type", async () => {
6478
+ const h = makeHarness();
6479
+ const db = await setupCompleteDb(h);
6480
+ try {
6481
+ setRootMode(db, "serve-app");
6482
+ const dist = makeAppDist(h.dir);
6483
+ const res = await hubFetch(h.dir, {
6484
+ getDb: () => db,
6485
+ manifestPath: h.manifestPath,
6486
+ resolveAppDist: () => dist,
6487
+ })(req("/assets/app-xyz.js", { headers: { accept: "*/*" } }));
6488
+ expect(res.status).toBe(200);
6489
+ expect(res.headers.get("content-type") ?? "").toMatch(/javascript/);
6490
+ expect(await res.text()).toContain("serve-app");
6491
+ } finally {
6492
+ db.close();
6493
+ h.cleanup();
6494
+ }
6495
+ });
6496
+
6497
+ test("SPA-fallback: an unclaimed HTML deep link gets the app shell", async () => {
6498
+ const h = makeHarness();
6499
+ const db = await setupCompleteDb(h);
6500
+ try {
6501
+ setRootMode(db, "serve-app");
6502
+ const dist = makeAppDist(h.dir);
6503
+ const res = await hubFetch(h.dir, {
6504
+ getDb: () => db,
6505
+ manifestPath: h.manifestPath,
6506
+ resolveAppDist: () => dist,
6507
+ })(req("/some/app/deep/link", { headers: { accept: "text/html" } }));
6508
+ expect(res.status).toBe(200);
6509
+ expect(await res.text()).toContain("PARACHUTE-APP-SHELL");
6510
+ } finally {
6511
+ db.close();
6512
+ h.cleanup();
6513
+ }
6514
+ });
6515
+
6516
+ test("a non-HTML unclaimed path keeps the branded 404 (not the app shell)", async () => {
6517
+ const h = makeHarness();
6518
+ const db = await setupCompleteDb(h);
6519
+ try {
6520
+ setRootMode(db, "serve-app");
6521
+ const dist = makeAppDist(h.dir);
6522
+ const res = await hubFetch(h.dir, {
6523
+ getDb: () => db,
6524
+ manifestPath: h.manifestPath,
6525
+ resolveAppDist: () => dist,
6526
+ })(req("/definitely/not/a/file.json", { headers: { accept: "application/json" } }));
6527
+ expect(res.status).toBe(404);
6528
+ } finally {
6529
+ db.close();
6530
+ h.cleanup();
6531
+ }
6532
+ });
6533
+
6534
+ test("a malformed percent-encoded asset path 404s (not 500) through the real dispatch", async () => {
6535
+ const h = makeHarness();
6536
+ const db = await setupCompleteDb(h);
6537
+ try {
6538
+ setRootMode(db, "serve-app");
6539
+ const dist = makeAppDist(h.dir);
6540
+ // `%ZZ` is an invalid escape — decodeURIComponent throws. Without the
6541
+ // guard in serveAppAtRoot this escapes to the dispatch outer catch → 500.
6542
+ const res = await hubFetch(h.dir, {
6543
+ getDb: () => db,
6544
+ manifestPath: h.manifestPath,
6545
+ resolveAppDist: () => dist,
6546
+ })(req("/assets/%ZZ", { headers: { accept: "text/html" } }));
6547
+ expect(res.status).toBe(404);
6548
+ } finally {
6549
+ db.close();
6550
+ h.cleanup();
6551
+ }
6552
+ });
6553
+
6554
+ test("app-not-installed falls back to the redirect (with resolveAppDist → null)", async () => {
6555
+ const h = makeHarness();
6556
+ const db = await setupCompleteDb(h);
6557
+ try {
6558
+ setRootMode(db, "serve-app");
6559
+ const res = await hubFetch(h.dir, {
6560
+ getDb: () => db,
6561
+ manifestPath: h.manifestPath,
6562
+ resolveAppDist: () => null, // app not installed
6563
+ })(req("/"));
6564
+ // Falls through to the redirect (default /admin).
6565
+ expect(res.status).toBe(302);
6566
+ expect(res.headers.get("location")).toBe("/admin");
6567
+ } finally {
6568
+ db.close();
6569
+ h.cleanup();
6570
+ }
6571
+ });
6572
+
6573
+ // --- Precedence: the funnels + hub-owned routes preempt serve-app ---------
6574
+
6575
+ test("fresh-hub wizard funnel WINS over serve-app (no admin → `/` 302 /admin/setup)", async () => {
6576
+ const h = makeHarness();
6577
+ // NO admin user, NO vault → needsWizard. serve-app is set + app resolvable.
6578
+ const db = openHubDb(hubDbPath(h.dir));
6579
+ try {
6580
+ setRootMode(db, "serve-app");
6581
+ const dist = makeAppDist(h.dir);
6582
+ const res = await hubFetch(h.dir, {
6583
+ getDb: () => db,
6584
+ manifestPath: h.manifestPath,
6585
+ resolveAppDist: () => dist,
6586
+ })(req("/"));
6587
+ expect(res.status).toBe(302);
6588
+ expect(res.headers.get("location")).toBe("/admin/setup");
6589
+ } finally {
6590
+ db.close();
6591
+ h.cleanup();
6592
+ }
6593
+ });
6594
+
6595
+ test("pre-admin 503 lockout WINS over serve-app (no admin → /api/* 503 setup_required)", async () => {
6596
+ const h = makeHarness();
6597
+ const db = openHubDb(hubDbPath(h.dir)); // no admin
6598
+ try {
6599
+ setRootMode(db, "serve-app");
6600
+ const dist = makeAppDist(h.dir);
6601
+ const res = await hubFetch(h.dir, {
6602
+ getDb: () => db,
6603
+ manifestPath: h.manifestPath,
6604
+ resolveAppDist: () => dist,
6605
+ })(req("/api/hub"));
6606
+ expect(res.status).toBe(503);
6607
+ const body = (await res.json()) as { error?: string };
6608
+ expect(body.error).toBe("setup_required");
6609
+ } finally {
6610
+ db.close();
6611
+ h.cleanup();
6612
+ }
6613
+ });
6614
+
6615
+ test("/admin stays reachable in serve-app mode (dispatched before the tail, not the app shell)", async () => {
6616
+ const h = makeHarness();
6617
+ const db = await setupCompleteDb(h);
6618
+ try {
6619
+ setRootMode(db, "serve-app");
6620
+ const appDist = makeAppDist(h.dir);
6621
+ const adminDist = makeAdminDist(h.dir);
6622
+ const res = await hubFetch(h.dir, {
6623
+ getDb: () => db,
6624
+ manifestPath: h.manifestPath,
6625
+ spaDistDir: adminDist,
6626
+ resolveAppDist: () => appDist,
6627
+ })(req("/admin"));
6628
+ expect(res.status).toBe(200);
6629
+ const body = await res.text();
6630
+ expect(body).toContain("ADMIN-SPA");
6631
+ expect(body).not.toContain("PARACHUTE-APP-SHELL");
6632
+ } finally {
6633
+ db.close();
6634
+ h.cleanup();
6635
+ }
6636
+ });
6637
+
6638
+ test("reserved /api|/oauth|/.well-known unclaimed paths keep the branded 404 (not the app shell)", async () => {
6639
+ const h = makeHarness();
6640
+ const db = await setupCompleteDb(h);
6641
+ try {
6642
+ setRootMode(db, "serve-app");
6643
+ const dist = makeAppDist(h.dir);
6644
+ const handler = hubFetch(h.dir, {
6645
+ getDb: () => db,
6646
+ manifestPath: h.manifestPath,
6647
+ resolveAppDist: () => dist,
6648
+ });
6649
+ // A browser navigation (Accept: text/html) to an unmatched /api path must
6650
+ // NOT be shelled — it keeps the branded 404 for the API namespace.
6651
+ const res = await handler(req("/api/nonexistent", { headers: { accept: "text/html" } }));
6652
+ expect(res.status).toBe(404);
6653
+ expect(await res.text()).not.toContain("PARACHUTE-APP-SHELL");
6654
+ } finally {
6655
+ db.close();
6656
+ h.cleanup();
6657
+ }
6658
+ });
6659
+
6660
+ // --- Regression pin: redirect mode is byte-identical to before ------------
6661
+
6662
+ test("redirect mode (default) is inert even with resolveAppDist wired — `/` still 302s /admin", async () => {
6663
+ const h = makeHarness();
6664
+ const db = await setupCompleteDb(h); // no root_mode set → default redirect
6665
+ try {
6666
+ const dist = makeAppDist(h.dir);
6667
+ const handler = hubFetch(h.dir, {
6668
+ getDb: () => db,
6669
+ manifestPath: h.manifestPath,
6670
+ resolveAppDist: () => dist, // wired but must stay inert in redirect mode
6671
+ });
6672
+ const rootRes = await handler(req("/"));
6673
+ expect(rootRes.status).toBe(302);
6674
+ expect(rootRes.headers.get("location")).toBe("/admin");
6675
+ // The 404 tail is unchanged too: an unclaimed HTML path is the branded
6676
+ // 404 page, NOT the app shell.
6677
+ const tailRes = await handler(req("/random/unclaimed", { headers: { accept: "text/html" } }));
6678
+ expect(tailRes.status).toBe(404);
6679
+ expect(await tailRes.text()).not.toContain("PARACHUTE-APP-SHELL");
6680
+ } finally {
6681
+ db.close();
6682
+ h.cleanup();
6683
+ }
6684
+ });
6685
+
6686
+ test("a non-GET `/` in serve-app mode keeps its 302 (only GET serves the app)", async () => {
6687
+ const h = makeHarness();
6688
+ const db = await setupCompleteDb(h);
6689
+ try {
6690
+ setRootMode(db, "serve-app");
6691
+ const dist = makeAppDist(h.dir);
6692
+ const res = await hubFetch(h.dir, {
6693
+ getDb: () => db,
6694
+ manifestPath: h.manifestPath,
6695
+ resolveAppDist: () => dist,
6696
+ })(req("/", { method: "POST" }));
6697
+ expect(res.status).toBe(302);
6698
+ expect(res.headers.get("location")).toBe("/admin");
6699
+ } finally {
6700
+ db.close();
6701
+ h.cleanup();
6702
+ }
6703
+ });
6704
+ });
@@ -12,30 +12,38 @@ import { join } from "node:path";
12
12
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
13
  import {
14
14
  DEFAULT_MODULE_INSTALL_CHANNEL,
15
+ DEFAULT_ROOT_MODE,
15
16
  DEFAULT_ROOT_REDIRECT,
16
17
  FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
17
18
  MODULE_INSTALL_CHANNELS,
19
+ PARACHUTE_HUB_ROOT_MODE_ENV,
18
20
  PARACHUTE_HUB_ROOT_REDIRECT_ENV,
19
21
  PARACHUTE_INSTALL_CHANNEL_ENV,
20
22
  PARACHUTE_MODULE_CHANNEL_ENV,
23
+ ROOT_MODES,
21
24
  SETUP_EXPOSE_MODES,
22
25
  consumeFirstClientAutoApproveWindow,
23
26
  deleteSetting,
24
27
  getHubOrigin,
25
28
  getModuleInstallChannel,
29
+ getRootMode,
26
30
  getRootRedirect,
27
31
  getSetting,
28
32
  isFirstClientAutoApproveWindowOpen,
29
33
  isModuleInstallChannel,
30
34
  isNotesRedirectDisabled,
35
+ isRootMode,
31
36
  isSafeRedirectPath,
32
37
  isSetupExposeMode,
33
38
  openFirstClientAutoApproveWindow,
39
+ resolveRootMode,
40
+ resolveRootModeDetailed,
34
41
  resolveRootRedirect,
35
42
  resolveRootRedirectDetailed,
36
43
  setHubOrigin,
37
44
  setModuleInstallChannel,
38
45
  setNotesRedirectDisabled,
46
+ setRootMode,
39
47
  setRootRedirect,
40
48
  setSetting,
41
49
  } from "../hub-settings.ts";
@@ -363,9 +371,9 @@ describe("hub-settings — module install channel bootstrap", () => {
363
371
  // First read with env=rc — env wins, returns "rc". DB stays empty
364
372
  // (no auto-write to DB when env is set, per the design that
365
373
  // preserves the SPA's last-write as the env-unset fallback).
366
- expect(
367
- getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } }),
368
- ).toBe("rc");
374
+ expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
375
+ "rc",
376
+ );
369
377
  // Second read with env=latest — env wins again (would have returned
370
378
  // "rc" under the old DB-after-first-seed behavior).
371
379
  expect(
@@ -386,9 +394,9 @@ describe("hub-settings — module install channel bootstrap", () => {
386
394
  const db = openHubDb(hubDbPath(dir));
387
395
  try {
388
396
  // PARACHUTE_INSTALL_CHANNEL — canonical name (matches install.ts).
389
- expect(
390
- getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } }),
391
- ).toBe("rc");
397
+ expect(getModuleInstallChannel(db, { env: { [PARACHUTE_INSTALL_CHANNEL_ENV]: "rc" } })).toBe(
398
+ "rc",
399
+ );
392
400
  // PARACHUTE_MODULE_CHANNEL — legacy alias, still recognized.
393
401
  const db2 = openHubDb(join(mkdtempSync(join(tmpdir(), "phub-hsalt-")), "hub.db"));
394
402
  try {
@@ -801,3 +809,99 @@ describe("hub-settings — root_redirect storage + resolution", () => {
801
809
  expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
802
810
  });
803
811
  });
812
+
813
+ describe("hub-settings — root_mode storage + resolution", () => {
814
+ let dir: string;
815
+ beforeEach(() => {
816
+ dir = mkdtempSync(join(tmpdir(), "hub-settings-root-mode-"));
817
+ });
818
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
819
+
820
+ const noEnv: NodeJS.ProcessEnv = {};
821
+ const silent = () => {};
822
+
823
+ test("isRootMode + ROOT_MODES", () => {
824
+ expect(ROOT_MODES).toEqual(["redirect", "serve-app"]);
825
+ expect(isRootMode("redirect")).toBe(true);
826
+ expect(isRootMode("serve-app")).toBe(true);
827
+ expect(isRootMode("serveapp")).toBe(false);
828
+ expect(isRootMode(42)).toBe(false);
829
+ });
830
+
831
+ test("getRootMode round-trips serve-app via setRootMode", () => {
832
+ const db = openHubDb(hubDbPath(dir));
833
+ try {
834
+ expect(getRootMode(db)).toBeNull();
835
+ setRootMode(db, "serve-app");
836
+ expect(getRootMode(db)).toBe("serve-app");
837
+ } finally {
838
+ db.close();
839
+ }
840
+ });
841
+
842
+ test("setRootMode(null) and setRootMode('redirect') both clear the row (default = absence)", () => {
843
+ const db = openHubDb(hubDbPath(dir));
844
+ try {
845
+ setRootMode(db, "serve-app");
846
+ setRootMode(db, null);
847
+ expect(getRootMode(db)).toBeNull();
848
+ setRootMode(db, "serve-app");
849
+ setRootMode(db, "redirect"); // the default is stored as absence
850
+ expect(getRootMode(db)).toBeNull();
851
+ expect(getSetting(db, "root_mode")).toBeUndefined();
852
+ } finally {
853
+ db.close();
854
+ }
855
+ });
856
+
857
+ test("resolves to the redirect default when neither DB nor env is set", () => {
858
+ const db = openHubDb(hubDbPath(dir));
859
+ try {
860
+ expect(resolveRootMode(db, { env: noEnv })).toBe(DEFAULT_ROOT_MODE);
861
+ expect(resolveRootModeDetailed(db, { env: noEnv })).toEqual({
862
+ value: "redirect",
863
+ source: "default",
864
+ });
865
+ } finally {
866
+ db.close();
867
+ }
868
+ });
869
+
870
+ test("env override applies when no DB row; DB overrides env (DB is tier-1)", () => {
871
+ const db = openHubDb(hubDbPath(dir));
872
+ try {
873
+ const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" };
874
+ expect(resolveRootModeDetailed(db, { env })).toEqual({ value: "serve-app", source: "env" });
875
+ setRootMode(db, "serve-app");
876
+ // A DB "serve-app" over an env "redirect" resolves to db.
877
+ expect(
878
+ resolveRootModeDetailed(db, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "redirect" } }),
879
+ ).toEqual({ value: "serve-app", source: "db" });
880
+ } finally {
881
+ db.close();
882
+ }
883
+ });
884
+
885
+ test("an invalid DB row / env value is ignored → falls back to the redirect default", () => {
886
+ const db = openHubDb(hubDbPath(dir));
887
+ try {
888
+ setSetting(db, "root_mode", "bogus"); // hand-edited row bypassing write validation
889
+ expect(resolveRootMode(db, { env: noEnv, warn: silent })).toBe("redirect");
890
+ expect(getRootMode(db)).toBeNull(); // getRootMode also guards the invalid value
891
+ const env = { [PARACHUTE_HUB_ROOT_MODE_ENV]: "nonsense" };
892
+ expect(resolveRootModeDetailed(db, { env, warn: silent })).toEqual({
893
+ value: "redirect",
894
+ source: "default",
895
+ });
896
+ } finally {
897
+ db.close();
898
+ }
899
+ });
900
+
901
+ test("a null db (no state) resolves from env / default only", () => {
902
+ expect(resolveRootMode(null, { env: noEnv })).toBe("redirect");
903
+ expect(resolveRootMode(null, { env: { [PARACHUTE_HUB_ROOT_MODE_ENV]: "serve-app" } })).toBe(
904
+ "serve-app",
905
+ );
906
+ });
907
+ });
@@ -239,6 +239,35 @@ describe("renderHub — signed-out slimming (operator feedback)", () => {
239
239
  });
240
240
  });
241
241
 
242
+ describe("renderHub — Get started tiles are uis[]-driven (W2-12)", () => {
243
+ // The tile logic runs client-side inside the discovery IIFE; these pins
244
+ // assert the emitted script's SHAPE (the JS-parses check above guards its
245
+ // validity). The section is uis[]-driven — one tile per active uis[]
246
+ // sub-unit, whichever mount identity (/surface/notes or
247
+ // /surface/parachute) an install serves — with the pre-W2-12 hardcoded
248
+ // "Open Notes" tile demoted to a fallback for surface-host rows that
249
+ // predate uis{} self-registration.
250
+ const html = renderHub({
251
+ session: { displayName: "operator", csrfToken: "csrf-w212" },
252
+ });
253
+
254
+ test("the discovery script iterates uis[] sub-units", () => {
255
+ expect(html).toContain("svc.uis");
256
+ // Inactive sub-units are skipped; absent status counts as active.
257
+ expect(html).toContain("ui.status");
258
+ });
259
+
260
+ test("the legacy 'Open Notes' hardcode survives only as the no-uis fallback", () => {
261
+ // Still present (pre-uis surface-host installs keep their CTA) …
262
+ expect(html).toContain("Open Notes");
263
+ expect(html).toContain("'/surface/notes/'");
264
+ // … but no longer keyed on the bare presence of parachute-surface as
265
+ // the PRIMARY path: the uis[] loop renders first, the hasSurface check
266
+ // only backstops an empty tile list.
267
+ expect(html).toContain("Legacy fallback");
268
+ });
269
+ });
270
+
242
271
  describe("renderHub — signed-in indicator (rc.13)", () => {
243
272
  test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
244
273
  const html = renderHub({