@openparachute/hub 0.7.7-rc.12 → 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.
@@ -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
+ });
@@ -5,8 +5,11 @@ import { join } from "node:path";
5
5
  import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
6
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
7
  import {
8
+ PARACHUTE_HUB_ROOT_MODE_ENV,
8
9
  PARACHUTE_HUB_ROOT_REDIRECT_ENV,
10
+ getRootMode,
9
11
  getRootRedirect,
12
+ setRootMode,
10
13
  setRootRedirect,
11
14
  } from "../hub-settings.ts";
12
15
  import { findService, upsertService } from "../services-manifest.ts";
@@ -2417,31 +2420,38 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
2417
2420
  });
2418
2421
  });
2419
2422
 
2420
- // hub-parity P5 (2026-07-11): `parachute install app` defaults the hub's
2421
- // bare `/` redirect to the app's front door — SET-IF-UNSET ONLY. Every test
2422
- // here injects `rootRedirectDb` (a hub.db opened in the same disposable
2423
- // tempdir `makeTempPath()` already isolates) so no real
2424
- // `~/.parachute/hub.db` is ever touched never drive this against the
2423
+ // `parachute install app` defaults the hub's origin root to SERVE the app
2424
+ // (root_mode = serve-app) on first install — SET-IF-UNSET ONLY, and only when
2425
+ // the root behavior is entirely pristine default (no mode row/env AND no
2426
+ // redirect row/env). Supersedes the prior hub-parity P5 behavior (which wrote
2427
+ // root_redirect = /app/). Every test here injects `rootRedirectDb` (a hub.db
2428
+ // opened in the same disposable tempdir `makeTempPath()` already isolates) so no
2429
+ // real `~/.parachute/hub.db` is ever touched — never drive this against the
2425
2430
  // operator's live install.
2426
- describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2427
- // The write gates on `resolveRootRedirectDetailed(db).source === "default"`,
2428
- // which consults `PARACHUTE_HUB_ROOT_REDIRECT`. Neutralize any ambient value
2429
- // (Aaron's box, CI) so the default-tier tests are deterministic; the
2430
- // env-set test below manages its own value inside its own body.
2431
- let savedEnv: string | undefined;
2431
+ describe("app-only serve-app set-if-unset default", () => {
2432
+ // The write gates on the RESOLVED mode + redirect being `default` (no DB row,
2433
+ // no env). Neutralize any ambient PARACHUTE_HUB_ROOT_MODE / _REDIRECT (Aaron's
2434
+ // box, CI) so the default-tier tests are deterministic; the env-set tests
2435
+ // below manage their own values inside their own bodies.
2436
+ let savedRedirectEnv: string | undefined;
2437
+ let savedModeEnv: string | undefined;
2432
2438
  beforeEach(() => {
2433
- savedEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2439
+ savedRedirectEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2440
+ savedModeEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2434
2441
  delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2442
+ delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2435
2443
  });
2436
2444
  afterEach(() => {
2437
- if (savedEnv === undefined) {
2438
- delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2439
- } else {
2440
- process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = savedEnv;
2445
+ for (const [name, saved] of [
2446
+ [PARACHUTE_HUB_ROOT_REDIRECT_ENV, savedRedirectEnv],
2447
+ [PARACHUTE_HUB_ROOT_MODE_ENV, savedModeEnv],
2448
+ ] as const) {
2449
+ if (saved === undefined) delete process.env[name];
2450
+ else process.env[name] = saved;
2441
2451
  }
2442
2452
  });
2443
2453
 
2444
- test("fresh install (no prior root_redirect) sets it to /app/", async () => {
2454
+ test("fresh install (pristine root) flips root_mode to serve-app", async () => {
2445
2455
  const { path, configDir, cleanup } = makeTempPath();
2446
2456
  try {
2447
2457
  const db = openHubDb(hubDbPath(configDir));
@@ -2458,8 +2468,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2458
2468
  log: (l) => logs.push(l),
2459
2469
  });
2460
2470
  expect(code).toBe(0);
2461
- expect(getRootRedirect(db)).toBe("/app/");
2462
- expect(logs.join("\n")).toMatch(/front page.*now opens the app at \/app\//);
2471
+ expect(getRootMode(db)).toBe("serve-app");
2472
+ // The redirect row is NOT written — serve-app takes over `/` directly.
2473
+ expect(getRootRedirect(db)).toBeNull();
2474
+ expect(logs.join("\n")).toMatch(/front page.*now serves the Parachute app/);
2463
2475
  } finally {
2464
2476
  db.close();
2465
2477
  }
@@ -2468,7 +2480,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2468
2480
  }
2469
2481
  });
2470
2482
 
2471
- test("a pre-set root_redirect is left alone — never clobbered", async () => {
2483
+ test("a pre-set root_redirect is left alone — mode NOT flipped", async () => {
2472
2484
  const { path, configDir, cleanup } = makeTempPath();
2473
2485
  try {
2474
2486
  const db = openHubDb(hubDbPath(configDir));
@@ -2486,13 +2498,43 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2486
2498
  log: (l) => logs.push(l),
2487
2499
  });
2488
2500
  expect(code).toBe(0);
2489
- // The operator's prior choice survives byte-for-byte.
2501
+ // The operator's prior choice survives byte-for-byte; no mode flip.
2490
2502
  expect(getRootRedirect(db)).toBe("/surface/reading-room");
2491
- // The set-if-unset write's OWN log line (distinct from the static
2492
- // postInstallFooter boilerplate, which always prints "now opens the
2493
- // app too, unless you've already..." regardless of whether the write
2494
- // fired) must NOT appear — the write itself was skipped.
2495
- expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2503
+ expect(getRootMode(db)).toBeNull();
2504
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2505
+ } finally {
2506
+ db.close();
2507
+ }
2508
+ } finally {
2509
+ cleanup();
2510
+ }
2511
+ });
2512
+
2513
+ test("an existing install with the old /app/ redirect keeps redirecting (back-compat)", async () => {
2514
+ // A hub that ran the PRIOR app-install code has root_redirect = /app/ in its
2515
+ // DB (source = db → not default). Re-running `install app` must NOT flip it
2516
+ // to serve-app — it keeps 302-ing to /app/, exactly as before. This is the
2517
+ // "existing installs unchanged" guarantee.
2518
+ const { path, configDir, cleanup } = makeTempPath();
2519
+ try {
2520
+ const db = openHubDb(hubDbPath(configDir));
2521
+ const logs: string[] = [];
2522
+ try {
2523
+ setRootRedirect(db, "/app/");
2524
+ const code = await install("app", {
2525
+ runner: async () => 0,
2526
+ manifestPath: path,
2527
+ configDir,
2528
+ startService: async () => 0,
2529
+ isLinked: () => false,
2530
+ portProbe: async () => false,
2531
+ rootRedirectDb: db,
2532
+ log: (l) => logs.push(l),
2533
+ });
2534
+ expect(code).toBe(0);
2535
+ expect(getRootMode(db)).toBeNull();
2536
+ expect(getRootRedirect(db)).toBe("/app/");
2537
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2496
2538
  } finally {
2497
2539
  db.close();
2498
2540
  }
@@ -2501,7 +2543,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2501
2543
  }
2502
2544
  });
2503
2545
 
2504
- test("installing a different module never touches root_redirect", async () => {
2546
+ test("installing a different module never touches the root settings", async () => {
2505
2547
  const { path, configDir, cleanup } = makeTempPath();
2506
2548
  try {
2507
2549
  const db = openHubDb(hubDbPath(configDir));
@@ -2518,6 +2560,7 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2518
2560
  });
2519
2561
  expect(code).toBe(0);
2520
2562
  expect(getRootRedirect(db)).toBeNull();
2563
+ expect(getRootMode(db)).toBeNull();
2521
2564
  } finally {
2522
2565
  db.close();
2523
2566
  }
@@ -2526,13 +2569,12 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2526
2569
  }
2527
2570
  });
2528
2571
 
2529
- test("an ENV-configured redirect (no DB row) is left alone — never clobbered (N1)", async () => {
2572
+ test("an ENV-configured redirect (no DB row) is left alone — no mode flip (N1)", async () => {
2530
2573
  // Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
2531
- // not a DB row. Gating on `getRootRedirect(db) === null` (DB only) would
2532
- // miss this and write `/app/`, which since the DB row wins over env on
2533
- // read silently overrides their configured landing. The fix gates on
2534
- // `resolveRootRedirectDetailed(db).source === "default"`, which sees the
2535
- // env tier. So: env set → no DB row written.
2574
+ // not a DB row. Flipping to serve-app would silently override their
2575
+ // configured landing (serve-app wins over redirect at `/`). The gate reads
2576
+ // the RESOLVED redirect source; with the env set that's `env`, not
2577
+ // `default`, so we leave it alone.
2536
2578
  const { path, configDir, cleanup } = makeTempPath();
2537
2579
  const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2538
2580
  try {
@@ -2551,9 +2593,10 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2551
2593
  log: (l) => logs.push(l),
2552
2594
  });
2553
2595
  expect(code).toBe(0);
2554
- // No DB row written — the env-configured landing survives.
2596
+ // No mode row written — the env-configured landing survives.
2597
+ expect(getRootMode(db)).toBeNull();
2555
2598
  expect(getRootRedirect(db)).toBeNull();
2556
- expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2599
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2557
2600
  } finally {
2558
2601
  db.close();
2559
2602
  }
@@ -2567,6 +2610,43 @@ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2567
2610
  }
2568
2611
  });
2569
2612
 
2613
+ test("an ENV-configured mode (no DB row) is left alone — no clobber", async () => {
2614
+ // PARACHUTE_HUB_ROOT_MODE=redirect on a container that deliberately wants
2615
+ // the redirect front door. The gate sees the resolved mode source as `env`,
2616
+ // not `default`, so it does NOT write a serve-app row.
2617
+ const { path, configDir, cleanup } = makeTempPath();
2618
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2619
+ try {
2620
+ process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = "redirect";
2621
+ const db = openHubDb(hubDbPath(configDir));
2622
+ const logs: string[] = [];
2623
+ try {
2624
+ const code = await install("app", {
2625
+ runner: async () => 0,
2626
+ manifestPath: path,
2627
+ configDir,
2628
+ startService: async () => 0,
2629
+ isLinked: () => false,
2630
+ portProbe: async () => false,
2631
+ rootRedirectDb: db,
2632
+ log: (l) => logs.push(l),
2633
+ });
2634
+ expect(code).toBe(0);
2635
+ expect(getRootMode(db)).toBeNull();
2636
+ expect(logs.join("\n")).not.toMatch(/now serves the Parachute app/);
2637
+ } finally {
2638
+ db.close();
2639
+ }
2640
+ } finally {
2641
+ if (prevEnv === undefined) {
2642
+ delete process.env[PARACHUTE_HUB_ROOT_MODE_ENV];
2643
+ } else {
2644
+ process.env[PARACHUTE_HUB_ROOT_MODE_ENV] = prevEnv;
2645
+ }
2646
+ cleanup();
2647
+ }
2648
+ });
2649
+
2570
2650
  test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
2571
2651
  // Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
2572
2652
  // install.ts: a test with a tempdir manifestPath and no explicit opt-in
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for `src/root-serve.ts` — serving the Parachute app AT the origin root
3
+ * (`root_mode = serve-app`).
4
+ *
5
+ * Covers `serveAppAtRoot` (the static file-or-SPA-shell decision, Accept-gated,
6
+ * reserved-prefix-guarded, traversal-guarded) and `makeAppDistResolver` (success
7
+ * memoization + failure NON-caching for dynamic recovery).
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { makeAppDistResolver, serveAppAtRoot } from "../root-serve.ts";
14
+
15
+ let dist: string;
16
+ let root: string;
17
+
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), "phub-root-serve-"));
20
+ dist = join(root, "dist");
21
+ mkdirSync(join(dist, "assets"), { recursive: true });
22
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>App</title>");
23
+ writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('app')");
24
+ writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
25
+ writeFileSync(join(dist, "sw.js"), "self.addEventListener('install',()=>{})");
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ function get(pathname: string, accept?: string): Request {
33
+ return new Request(`http://localhost${pathname}`, {
34
+ method: "GET",
35
+ headers: accept ? { accept } : {},
36
+ });
37
+ }
38
+
39
+ describe("serveAppAtRoot", () => {
40
+ test("serves index.html at `/`", async () => {
41
+ const res = serveAppAtRoot(dist, get("/", "text/html"), "/");
42
+ expect(res).not.toBeNull();
43
+ expect(res?.headers.get("content-type")).toContain("text/html");
44
+ expect(await res?.text()).toContain("<title>App</title>");
45
+ });
46
+
47
+ test("serves an existing asset with an inferred content-type", async () => {
48
+ const res = serveAppAtRoot(dist, get("/assets/index-abc.js", "*/*"), "/assets/index-abc.js");
49
+ expect(res).not.toBeNull();
50
+ expect(res?.headers.get("content-type") ?? "").toMatch(/javascript/);
51
+ expect(await res?.text()).toContain("console.log");
52
+ });
53
+
54
+ test("serves the PWA manifest with the application/manifest+json override", () => {
55
+ const res = serveAppAtRoot(dist, get("/manifest.webmanifest", "*/*"), "/manifest.webmanifest");
56
+ expect(res).not.toBeNull();
57
+ expect(res?.headers.get("content-type")).toBe("application/manifest+json");
58
+ });
59
+
60
+ test("serves the service worker as a real file (not the SPA shell)", async () => {
61
+ const res = serveAppAtRoot(dist, get("/sw.js", "*/*"), "/sw.js");
62
+ expect(res).not.toBeNull();
63
+ expect(await res?.text()).toContain("addEventListener");
64
+ });
65
+
66
+ test("SPA fallback: an unclaimed HTML deep link gets index.html", async () => {
67
+ const res = serveAppAtRoot(dist, get("/some/app/route", "text/html"), "/some/app/route");
68
+ expect(res).not.toBeNull();
69
+ expect(await res?.text()).toContain("<title>App</title>");
70
+ });
71
+
72
+ test("non-HTML unclaimed request → null (branded 404 tail)", () => {
73
+ expect(serveAppAtRoot(dist, get("/nope.json", "application/json"), "/nope.json")).toBeNull();
74
+ // A missing asset fetched with Accept: */* is not an HTML navigation → null.
75
+ expect(serveAppAtRoot(dist, get("/assets/missing.js", "*/*"), "/assets/missing.js")).toBeNull();
76
+ });
77
+
78
+ test("non-GET → null (a non-GET unclaimed path keeps its default)", () => {
79
+ const post = new Request("http://localhost/", {
80
+ method: "POST",
81
+ headers: { accept: "text/html" },
82
+ });
83
+ expect(serveAppAtRoot(dist, post, "/")).toBeNull();
84
+ });
85
+
86
+ test("reserved hub/protocol prefixes keep the branded 404 even for HTML nav", () => {
87
+ for (const p of ["/api/bogus", "/oauth/typo", "/.well-known/nope"]) {
88
+ expect(serveAppAtRoot(dist, get(p, "text/html"), p)).toBeNull();
89
+ }
90
+ });
91
+
92
+ test("path traversal cannot escape dist", () => {
93
+ // A crafted encoded traversal joins outside dist → falls through (null).
94
+ const p = "/assets/..%2f..%2f..%2fetc%2fpasswd";
95
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
96
+ });
97
+
98
+ test("a resolved dist whose index.html vanished → null (no broken shell)", () => {
99
+ rmSync(join(dist, "index.html"));
100
+ expect(serveAppAtRoot(dist, get("/", "text/html"), "/")).toBeNull();
101
+ });
102
+
103
+ test("malformed percent-encoding → null (no URIError thrown → branded 404, not 500)", () => {
104
+ // decodeURIComponent throws URIError on a bad escape; the guard must swallow
105
+ // it and fall through so the dispatch never 500s on a garbage asset URL.
106
+ for (const p of ["/assets/%ZZ", "/foo%", "/%E0%A4%A", "/bar%2"]) {
107
+ expect(() => serveAppAtRoot(dist, get(p, "*/*"), p)).not.toThrow();
108
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
109
+ }
110
+ // Even an HTML navigation with bad encoding falls through (redirect-mode
111
+ // parity) rather than shelling.
112
+ expect(serveAppAtRoot(dist, get("/foo%", "text/html"), "/foo%")).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("makeAppDistResolver", () => {
117
+ test("returns the resolved dist and memoizes a success", () => {
118
+ let calls = 0;
119
+ const resolve = makeAppDistResolver(() => {
120
+ calls++;
121
+ return dist;
122
+ });
123
+ expect(resolve()).toBe(dist);
124
+ expect(resolve()).toBe(dist);
125
+ expect(calls).toBe(1); // success cached — only resolved once
126
+ });
127
+
128
+ test("does NOT cache a failure — recovers once the app is installed", () => {
129
+ let installed = false;
130
+ const resolve = makeAppDistResolver(() => {
131
+ if (!installed) throw new Error("not installed");
132
+ return dist;
133
+ });
134
+ expect(resolve()).toBeNull(); // not installed yet
135
+ expect(resolve()).toBeNull(); // still probing, still null
136
+ installed = true;
137
+ expect(resolve()).toBe(dist); // picked up without a restart
138
+ });
139
+ });