@openparachute/hub 0.5.13 → 0.5.14-rc.1

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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +140 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules.test.ts +32 -32
  8. package/src/__tests__/api-users.test.ts +192 -2
  9. package/src/__tests__/chrome-strip.test.ts +15 -15
  10. package/src/__tests__/hub-server.test.ts +23 -23
  11. package/src/__tests__/notes-redirect.test.ts +20 -20
  12. package/src/__tests__/services-manifest.test.ts +40 -40
  13. package/src/__tests__/setup-wizard.test.ts +157 -19
  14. package/src/__tests__/setup.test.ts +1 -1
  15. package/src/__tests__/status.test.ts +39 -0
  16. package/src/__tests__/users.test.ts +261 -0
  17. package/src/__tests__/well-known.test.ts +9 -9
  18. package/src/account-home-ui.ts +404 -0
  19. package/src/admin-handlers.ts +49 -17
  20. package/src/admin-host-admin-token.ts +25 -0
  21. package/src/admin-vault-admin-token.ts +17 -0
  22. package/src/api-account.ts +72 -6
  23. package/src/api-modules.ts +3 -3
  24. package/src/api-users.ts +173 -12
  25. package/src/chrome-strip.ts +6 -6
  26. package/src/commands/status.ts +10 -1
  27. package/src/help.ts +2 -2
  28. package/src/hub-server.ts +50 -10
  29. package/src/hub-settings.ts +2 -2
  30. package/src/hub.ts +6 -6
  31. package/src/notes-redirect.ts +5 -5
  32. package/src/service-spec.ts +39 -18
  33. package/src/setup-wizard.ts +335 -28
  34. package/src/users.ts +112 -0
  35. package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
  36. package/web/ui/dist/index.html +1 -1
  37. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -171,7 +171,7 @@ describe("GET /api/modules", () => {
171
171
  supervisor_available: boolean;
172
172
  };
173
173
  // Curated order is preserved: vault → app → notes → scribe → runner.
174
- expect(body.modules.map((m) => m.short)).toEqual(["vault", "app", "notes", "scribe", "runner"]);
174
+ expect(body.modules.map((m) => m.short)).toEqual(["vault", "surface", "notes", "scribe", "runner"]);
175
175
  expect(body.modules.every((m) => m.available)).toBe(true);
176
176
  expect(body.modules.every((m) => !m.installed)).toBe(true);
177
177
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -202,12 +202,12 @@ describe("GET /api/modules", () => {
202
202
  available: boolean;
203
203
  }>;
204
204
  };
205
- const app = body.modules.find((m) => m.short === "app");
206
- expect(app).toBeDefined();
207
- expect(app?.package).toBe("@openparachute/app");
208
- expect(app?.display_name).toBe("App");
209
- expect(app?.tagline).toContain("auto-installs Notes");
210
- expect(app?.available).toBe(true);
205
+ const surface = body.modules.find((m) => m.short === "surface");
206
+ expect(surface).toBeDefined();
207
+ expect(surface?.package).toBe("@openparachute/surface");
208
+ expect(surface?.display_name).toBe("Surface");
209
+ expect(surface?.tagline).toContain("auto-installs Notes");
210
+ expect(surface?.available).toBe(true);
211
211
  });
212
212
 
213
213
  test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
@@ -366,9 +366,9 @@ describe("GET /api/modules", () => {
366
366
  });
367
367
 
368
368
  test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
369
- // Audit caught 2026-05-25: app declares `managementUrl: "/app/admin/"`
370
- // (full hub-origin path) and `paths: ["/app", "/.parachute"]`. The
371
- // SPA's Services dropdown was navigating to `/app/app/admin/` (404)
369
+ // Audit caught 2026-05-25: app declares `managementUrl: "/surface/admin/"`
370
+ // (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
371
+ // SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
372
372
  // because api-modules unconditionally prepended the mount onto the
373
373
  // candidate. Fix: detect already-mount-prefixed paths and pass through.
374
374
  //
@@ -376,12 +376,12 @@ describe("GET /api/modules", () => {
376
376
  // multi-instance modules (vault) use the per-instance relative form.
377
377
  writeManifest(h.manifestPath, [
378
378
  {
379
- name: "parachute-app",
379
+ name: "parachute-surface",
380
380
  port: 1946,
381
- paths: ["/app", "/.parachute"],
382
- health: "/app/healthz",
381
+ paths: ["/surface", "/.parachute"],
382
+ health: "/surface/healthz",
383
383
  version: "0.2.0-rc.13",
384
- installDir: "/install/dir/app",
384
+ installDir: "/install/dir/surface",
385
385
  },
386
386
  ]);
387
387
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -391,17 +391,17 @@ describe("GET /api/modules", () => {
391
391
  manifestPath: h.manifestPath,
392
392
  fetchLatestVersion: async () => null,
393
393
  readModuleManifest: async (installDir) => {
394
- if (installDir === "/install/dir/app") {
394
+ if (installDir === "/install/dir/surface") {
395
395
  return {
396
- name: "app",
397
- manifestName: "parachute-app",
398
- displayName: "App",
396
+ name: "surface",
397
+ manifestName: "parachute-surface",
398
+ displayName: "Surface",
399
399
  tagline: "",
400
400
  port: 1946,
401
- paths: ["/app", "/.parachute"],
402
- health: "/app/healthz",
403
- uiUrl: "/app/admin/",
404
- managementUrl: "/app/admin/",
401
+ paths: ["/surface", "/.parachute"],
402
+ health: "/surface/healthz",
403
+ uiUrl: "/surface/admin/",
404
+ managementUrl: "/surface/admin/",
405
405
  } as unknown as Awaited<
406
406
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
407
407
  >;
@@ -413,9 +413,9 @@ describe("GET /api/modules", () => {
413
413
  const body = (await res.json()) as {
414
414
  modules: Array<{ short: string; management_url: string | null }>;
415
415
  };
416
- const app = body.modules.find((m) => m.short === "app");
417
- // Single `/app/`, not `/app/app/`.
418
- expect(app?.management_url).toBe("/app/admin/");
416
+ const surface = body.modules.find((m) => m.short === "surface");
417
+ // Single `/surface/`, not `/surface/surface/`.
418
+ expect(surface?.management_url).toBe("/surface/admin/");
419
419
  });
420
420
 
421
421
  test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
@@ -430,8 +430,8 @@ describe("GET /api/modules", () => {
430
430
  {
431
431
  name: "parachute-vault",
432
432
  port: 1940,
433
- paths: ["/app"], // mount is /app (using vault as a stand-in installable)
434
- health: "/app/health",
433
+ paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
434
+ health: "/surface/health",
435
435
  version: "0.4.5",
436
436
  installDir: "/install/dir/contrived",
437
437
  },
@@ -450,8 +450,8 @@ describe("GET /api/modules", () => {
450
450
  displayName: "Vault",
451
451
  tagline: "",
452
452
  port: 1940,
453
- paths: ["/app"],
454
- health: "/app/health",
453
+ paths: ["/surface"],
454
+ health: "/surface/health",
455
455
  // candidate looks like a sibling-name prefix but is NOT a
456
456
  // mount-prefix of /app — should still get prepended.
457
457
  managementUrl: "/app-foo/admin",
@@ -467,9 +467,9 @@ describe("GET /api/modules", () => {
467
467
  modules: Array<{ short: string; management_url: string | null }>;
468
468
  };
469
469
  const vault = body.modules.find((m) => m.short === "vault");
470
- // /app + /app-foo/admin → /app/app-foo/admin (prepend fires; not treated
471
- // as already-mount-prefixed because /app-foo/ doesn't start with /app/).
472
- expect(vault?.management_url).toBe("/app/app-foo/admin");
470
+ // /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
471
+ // treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
472
+ expect(vault?.management_url).toBe("/surface/app-foo/admin");
473
473
  });
474
474
 
475
475
  test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Tests for `/api/users*` (multi-user Phase 1, PR 2). Covers:
2
+ * Tests for `/api/users*` (multi-user Phase 1 PR 2 + Phase 2 PR 1).
3
+ * Covers:
3
4
  *
4
5
  * - Auth boundary: every endpoint requires a bearer carrying
5
6
  * `parachute:host:admin`.
@@ -11,6 +12,10 @@
11
12
  * 400 `assigned_vault_not_found`).
12
13
  * - DELETE happy path with token revocation; first-admin-undeletable
13
14
  * returns 403; 404 on unknown id.
15
+ * - POST /:id/reset-password (Phase 2 PR 1) happy path with token
16
+ * revocation, first-admin protection (403 cannot_reset_first_admin),
17
+ * password-validation branches (too-short 400, too-long 413 before
18
+ * argon2id), missing target (404), auth boundary.
14
19
  * - GET /api/users/vaults returns the same name set the OAuth issuer
15
20
  * would resolve against.
16
21
  * - 405 on wrong methods.
@@ -25,10 +30,11 @@ import {
25
30
  handleDeleteUser,
26
31
  handleListUsers,
27
32
  handleListVaults,
33
+ handleResetUserPassword,
28
34
  } from "../api-users.ts";
29
35
  import { hubDbPath, openHubDb } from "../hub-db.ts";
30
36
  import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
31
- import { createUser } from "../users.ts";
37
+ import { createUser, getUserById, verifyPassword } from "../users.ts";
32
38
 
33
39
  const ISSUER = "https://hub.test";
34
40
  const HOST_ADMIN_SCOPE = "parachute:host:admin";
@@ -477,6 +483,190 @@ describe("handleDeleteUser", () => {
477
483
  });
478
484
  });
479
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // POST /api/users/:id/reset-password — admin-initiated password reset
488
+ // (multi-user Phase 2 PR 1)
489
+ // ---------------------------------------------------------------------------
490
+
491
+ describe("handleResetUserPassword", () => {
492
+ async function post(
493
+ bearer: string | null,
494
+ id: string,
495
+ body: Record<string, unknown> | string | null,
496
+ headers: Record<string, string> = {},
497
+ ): Promise<Response> {
498
+ const init: RequestInit = {
499
+ method: "POST",
500
+ headers: {
501
+ "content-type": "application/json",
502
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
503
+ ...headers,
504
+ },
505
+ body: body === null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
506
+ };
507
+ return await handleResetUserPassword(req(`/api/users/${id}/reset-password`, init), id, deps());
508
+ }
509
+
510
+ test("401 with no Authorization header", async () => {
511
+ const res = await post(null, "some-id", { new_password: "twelvechars1" });
512
+ expect(res.status).toBe(401);
513
+ });
514
+
515
+ test("403 when bearer lacks parachute:host:admin", async () => {
516
+ const { bearer } = await makeAdminBearer(["other:scope"]);
517
+ const res = await post(bearer, "some-id", { new_password: "twelvechars1" });
518
+ expect(res.status).toBe(403);
519
+ });
520
+
521
+ test("405 on GET", async () => {
522
+ const { bearer } = await makeAdminBearer();
523
+ const res = await handleResetUserPassword(
524
+ withBearer("/api/users/some-id/reset-password", bearer, { method: "GET" }),
525
+ "some-id",
526
+ deps(),
527
+ );
528
+ expect(res.status).toBe(405);
529
+ });
530
+
531
+ test("404 when target user does not exist", async () => {
532
+ const { bearer } = await makeAdminBearer();
533
+ const res = await post(bearer, "no-such-id", { new_password: "twelvechars1" });
534
+ expect(res.status).toBe(404);
535
+ const body = (await res.json()) as { error: string };
536
+ expect(body.error).toBe("not_found");
537
+ });
538
+
539
+ test("403 cannot_reset_first_admin when targeting the first admin", async () => {
540
+ const { bearer, userId } = await makeAdminBearer();
541
+ const res = await post(bearer, userId, { new_password: "twelvechars1" });
542
+ expect(res.status).toBe(403);
543
+ const body = (await res.json()) as { error: string };
544
+ expect(body.error).toBe("cannot_reset_first_admin");
545
+ // First-admin row's hash + password_changed bit untouched.
546
+ const fresh = getUserById(harness.db, userId);
547
+ expect(fresh?.passwordChanged).toBe(true);
548
+ });
549
+
550
+ test("400 invalid_password when new_password is too short (< 12 chars)", async () => {
551
+ const { bearer } = await makeAdminBearer();
552
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
553
+ allowMulti: true,
554
+ passwordChanged: true,
555
+ });
556
+ const res = await post(bearer, friend.id, { new_password: "short" });
557
+ expect(res.status).toBe(400);
558
+ const body = (await res.json()) as { error: string; error_description: string };
559
+ expect(body.error).toBe("invalid_password");
560
+ expect(body.error_description).toMatch(/12 characters/);
561
+ });
562
+
563
+ test("413 password_too_long when new_password > 256 chars (before argon2id touches it)", async () => {
564
+ const { bearer } = await makeAdminBearer();
565
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
566
+ allowMulti: true,
567
+ passwordChanged: true,
568
+ });
569
+ const huge = "a".repeat(300);
570
+ const t0 = Date.now();
571
+ const res = await post(bearer, friend.id, { new_password: huge });
572
+ const elapsed = Date.now() - t0;
573
+ expect(res.status).toBe(413);
574
+ const body = (await res.json()) as { error: string };
575
+ expect(body.error).toBe("password_too_long");
576
+ // Same liveness check as the create-user path: 300-char argon2id is
577
+ // hundreds of ms; cap-and-reject should complete in <200ms.
578
+ expect(elapsed).toBeLessThan(200);
579
+ });
580
+
581
+ test("400 invalid_request when content-type is not application/json", async () => {
582
+ const { bearer } = await makeAdminBearer();
583
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
584
+ allowMulti: true,
585
+ passwordChanged: true,
586
+ });
587
+ const res = await post(bearer, friend.id, "new_password=twelvechars1", {
588
+ "content-type": "application/x-www-form-urlencoded",
589
+ });
590
+ expect(res.status).toBe(400);
591
+ });
592
+
593
+ test("400 invalid_request when new_password missing", async () => {
594
+ const { bearer } = await makeAdminBearer();
595
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
596
+ allowMulti: true,
597
+ passwordChanged: true,
598
+ });
599
+ const res = await post(bearer, friend.id, {});
600
+ expect(res.status).toBe(400);
601
+ const body = (await res.json()) as { error: string };
602
+ expect(body.error).toBe("invalid_request");
603
+ });
604
+
605
+ test("happy path — rotates hash, flips password_changed=false, returns user shape", async () => {
606
+ const { bearer } = await makeAdminBearer();
607
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
608
+ allowMulti: true,
609
+ passwordChanged: true,
610
+ });
611
+ const oldHash = friend.passwordHash;
612
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
613
+ expect(res.status).toBe(200);
614
+ const body = (await res.json()) as {
615
+ ok: boolean;
616
+ user: { id: string; password_changed: boolean; username: string };
617
+ };
618
+ expect(body.ok).toBe(true);
619
+ expect(body.user.id).toBe(friend.id);
620
+ expect(body.user.username).toBe("alice");
621
+ expect(body.user.password_changed).toBe(false);
622
+ // Echo body never carries the hash (or the new password).
623
+ expect(body.user).not.toHaveProperty("password_hash");
624
+ expect(body).not.toHaveProperty("new_password");
625
+ // Round-trip on the user row: new password works, old does not, hash
626
+ // moved, password_changed is now false (force-redirect on next login).
627
+ const fresh = getUserById(harness.db, friend.id);
628
+ expect(fresh).not.toBeNull();
629
+ expect(fresh?.passwordHash).not.toBe(oldHash);
630
+ expect(fresh?.passwordChanged).toBe(false);
631
+ expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
632
+ expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
633
+ });
634
+
635
+ test("revokes the friend's existing tokens (pre-reset token row has revoked_at after)", async () => {
636
+ const { bearer } = await makeAdminBearer();
637
+ const friend = await createUser(harness.db, "alice", "alice-strong-passphrase", {
638
+ allowMulti: true,
639
+ passwordChanged: true,
640
+ });
641
+ const minted = await signAccessToken(harness.db, {
642
+ sub: friend.id,
643
+ scopes: ["vault:home:read"],
644
+ audience: "vault",
645
+ clientId: "notes-client",
646
+ issuer: ISSUER,
647
+ ttlSeconds: 600,
648
+ });
649
+ recordTokenMint(harness.db, {
650
+ jti: minted.jti,
651
+ createdVia: "operator_mint",
652
+ subject: friend.username,
653
+ userId: friend.id,
654
+ clientId: "notes-client",
655
+ scopes: ["vault:home:read"],
656
+ expiresAt: minted.expiresAt,
657
+ });
658
+ expect(findTokenRowByJti(harness.db, minted.jti)?.revokedAt).toBeNull();
659
+
660
+ const res = await post(bearer, friend.id, { new_password: "new-temp-passphrase" });
661
+ expect(res.status).toBe(200);
662
+
663
+ const row = findTokenRowByJti(harness.db, minted.jti);
664
+ expect(row?.revokedAt).not.toBeNull();
665
+ // User row sticks around (unlike delete), so user_id is NOT NULLed.
666
+ expect(row?.userId).toBe(friend.id);
667
+ });
668
+ });
669
+
480
670
  // ---------------------------------------------------------------------------
481
671
  // GET /api/users/vaults — vault-name list for the assigned-vault dropdown
482
672
  // ---------------------------------------------------------------------------
@@ -82,21 +82,21 @@ describe("shouldInjectChrome", () => {
82
82
  expect(shouldInjectChrome("/admin/vaults")).toBe(true);
83
83
  expect(shouldInjectChrome("/scribe/admin")).toBe(true);
84
84
  expect(shouldInjectChrome("/vault/default/admin/")).toBe(true);
85
- expect(shouldInjectChrome("/app/admin/modules")).toBe(true);
85
+ expect(shouldInjectChrome("/surface/admin/modules")).toBe(true);
86
86
  });
87
87
 
88
- test("default: opts out the Notes PWA at /app/notes/*", () => {
89
- expect(shouldInjectChrome("/app/notes")).toBe(false);
90
- expect(shouldInjectChrome("/app/notes/")).toBe(false);
91
- expect(shouldInjectChrome("/app/notes/index.html")).toBe(false);
92
- expect(shouldInjectChrome("/app/notes/assets/index-XXX.js")).toBe(false);
88
+ test("default: opts out the Notes PWA at /surface/notes/*", () => {
89
+ expect(shouldInjectChrome("/surface/notes")).toBe(false);
90
+ expect(shouldInjectChrome("/surface/notes/")).toBe(false);
91
+ expect(shouldInjectChrome("/surface/notes/index.html")).toBe(false);
92
+ expect(shouldInjectChrome("/surface/notes/assets/index-XXX.js")).toBe(false);
93
93
  });
94
94
 
95
95
  test("opt-out prefix matching does not over-match sibling paths", () => {
96
- // `/app/notesbook` must NOT match `/app/notes/` — startsWith check
96
+ // `/surface/notesbook` must NOT match `/surface/notes/` — startsWith check
97
97
  // requires a trailing slash boundary.
98
- expect(shouldInjectChrome("/app/notesbook")).toBe(true);
99
- expect(shouldInjectChrome("/app/notes-archive/")).toBe(true);
98
+ expect(shouldInjectChrome("/surface/notesbook")).toBe(true);
99
+ expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
100
100
  });
101
101
 
102
102
  test("custom opt-out list is honored", () => {
@@ -111,8 +111,8 @@ describe("shouldInjectChrome", () => {
111
111
  expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
112
112
  });
113
113
 
114
- test("the canonical opt-out list contains /app/notes/", () => {
115
- expect(CHROME_OPT_OUT_PREFIXES).toContain("/app/notes/");
114
+ test("the canonical opt-out list contains /surface/notes/", () => {
115
+ expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
116
116
  });
117
117
  });
118
118
 
@@ -250,27 +250,27 @@ describe("injectChromeIntoResponse", () => {
250
250
  expect(cssOut).toBe(css);
251
251
  });
252
252
 
253
- test("passes through responses on opt-out paths (/app/notes/) unchanged", async () => {
253
+ test("passes through responses on opt-out paths (/surface/notes/) unchanged", async () => {
254
254
  const res = new Response("<html><body>notes</body></html>", {
255
255
  status: 200,
256
256
  headers: { "content-type": "text/html" },
257
257
  });
258
258
  const out = await injectChromeIntoResponse(res, {
259
259
  chromeHtml: chrome,
260
- pathname: "/app/notes/",
260
+ pathname: "/surface/notes/",
261
261
  });
262
262
  expect(out).toBe(res);
263
263
  expect(await out.text()).toBe("<html><body>notes</body></html>");
264
264
  });
265
265
 
266
- test("passes through responses on opt-out sub-paths (/app/notes/assets/x.js)", async () => {
266
+ test("passes through responses on opt-out sub-paths (/surface/notes/assets/x.js)", async () => {
267
267
  const res = new Response("<html><body>notes</body></html>", {
268
268
  status: 200,
269
269
  headers: { "content-type": "text/html" },
270
270
  });
271
271
  const out = await injectChromeIntoResponse(res, {
272
272
  chromeHtml: chrome,
273
- pathname: "/app/notes/index.html",
273
+ pathname: "/surface/notes/index.html",
274
274
  });
275
275
  expect(out).toBe(res);
276
276
  });
@@ -995,22 +995,22 @@ describe("hubFetch routing", () => {
995
995
  });
996
996
 
997
997
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
998
- // `/notes/*` 301-redirects to `/app/notes/*` so legacy bookmarks land
998
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land
999
999
  // on the apps-hosted Notes. Tested with no DB (the migration-default
1000
1000
  // path — absent DB or absent row means redirect-on).
1001
- test("301: /notes/ → /app/notes/", async () => {
1001
+ test("301: /notes/ → /surface/notes/", async () => {
1002
1002
  clearNotesRedirectLogState();
1003
1003
  const h = makeHarness();
1004
1004
  try {
1005
1005
  const res = await hubFetch(h.dir)(req("/notes/"));
1006
1006
  expect(res.status).toBe(301);
1007
- expect(res.headers.get("location")).toBe("/app/notes/");
1007
+ expect(res.headers.get("location")).toBe("/surface/notes/");
1008
1008
  } finally {
1009
1009
  h.cleanup();
1010
1010
  }
1011
1011
  });
1012
1012
 
1013
- test("301: bare /notes → /app/notes", async () => {
1013
+ test("301: bare /notes → /surface/notes", async () => {
1014
1014
  // The bare-prefix form (no trailing slash) is the path browsers land
1015
1015
  // on when an operator types `https://hub.example/notes` directly.
1016
1016
  clearNotesRedirectLogState();
@@ -1018,7 +1018,7 @@ describe("hubFetch routing", () => {
1018
1018
  try {
1019
1019
  const res = await hubFetch(h.dir)(req("/notes"));
1020
1020
  expect(res.status).toBe(301);
1021
- expect(res.headers.get("location")).toBe("/app/notes");
1021
+ expect(res.headers.get("location")).toBe("/surface/notes");
1022
1022
  } finally {
1023
1023
  h.cleanup();
1024
1024
  }
@@ -1030,7 +1030,7 @@ describe("hubFetch routing", () => {
1030
1030
  try {
1031
1031
  const res = await hubFetch(h.dir)(req("/notes/some/path?q=1&n=2"));
1032
1032
  expect(res.status).toBe(301);
1033
- expect(res.headers.get("location")).toBe("/app/notes/some/path?q=1&n=2");
1033
+ expect(res.headers.get("location")).toBe("/surface/notes/some/path?q=1&n=2");
1034
1034
  } finally {
1035
1035
  h.cleanup();
1036
1036
  }
@@ -2240,7 +2240,7 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2240
2240
  // motivator for the `--mount` strip in notes-serve.ts).
2241
2241
  //
2242
2242
  // Post-parachute-app §16 Phase 2 the `/notes/*` path 301-redirects to
2243
- // `/app/notes/*` by default. This test pins the notes-as-module legacy
2243
+ // `/surface/notes/*` by default. This test pins the notes-as-module legacy
2244
2244
  // path (notes-daemon still serving its own mount); set the opt-out
2245
2245
  // flag so the dispatch falls through to the generic proxy.
2246
2246
  const h = makeHarness();
@@ -3453,7 +3453,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3453
3453
  // Pins the proxy-side wiring of the chrome strip from
3454
3454
  // `parachute-patterns/patterns/design-system.md` §7 — every proxied
3455
3455
  // text/html response gets the strip injected after the first `<body>`,
3456
- // with opt-outs for `/app/notes/*` (the Notes PWA owns its own chrome).
3456
+ // with opt-outs for `/surface/notes/*` (the Notes PWA owns its own chrome).
3457
3457
  // The pure rewrite + opt-out logic is covered in chrome-strip.test.ts;
3458
3458
  // here we exercise the dispatch integration end-to-end through hubFetch.
3459
3459
 
@@ -3608,7 +3608,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3608
3608
  }
3609
3609
  });
3610
3610
 
3611
- test("does NOT inject chrome on /app/notes/* (Notes PWA owns its own chrome)", async () => {
3611
+ test("does NOT inject chrome on /surface/notes/* (Notes PWA owns its own chrome)", async () => {
3612
3612
  const h = makeHarness();
3613
3613
  const upstream = startHtmlUpstream("<html><body><h1>Notes</h1></body></html>");
3614
3614
  try {
@@ -3616,10 +3616,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3616
3616
  {
3617
3617
  services: [
3618
3618
  {
3619
- name: "parachute-app",
3619
+ name: "parachute-surface",
3620
3620
  port: upstream.port,
3621
- paths: ["/app"],
3622
- health: "/app/health",
3621
+ paths: ["/surface"],
3622
+ health: "/surface/health",
3623
3623
  version: "0.1.0",
3624
3624
  },
3625
3625
  ],
@@ -3627,7 +3627,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3627
3627
  h.manifestPath,
3628
3628
  );
3629
3629
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3630
- const res = await fetcher(req("/app/notes/"));
3630
+ const res = await fetcher(req("/surface/notes/"));
3631
3631
  expect(res.status).toBe(200);
3632
3632
  const body = await res.text();
3633
3633
  expect(body).toBe("<html><body><h1>Notes</h1></body></html>");
@@ -3638,7 +3638,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3638
3638
  }
3639
3639
  });
3640
3640
 
3641
- test("DOES inject chrome on /app/admin/* (parachute-app admin, not Notes)", async () => {
3641
+ test("DOES inject chrome on /surface/admin/* (parachute-app admin, not Notes)", async () => {
3642
3642
  const h = makeHarness();
3643
3643
  const upstream = startHtmlUpstream("<html><body>app admin</body></html>");
3644
3644
  try {
@@ -3646,10 +3646,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3646
3646
  {
3647
3647
  services: [
3648
3648
  {
3649
- name: "parachute-app",
3649
+ name: "parachute-surface",
3650
3650
  port: upstream.port,
3651
- paths: ["/app"],
3652
- health: "/app/health",
3651
+ paths: ["/surface"],
3652
+ health: "/surface/health",
3653
3653
  version: "0.1.0",
3654
3654
  },
3655
3655
  ],
@@ -3657,7 +3657,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3657
3657
  h.manifestPath,
3658
3658
  );
3659
3659
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3660
- const res = await fetcher(req("/app/admin/"));
3660
+ const res = await fetcher(req("/surface/admin/"));
3661
3661
  expect(res.status).toBe(200);
3662
3662
  const body = await res.text();
3663
3663
  expect(body).toContain("pc-chrome");
@@ -3668,7 +3668,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3668
3668
  }
3669
3669
  });
3670
3670
 
3671
- test("does NOT inject on /app/notes/ sub-paths (asset requests)", async () => {
3671
+ test("does NOT inject on /surface/notes/ sub-paths (asset requests)", async () => {
3672
3672
  const h = makeHarness();
3673
3673
  const upstream = startHtmlUpstream("<html><body>asset shell</body></html>");
3674
3674
  try {
@@ -3676,10 +3676,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3676
3676
  {
3677
3677
  services: [
3678
3678
  {
3679
- name: "parachute-app",
3679
+ name: "parachute-surface",
3680
3680
  port: upstream.port,
3681
- paths: ["/app"],
3682
- health: "/app/health",
3681
+ paths: ["/surface"],
3682
+ health: "/surface/health",
3683
3683
  version: "0.1.0",
3684
3684
  },
3685
3685
  ],
@@ -3687,7 +3687,7 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3687
3687
  h.manifestPath,
3688
3688
  );
3689
3689
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3690
- const res = await fetcher(req("/app/notes/index.html"));
3690
+ const res = await fetcher(req("/surface/notes/index.html"));
3691
3691
  expect(res.status).toBe(200);
3692
3692
  const body = await res.text();
3693
3693
  expect(body).not.toContain("pc-chrome");