@openparachute/hub 0.5.13 → 0.5.14-rc.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -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-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -151,7 +151,7 @@ describe("openHubDb + migrate", () => {
151
151
  }
152
152
  });
153
153
 
154
- test("v8 adds password_changed + assigned_vault columns on a fresh DB", () => {
154
+ test("v8 added password_changed column (still present at v10)", () => {
155
155
  const h = makeHarness();
156
156
  try {
157
157
  const db = openHubDb(h.dbPath);
@@ -160,8 +160,9 @@ describe("openHubDb + migrate", () => {
160
160
  db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
161
161
  ).map((r) => r.version);
162
162
  expect(versions).toContain(8);
163
- // PRAGMA table_info returns the column shape; we want both new
164
- // columns present with the right defaults / nullability.
163
+ // PRAGMA table_info returns the column shape; password_changed
164
+ // should still be on users at v10 (only assigned_vault was
165
+ // dropped in v10's recreate).
165
166
  interface ColInfo {
166
167
  name: string;
167
168
  type: string;
@@ -180,10 +181,8 @@ describe("openHubDb + migrate", () => {
180
181
  expect(pc?.notnull).toBe(1);
181
182
  // Default literal — SQLite returns it as a string "0".
182
183
  expect(pc?.dflt_value).toBe("0");
183
- const av = byName.get("assigned_vault");
184
- expect(av).toBeDefined();
185
- expect(av?.type).toBe("TEXT");
186
- expect(av?.notnull).toBe(0);
184
+ // v10 dropped assigned_vault — verify the column is gone.
185
+ expect(byName.has("assigned_vault")).toBe(false);
187
186
  } finally {
188
187
  db.close();
189
188
  }
@@ -195,21 +194,13 @@ describe("openHubDb + migrate", () => {
195
194
  test("v8 backfills password_changed=1 for users that pre-date the migration", () => {
196
195
  const h = makeHarness();
197
196
  try {
198
- // Stand up a DB at the v7 state by partially-applying migrations:
199
- // open Database directly, call migrate after stripping the v8 entry
200
- // would be invasive. Instead, drive the same migration shape by hand
201
- // for v1-v7 then insert a row, then call migrate() to apply v8.
202
- // Cleanest path: openHubDb runs everything, but we want a v7 snapshot.
203
- // Approach: open with openHubDb (runs all migrations), drop the v8
204
- // changes, mark v8 unapplied, insert a user with password_changed=0
205
- // (simulating a row from before the backfill), then re-run migrate.
206
- // SQLite doesn't have DROP COLUMN pre-3.35 universally, so we do the
207
- // recreate-and-rename: drop v8's columns by recreating users without
208
- // them, then delete the v8 schema_version row, then call migrate().
197
+ // Stand up a DB at the v7 state by recreating the users table
198
+ // without the v8/v10 columns and re-running migrate().
209
199
  const db = openHubDb(h.dbPath);
210
200
  try {
211
201
  // Build a v7-shape users table and copy the v8-shape rows.
212
202
  db.exec(`
203
+ DROP TABLE IF EXISTS user_vaults;
213
204
  CREATE TABLE users_v7 (
214
205
  id TEXT PRIMARY KEY,
215
206
  username TEXT UNIQUE NOT NULL,
@@ -222,26 +213,26 @@ describe("openHubDb + migrate", () => {
222
213
  DROP TABLE users;
223
214
  ALTER TABLE users_v7 RENAME TO users;
224
215
  `);
225
- db.exec("DELETE FROM schema_version WHERE version = 8");
216
+ db.exec("DELETE FROM schema_version WHERE version IN (8, 10)");
226
217
  // Insert a row that pre-dates v8 (no password_changed column yet).
227
218
  db.prepare(
228
219
  `INSERT INTO users (id, username, password_hash, created_at, updated_at)
229
220
  VALUES (?, ?, ?, ?, ?)`,
230
221
  ).run("legacy-user", "owner", "h", "2026-01-01", "2026-01-01");
231
- // Now re-run migrations — v8 should ALTER the table and backfill.
222
+ // Now re-run migrations — v8 + v10 apply.
232
223
  migrate(db);
233
224
  const row = db
234
- .query<{ password_changed: number; assigned_vault: string | null }, [string]>(
235
- "SELECT password_changed, assigned_vault FROM users WHERE id = ?",
225
+ .query<{ password_changed: number }, [string]>(
226
+ "SELECT password_changed FROM users WHERE id = ?",
236
227
  )
237
228
  .get("legacy-user");
238
229
  expect(row).not.toBeNull();
239
230
  expect(row?.password_changed).toBe(1);
240
- expect(row?.assigned_vault).toBeNull();
241
231
  const versions = (
242
232
  db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
243
233
  ).map((r) => r.version);
244
234
  expect(versions).toContain(8);
235
+ expect(versions).toContain(10);
245
236
  } finally {
246
237
  db.close();
247
238
  }
@@ -250,24 +241,198 @@ describe("openHubDb + migrate", () => {
250
241
  }
251
242
  });
252
243
 
253
- test("v8 — fresh inserts default password_changed=0 and assigned_vault NULL", () => {
244
+ test("v8 — fresh inserts default password_changed=0 (v10 dropped assigned_vault)", () => {
254
245
  const h = makeHarness();
255
246
  try {
256
247
  const db = openHubDb(h.dbPath);
257
248
  try {
258
- // Insert via the bare-columns SQL (mirrors what a pre-v8 caller
259
- // would emit) to confirm the column DEFAULTs work.
249
+ // Insert via the bare-columns SQL to confirm the column DEFAULTs work.
260
250
  db.prepare(
261
251
  `INSERT INTO users (id, username, password_hash, created_at, updated_at)
262
252
  VALUES (?, ?, ?, ?, ?)`,
263
253
  ).run("u-default", "owner", "h", "2026-01-01", "2026-01-01");
264
254
  const row = db
265
- .query<{ password_changed: number; assigned_vault: string | null }, [string]>(
266
- "SELECT password_changed, assigned_vault FROM users WHERE id = ?",
255
+ .query<{ password_changed: number }, [string]>(
256
+ "SELECT password_changed FROM users WHERE id = ?",
267
257
  )
268
258
  .get("u-default");
269
259
  expect(row?.password_changed).toBe(0);
270
- expect(row?.assigned_vault).toBeNull();
260
+ // user_vaults table is empty for a default insert.
261
+ const vaultCount = db
262
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
263
+ .get("u-default");
264
+ expect(vaultCount?.n).toBe(0);
265
+ } finally {
266
+ db.close();
267
+ }
268
+ } finally {
269
+ h.cleanup();
270
+ }
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // v10 — user_vaults many-to-many membership (multi-user Phase 2 PR 2)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ test("v10 creates user_vaults table with the expected shape", () => {
278
+ const h = makeHarness();
279
+ try {
280
+ const db = openHubDb(h.dbPath);
281
+ try {
282
+ const versions = (
283
+ db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
284
+ ).map((r) => r.version);
285
+ expect(versions).toContain(10);
286
+ const tables = (
287
+ db
288
+ .query<{ name: string }, []>(
289
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
290
+ )
291
+ .all() ?? []
292
+ ).map((r) => r.name);
293
+ expect(tables).toContain("user_vaults");
294
+ interface ColInfo {
295
+ name: string;
296
+ type: string;
297
+ notnull: number;
298
+ dflt_value: string | null;
299
+ }
300
+ const cols = db
301
+ .query<ColInfo, []>(
302
+ "SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('user_vaults')",
303
+ )
304
+ .all();
305
+ const names = cols.map((c) => c.name);
306
+ expect(names).toContain("user_id");
307
+ expect(names).toContain("vault_name");
308
+ expect(names).toContain("role");
309
+ expect(names).toContain("created_at");
310
+ const role = cols.find((c) => c.name === "role");
311
+ expect(role?.notnull).toBe(1);
312
+ // SQLite represents the default literal verbatim — `'write'`.
313
+ expect(role?.dflt_value).toBe("'write'");
314
+ } finally {
315
+ db.close();
316
+ }
317
+ } finally {
318
+ h.cleanup();
319
+ }
320
+ });
321
+
322
+ test("v10 backfills user_vaults from v9 assigned_vault column", () => {
323
+ const h = makeHarness();
324
+ try {
325
+ const db = openHubDb(h.dbPath);
326
+ try {
327
+ // Rebuild a v9-shape users table (with assigned_vault column),
328
+ // mark v10 unapplied, drop user_vaults, populate fixture rows,
329
+ // then re-run migrate to apply v10's backfill.
330
+ db.exec(`
331
+ DROP TABLE IF EXISTS user_vaults;
332
+ CREATE TABLE users_v9 (
333
+ id TEXT PRIMARY KEY,
334
+ username TEXT UNIQUE NOT NULL,
335
+ password_hash TEXT NOT NULL,
336
+ created_at TEXT NOT NULL,
337
+ updated_at TEXT NOT NULL,
338
+ password_changed INTEGER NOT NULL DEFAULT 0,
339
+ assigned_vault TEXT
340
+ );
341
+ INSERT INTO users_v9 (id, username, password_hash, created_at, updated_at, password_changed, assigned_vault)
342
+ VALUES
343
+ ('u-admin', 'admin', 'h', '2026-01-01', '2026-01-01', 1, NULL),
344
+ ('u-alice', 'alice', 'h', '2026-01-02', '2026-01-02', 1, 'personal'),
345
+ ('u-bob', 'bob', 'h', '2026-01-03', '2026-01-03', 1, 'family');
346
+ DROP TABLE users;
347
+ ALTER TABLE users_v9 RENAME TO users;
348
+ `);
349
+ db.exec("DELETE FROM schema_version WHERE version = 10");
350
+ migrate(db);
351
+ // Expect 2 rows in user_vaults (admin had NULL → no row).
352
+ const rows = db
353
+ .query<{ user_id: string; vault_name: string; role: string }, []>(
354
+ "SELECT user_id, vault_name, role FROM user_vaults ORDER BY user_id ASC",
355
+ )
356
+ .all();
357
+ expect(rows.length).toBe(2);
358
+ expect(rows[0]).toMatchObject({
359
+ user_id: "u-alice",
360
+ vault_name: "personal",
361
+ role: "write",
362
+ });
363
+ expect(rows[1]).toMatchObject({ user_id: "u-bob", vault_name: "family", role: "write" });
364
+ // No row for the admin.
365
+ const adminRows = db
366
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
367
+ .get("u-admin");
368
+ expect(adminRows?.n).toBe(0);
369
+ // assigned_vault column should be gone.
370
+ interface ColInfo {
371
+ name: string;
372
+ }
373
+ const cols = db.query<ColInfo, []>("SELECT name FROM pragma_table_info('users')").all();
374
+ expect(cols.map((c) => c.name)).not.toContain("assigned_vault");
375
+ } finally {
376
+ db.close();
377
+ }
378
+ } finally {
379
+ h.cleanup();
380
+ }
381
+ });
382
+
383
+ test("v10 FK cascade: deleting a user drops their user_vaults rows", () => {
384
+ const h = makeHarness();
385
+ try {
386
+ const db = openHubDb(h.dbPath);
387
+ try {
388
+ const stamp = "2026-05-27T00:00:00.000Z";
389
+ db.prepare(
390
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
391
+ ).run("u1", "alice", "h", stamp, stamp, 1);
392
+ db.prepare(
393
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
394
+ ).run("u1", "personal", "write", stamp);
395
+ db.prepare(
396
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
397
+ ).run("u1", "family", "write", stamp);
398
+ // sanity
399
+ const before = db
400
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
401
+ .get("u1");
402
+ expect(before?.n).toBe(2);
403
+ // Delete the user — ON DELETE CASCADE should drop the user_vaults rows.
404
+ db.prepare("DELETE FROM users WHERE id = ?").run("u1");
405
+ const after = db
406
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
407
+ .get("u1");
408
+ expect(after?.n).toBe(0);
409
+ } finally {
410
+ db.close();
411
+ }
412
+ } finally {
413
+ h.cleanup();
414
+ }
415
+ });
416
+
417
+ test("v10 (user_id, vault_name) PRIMARY KEY blocks duplicate (user, vault) pairs", () => {
418
+ const h = makeHarness();
419
+ try {
420
+ const db = openHubDb(h.dbPath);
421
+ try {
422
+ const stamp = "2026-05-27T00:00:00.000Z";
423
+ db.prepare(
424
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at, password_changed) VALUES (?, ?, ?, ?, ?, ?)",
425
+ ).run("u1", "alice", "h", stamp, stamp, 1);
426
+ db.prepare(
427
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
428
+ ).run("u1", "personal", "write", stamp);
429
+ expect(() =>
430
+ db
431
+ .prepare(
432
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, ?, ?)",
433
+ )
434
+ .run("u1", "personal", "write", stamp),
435
+ ).toThrow();
271
436
  } finally {
272
437
  db.close();
273
438
  }
@@ -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");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for the `/notes/*` → `/app/notes/*` redirect helper (Notes-as-app
2
+ * Tests for the `/notes/*` → `/surface/notes/*` redirect helper (Notes-as-app
3
3
  * migration Phase 2, parachute-app design doc §16).
4
4
  *
5
5
  * Covers the path-match predicate, the target-URL builder, the DB-aware
@@ -52,30 +52,30 @@ describe("notes-redirect — isLegacyNotesPath", () => {
52
52
  });
53
53
 
54
54
  describe("notes-redirect — buildNotesRedirectTarget", () => {
55
- test("rewrites the bare path /notes → /app/notes", () => {
56
- expect(buildNotesRedirectTarget("/notes", "")).toBe("/app/notes");
55
+ test("rewrites the bare path /notes → /surface/notes", () => {
56
+ expect(buildNotesRedirectTarget("/notes", "")).toBe("/surface/notes");
57
57
  });
58
58
 
59
- test("rewrites the trailing-slash form /notes/ → /app/notes/", () => {
60
- expect(buildNotesRedirectTarget("/notes/", "")).toBe("/app/notes/");
59
+ test("rewrites the trailing-slash form /notes/ → /surface/notes/", () => {
60
+ expect(buildNotesRedirectTarget("/notes/", "")).toBe("/surface/notes/");
61
61
  });
62
62
 
63
- test("rewrites a sub-path /notes/sw.js → /app/notes/sw.js", () => {
64
- expect(buildNotesRedirectTarget("/notes/sw.js", "")).toBe("/app/notes/sw.js");
63
+ test("rewrites a sub-path /notes/sw.js → /surface/notes/sw.js", () => {
64
+ expect(buildNotesRedirectTarget("/notes/sw.js", "")).toBe("/surface/notes/sw.js");
65
65
  });
66
66
 
67
67
  test("preserves a single-param query string", () => {
68
- expect(buildNotesRedirectTarget("/notes/foo", "?q=1")).toBe("/app/notes/foo?q=1");
68
+ expect(buildNotesRedirectTarget("/notes/foo", "?q=1")).toBe("/surface/notes/foo?q=1");
69
69
  });
70
70
 
71
71
  test("preserves a multi-param query string verbatim (no re-encoding)", () => {
72
72
  expect(buildNotesRedirectTarget("/notes/foo", "?a=1&b=hello%20world")).toBe(
73
- "/app/notes/foo?a=1&b=hello%20world",
73
+ "/surface/notes/foo?a=1&b=hello%20world",
74
74
  );
75
75
  });
76
76
 
77
77
  test("preserves the bare /notes + query (no trailing slash on rewrite)", () => {
78
- expect(buildNotesRedirectTarget("/notes", "?next=foo")).toBe("/app/notes?next=foo");
78
+ expect(buildNotesRedirectTarget("/notes", "?next=foo")).toBe("/surface/notes?next=foo");
79
79
  });
80
80
  });
81
81
 
@@ -90,13 +90,13 @@ describe("notes-redirect — maybeRedirectNotes", () => {
90
90
  // Absent DB defaults to redirect-on — the migration-default direction.
91
91
  // Operators flipping the opt-out flag have a hub-with-DB; the default
92
92
  // doesn't depend on DB readiness.
93
- expect(maybeRedirectNotes("/notes/foo", "?q=1", undefined)).toBe("/app/notes/foo?q=1");
93
+ expect(maybeRedirectNotes("/notes/foo", "?q=1", undefined)).toBe("/surface/notes/foo?q=1");
94
94
  });
95
95
 
96
96
  test("returns the target URL when the path matches and the flag is absent (default)", () => {
97
97
  const db = openHubDb(hubDbPath(dir));
98
98
  try {
99
- expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/app/notes/foo");
99
+ expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/surface/notes/foo");
100
100
  } finally {
101
101
  db.close();
102
102
  }
@@ -109,7 +109,7 @@ describe("notes-redirect — maybeRedirectNotes", () => {
109
109
  try {
110
110
  setNotesRedirectDisabled(db, true);
111
111
  setNotesRedirectDisabled(db, false);
112
- expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/app/notes/foo");
112
+ expect(maybeRedirectNotes("/notes/foo", "", db)).toBe("/surface/notes/foo");
113
113
  } finally {
114
114
  db.close();
115
115
  }
@@ -144,18 +144,18 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
144
144
 
145
145
  test("logs once on the first hit", () => {
146
146
  const lines: string[] = [];
147
- logNotesRedirect("/notes/foo", "/app/notes/foo", {
147
+ logNotesRedirect("/notes/foo", "/surface/notes/foo", {
148
148
  now: () => 1_000_000,
149
149
  log: (m) => lines.push(m),
150
150
  });
151
- expect(lines).toEqual(["[notes-migration] redirect /notes/foo → /app/notes/foo"]);
151
+ expect(lines).toEqual(["[notes-migration] redirect /notes/foo → /surface/notes/foo"]);
152
152
  });
153
153
 
154
154
  test("throttles repeated hits to the same path within the window", () => {
155
155
  const lines: string[] = [];
156
156
  // Five hits within a 10-second span — well inside the 60-second window.
157
157
  for (let i = 0; i < 5; i++) {
158
- logNotesRedirect("/notes/foo", "/app/notes/foo", {
158
+ logNotesRedirect("/notes/foo", "/surface/notes/foo", {
159
159
  now: () => 1_000_000 + i * 2_000,
160
160
  log: (m) => lines.push(m),
161
161
  });
@@ -165,12 +165,12 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
165
165
 
166
166
  test("re-logs the same path after the window expires", () => {
167
167
  const lines: string[] = [];
168
- logNotesRedirect("/notes/foo", "/app/notes/foo", {
168
+ logNotesRedirect("/notes/foo", "/surface/notes/foo", {
169
169
  now: () => 1_000_000,
170
170
  log: (m) => lines.push(m),
171
171
  });
172
172
  // 60_001 ms later → window has rolled, log fires again.
173
- logNotesRedirect("/notes/foo", "/app/notes/foo", {
173
+ logNotesRedirect("/notes/foo", "/surface/notes/foo", {
174
174
  now: () => 1_000_000 + 60_001,
175
175
  log: (m) => lines.push(m),
176
176
  });
@@ -179,11 +179,11 @@ describe("notes-redirect — logNotesRedirect (throttled)", () => {
179
179
 
180
180
  test("logs distinct paths independently (per-path bucket)", () => {
181
181
  const lines: string[] = [];
182
- logNotesRedirect("/notes/foo", "/app/notes/foo", {
182
+ logNotesRedirect("/notes/foo", "/surface/notes/foo", {
183
183
  now: () => 1_000_000,
184
184
  log: (m) => lines.push(m),
185
185
  });
186
- logNotesRedirect("/notes/bar", "/app/notes/bar", {
186
+ logNotesRedirect("/notes/bar", "/surface/notes/bar", {
187
187
  now: () => 1_000_000,
188
188
  log: (m) => lines.push(m),
189
189
  });