@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.12",
3
+ "version": "0.7.7-rc.13",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -18,10 +18,11 @@ import { join } from "node:path";
18
18
  import {
19
19
  API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
20
20
  handleApiSettingsRootRedirect,
21
+ validateRootMode,
21
22
  validateRootRedirect,
22
23
  } from "../api-settings-root-redirect.ts";
23
24
  import { hubDbPath, openHubDb } from "../hub-db.ts";
24
- import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
25
+ import { getRootMode, getRootRedirect, setRootMode, setRootRedirect } from "../hub-settings.ts";
25
26
  import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
26
27
  import { rotateSigningKey } from "../signing-keys.ts";
27
28
  import { createUser } from "../users.ts";
@@ -192,7 +193,14 @@ describe("GET /api/settings/root-redirect", () => {
192
193
  );
193
194
  expect(res.status).toBe(200);
194
195
  const body = (await res.json()) as Record<string, unknown>;
195
- expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
196
+ expect(body).toEqual({
197
+ root_redirect: null,
198
+ resolved: "/admin",
199
+ source: "default",
200
+ root_mode: null,
201
+ resolved_mode: "redirect",
202
+ mode_source: "default",
203
+ });
196
204
  });
197
205
 
198
206
  // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
@@ -204,7 +212,14 @@ describe("GET /api/settings/root-redirect", () => {
204
212
  );
205
213
  expect(res.status).toBe(200);
206
214
  const body = (await res.json()) as Record<string, unknown>;
207
- expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
215
+ expect(body).toEqual({
216
+ root_redirect: null,
217
+ resolved: "/admin",
218
+ source: "default",
219
+ root_mode: null,
220
+ resolved_mode: "redirect",
221
+ mode_source: "default",
222
+ });
208
223
  });
209
224
 
210
225
  test("reflects a stored value with source=db", async () => {
@@ -219,6 +234,9 @@ describe("GET /api/settings/root-redirect", () => {
219
234
  root_redirect: "/surface/reading-room",
220
235
  resolved: "/surface/reading-room",
221
236
  source: "db",
237
+ root_mode: null,
238
+ resolved_mode: "redirect",
239
+ mode_source: "default",
222
240
  });
223
241
  });
224
242
 
@@ -233,6 +251,9 @@ describe("GET /api/settings/root-redirect", () => {
233
251
  root_redirect: null,
234
252
  resolved: "/surface/from-env",
235
253
  source: "env",
254
+ root_mode: null,
255
+ resolved_mode: "redirect",
256
+ mode_source: "default",
236
257
  });
237
258
  });
238
259
  });
@@ -251,7 +272,10 @@ describe("PUT /api/settings/root-redirect", () => {
251
272
  deps(h),
252
273
  );
253
274
  expect(put.status).toBe(200);
254
- expect((await put.json()) as unknown).toEqual({ root_redirect: "/surface/reading-room" });
275
+ expect((await put.json()) as unknown).toEqual({
276
+ root_redirect: "/surface/reading-room",
277
+ root_mode: null,
278
+ });
255
279
  expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
256
280
 
257
281
  const get = await handleApiSettingsRootRedirect(
@@ -294,7 +318,7 @@ describe("PUT /api/settings/root-redirect", () => {
294
318
  }
295
319
  });
296
320
 
297
- test("400 on a body without a root_redirect field", async () => {
321
+ test("400 on a body with neither root_redirect nor root_mode", async () => {
298
322
  const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
299
323
  const res = await handleApiSettingsRootRedirect(
300
324
  putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
@@ -312,3 +336,122 @@ describe("PUT /api/settings/root-redirect", () => {
312
336
  expect(res.status).toBe(400);
313
337
  });
314
338
  });
339
+
340
+ describe("validateRootMode — pure validator", () => {
341
+ test("null + empty string → normalized null (clear)", () => {
342
+ expect(validateRootMode(null)).toEqual({ ok: true, normalized: null });
343
+ expect(validateRootMode("")).toEqual({ ok: true, normalized: null });
344
+ });
345
+
346
+ test("valid modes normalize verbatim", () => {
347
+ expect(validateRootMode("redirect")).toEqual({ ok: true, normalized: "redirect" });
348
+ expect(validateRootMode("serve-app")).toEqual({ ok: true, normalized: "serve-app" });
349
+ });
350
+
351
+ test("rejects unknown modes + non-strings", () => {
352
+ for (const bad of ["serveapp", "SERVE-APP", "app", 42, {}]) {
353
+ expect(validateRootMode(bad).ok).toBe(false);
354
+ }
355
+ });
356
+ });
357
+
358
+ describe("root_mode over the endpoint", () => {
359
+ let h: Harness;
360
+ beforeEach(async () => {
361
+ h = await makeHarness();
362
+ });
363
+ afterEach(() => h.cleanup());
364
+
365
+ test("GET reflects a stored serve-app mode with mode_source=db", async () => {
366
+ setRootMode(h.db, "serve-app");
367
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
368
+ const res = await handleApiSettingsRootRedirect(
369
+ getReq({ authorization: `Bearer ${bearer}` }),
370
+ deps(h),
371
+ );
372
+ const body = (await res.json()) as Record<string, unknown>;
373
+ expect(body.root_mode).toBe("serve-app");
374
+ expect(body.resolved_mode).toBe("serve-app");
375
+ expect(body.mode_source).toBe("db");
376
+ });
377
+
378
+ test("GET surfaces an env-sourced mode while the stored row is null", async () => {
379
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
380
+ const res = await handleApiSettingsRootRedirect(
381
+ getReq({ authorization: `Bearer ${bearer}` }),
382
+ deps(h, { env: { PARACHUTE_HUB_ROOT_MODE: "serve-app" } }),
383
+ );
384
+ const body = (await res.json()) as Record<string, unknown>;
385
+ expect(body.root_mode).toBeNull();
386
+ expect(body.resolved_mode).toBe("serve-app");
387
+ expect(body.mode_source).toBe("env");
388
+ });
389
+
390
+ test("PUT stores serve-app + GET reflects it on the next request (no restart)", async () => {
391
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
392
+ const put = await handleApiSettingsRootRedirect(
393
+ putReq({ root_mode: "serve-app" }, { authorization: `Bearer ${bearer}` }),
394
+ deps(h),
395
+ );
396
+ expect(put.status).toBe(200);
397
+ expect((await put.json()) as unknown).toEqual({ root_redirect: null, root_mode: "serve-app" });
398
+ expect(getRootMode(h.db)).toBe("serve-app");
399
+ });
400
+
401
+ test("PUT redirect / null clears the row back to the default", async () => {
402
+ setRootMode(h.db, "serve-app");
403
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
404
+ // Writing the default "redirect" deletes the row (footgun-guard parity).
405
+ const res = await handleApiSettingsRootRedirect(
406
+ putReq({ root_mode: "redirect" }, { authorization: `Bearer ${bearer}` }),
407
+ deps(h),
408
+ );
409
+ expect(res.status).toBe(200);
410
+ expect(getRootMode(h.db)).toBeNull();
411
+ });
412
+
413
+ test("PUT rejects an invalid mode with 400 + writes nothing", async () => {
414
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
415
+ const res = await handleApiSettingsRootRedirect(
416
+ putReq({ root_mode: "serveapp" }, { authorization: `Bearer ${bearer}` }),
417
+ deps(h),
418
+ );
419
+ expect(res.status).toBe(400);
420
+ const body = (await res.json()) as Record<string, unknown>;
421
+ expect(body.error).toBe("invalid_root_mode");
422
+ expect(getRootMode(h.db)).toBeNull();
423
+ });
424
+
425
+ test("PUT applies both root_mode + root_redirect in one call", async () => {
426
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
427
+ const res = await handleApiSettingsRootRedirect(
428
+ putReq(
429
+ { root_mode: "serve-app", root_redirect: "/surface/fallback" },
430
+ { authorization: `Bearer ${bearer}` },
431
+ ),
432
+ deps(h),
433
+ );
434
+ expect(res.status).toBe(200);
435
+ expect((await res.json()) as unknown).toEqual({
436
+ root_redirect: "/surface/fallback",
437
+ root_mode: "serve-app",
438
+ });
439
+ expect(getRootMode(h.db)).toBe("serve-app");
440
+ expect(getRootRedirect(h.db)).toBe("/surface/fallback");
441
+ });
442
+
443
+ test("a rejected mode does NOT half-apply a valid redirect in the same call", async () => {
444
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
445
+ const res = await handleApiSettingsRootRedirect(
446
+ putReq(
447
+ { root_mode: "bogus", root_redirect: "/surface/should-not-land" },
448
+ { authorization: `Bearer ${bearer}` },
449
+ ),
450
+ deps(h),
451
+ );
452
+ expect(res.status).toBe(400);
453
+ // Both-validated-before-either-applied: the redirect was NOT written.
454
+ expect(getRootRedirect(h.db)).toBeNull();
455
+ expect(getRootMode(h.db)).toBeNull();
456
+ });
457
+ });
@@ -12,9 +12,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
12
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { hub, hubSetOrigin, hubSetRootRedirect, rewriteCaddyfileHost } from "../commands/hub.ts";
15
+ import {
16
+ hub,
17
+ hubSetOrigin,
18
+ hubSetRootMode,
19
+ hubSetRootRedirect,
20
+ rewriteCaddyfileHost,
21
+ } from "../commands/hub.ts";
16
22
  import { hubDbPath, openHubDb } from "../hub-db.ts";
17
- import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
23
+ import { getHubOrigin, getRootMode, getRootRedirect } from "../hub-settings.ts";
18
24
  import type { CommandResult } from "../tailscale/run.ts";
19
25
 
20
26
  describe("parachute hub set-origin", () => {
@@ -498,3 +504,65 @@ describe("parachute hub set-root-redirect", () => {
498
504
  expect(persisted()).toBe("/surface/via-dispatcher");
499
505
  });
500
506
  });
507
+
508
+ describe("parachute hub set-root-mode", () => {
509
+ let dir: string;
510
+ let log: string[];
511
+ const collect = (line: string) => log.push(line);
512
+
513
+ beforeEach(() => {
514
+ dir = mkdtempSync(join(tmpdir(), "hub-set-root-mode-"));
515
+ log = [];
516
+ });
517
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
518
+
519
+ function persisted(): string | null {
520
+ const db = openHubDb(hubDbPath(dir));
521
+ try {
522
+ return getRootMode(db);
523
+ } finally {
524
+ db.close();
525
+ }
526
+ }
527
+
528
+ test("persists serve-app to hub_settings.root_mode", async () => {
529
+ const code = await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
530
+ expect(code).toBe(0);
531
+ expect(persisted()).toBe("serve-app");
532
+ expect(log.join("\n")).toMatch(/serves the Parachute app/);
533
+ });
534
+
535
+ test("`redirect` stores as absence (the default), clearing any row", async () => {
536
+ await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
537
+ const code = await hubSetRootMode(["redirect"], { configDir: dir, log: collect });
538
+ expect(code).toBe(0);
539
+ expect(persisted()).toBeNull();
540
+ });
541
+
542
+ test("--clear deletes the row", async () => {
543
+ await hubSetRootMode(["serve-app"], { configDir: dir, log: collect });
544
+ const code = await hubSetRootMode(["--clear"], { configDir: dir, log: collect });
545
+ expect(code).toBe(0);
546
+ expect(persisted()).toBeNull();
547
+ });
548
+
549
+ test("rejects an unknown mode without writing", async () => {
550
+ for (const bad of ["serveapp", "SERVE-APP", "app", "/surface/x"]) {
551
+ const code = await hubSetRootMode([bad], { configDir: dir, log: collect });
552
+ expect(code).toBe(1);
553
+ expect(persisted()).toBeNull();
554
+ }
555
+ });
556
+
557
+ test("usage error (exit 1) when no mode + no --clear", async () => {
558
+ const code = await hubSetRootMode([], { configDir: dir, log: collect });
559
+ expect(code).toBe(1);
560
+ expect(persisted()).toBeNull();
561
+ });
562
+
563
+ test("routed through the `hub` dispatcher", async () => {
564
+ const code = await hub(["set-root-mode", "serve-app"], { configDir: dir, log: collect });
565
+ expect(code).toBe(0);
566
+ expect(persisted()).toBe("serve-app");
567
+ });
568
+ });
@@ -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";
@@ -6418,3 +6418,287 @@ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6418
6418
  }
6419
6419
  });
6420
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
+ });