@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
@@ -123,7 +123,7 @@ describe("setup", () => {
123
123
  { name: "parachute-scribe", port: 1943 },
124
124
  { name: "parachute-channel", port: 1941 },
125
125
  { name: "parachute-runner", port: 1945 },
126
- { name: "parachute-app", port: 1946 },
126
+ { name: "parachute-surface", port: 1946 },
127
127
  ];
128
128
  for (const s of seeds) {
129
129
  upsertService(
@@ -123,6 +123,45 @@ describe("status", () => {
123
123
  }
124
124
  });
125
125
 
126
+ test("http 401 counts as HEALTHY (auth-gated endpoint is responsive)", async () => {
127
+ // Vault's canonical health path `/vault/<name>/health` returns 401
128
+ // without an API key — that's the server replying "I'm up but you
129
+ // need auth," not "I'm down." `parachute status` used to roll 401
130
+ // into the failing bucket via `res.ok`, surfacing "failing" on every
131
+ // fresh install (vault was fine — the probe was just confused).
132
+ // Now: 401 specifically counts as healthy. Other 4xx (404, 400) stay
133
+ // unhealthy — those mean the configured health path is misshapen.
134
+ const { path, configDir, cleanup } = makeTempPath();
135
+ try {
136
+ upsertService(
137
+ {
138
+ name: "parachute-vault",
139
+ port: 1940,
140
+ paths: ["/"],
141
+ health: "/vault/default/health",
142
+ version: "0.2.4",
143
+ },
144
+ path,
145
+ );
146
+ writePid("vault", 4242, configDir);
147
+ const lines: string[] = [];
148
+ const code = await status({
149
+ manifestPath: path,
150
+ configDir,
151
+ alive: () => true,
152
+ fetchImpl: async () => new Response(null, { status: 401 }),
153
+ print: (l) => lines.push(l),
154
+ });
155
+ expect(code).toBe(0);
156
+ expect(lines.some((l) => /\bactive\b/.test(l))).toBe(true);
157
+ // No "failing" rollup, no `! probe: http 401` continuation line.
158
+ expect(lines.some((l) => /\bfailing\b/.test(l))).toBe(false);
159
+ expect(lines.some((l) => l.includes("probe: http 401"))).toBe(false);
160
+ } finally {
161
+ cleanup();
162
+ }
163
+ });
164
+
126
165
  test("running + healthy probe shows STATE=active, pid + uptime", async () => {
127
166
  const { path, configDir, cleanup } = makeTempPath();
128
167
  try {
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
6
7
  import {
7
8
  PASSWORD_MIN_LEN,
8
9
  SingleUserModeError,
@@ -11,11 +12,15 @@ import {
11
12
  UsernameTakenError,
12
13
  createUser,
13
14
  deleteUser,
15
+ getFirstAdminId,
14
16
  getUserById,
15
17
  getUserByUsername,
16
18
  getUserByUsernameCI,
19
+ isFirstAdmin,
17
20
  listUsers,
21
+ resetUserPassword,
18
22
  setPassword,
23
+ setUserVaults,
19
24
  userCount,
20
25
  validatePassword,
21
26
  validateUsername,
@@ -52,7 +57,7 @@ describe("createUser", () => {
52
57
  expect(userCount(db)).toBe(1);
53
58
  // Default multi-user-Phase-1 shape: unchanged password, no vault pin.
54
59
  expect(u.passwordChanged).toBe(false);
55
- expect(u.assignedVault).toBeNull();
60
+ expect(u.assignedVaults).toEqual([]);
56
61
  } finally {
57
62
  cleanup();
58
63
  }
@@ -72,28 +77,63 @@ describe("createUser", () => {
72
77
  }
73
78
  });
74
79
 
75
- test("assignedVault opt-in persists the column (admin-creates-user path)", async () => {
80
+ test("assignedVaults opt-in persists each vault (admin-creates-user path)", async () => {
76
81
  const { db, cleanup } = makeDb();
77
82
  try {
78
83
  const u = await createUser(db, "alice", "pw1", {
79
84
  allowMulti: true,
80
- assignedVault: "alice",
85
+ assignedVaults: ["alice"],
81
86
  });
82
- expect(u.assignedVault).toBe("alice");
87
+ expect(u.assignedVaults).toEqual(["alice"]);
83
88
  const fresh = getUserById(db, u.id);
84
- expect(fresh?.assignedVault).toBe("alice");
89
+ expect(fresh?.assignedVaults).toEqual(["alice"]);
85
90
  } finally {
86
91
  cleanup();
87
92
  }
88
93
  });
89
94
 
90
- test("assignedVault explicit null is treated the same as omitted", async () => {
95
+ test("assignedVaults with multiple entries all persist (multi-vault Phase 2)", async () => {
91
96
  const { db, cleanup } = makeDb();
92
97
  try {
93
- const u = await createUser(db, "admin1", "pw1", { assignedVault: null });
94
- expect(u.assignedVault).toBeNull();
98
+ const u = await createUser(db, "alice", "pw1", {
99
+ allowMulti: true,
100
+ assignedVaults: ["personal", "family"],
101
+ });
102
+ expect(u.assignedVaults).toEqual(["personal", "family"]);
103
+ const fresh = getUserById(db, u.id);
104
+ // Loaded via the user_vaults JOIN — order matches insertion order
105
+ // because rows share the same `created_at` timestamp and tie-break
106
+ // on `vault_name` ASC. `family` precedes `personal` alphabetically.
107
+ expect(fresh?.assignedVaults.length).toBe(2);
108
+ expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["personal", "family"]));
109
+ } finally {
110
+ cleanup();
111
+ }
112
+ });
113
+
114
+ test("assignedVaults omitted defaults to empty array (admin posture)", async () => {
115
+ const { db, cleanup } = makeDb();
116
+ try {
117
+ const u = await createUser(db, "admin1", "pw1");
118
+ expect(u.assignedVaults).toEqual([]);
95
119
  const fresh = getUserById(db, u.id);
96
- expect(fresh?.assignedVault).toBeNull();
120
+ expect(fresh?.assignedVaults).toEqual([]);
121
+ } finally {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ test("assignedVaults de-duplicates repeated names", async () => {
127
+ const { db, cleanup } = makeDb();
128
+ try {
129
+ const u = await createUser(db, "alice", "pw1", {
130
+ allowMulti: true,
131
+ assignedVaults: ["personal", "personal", "family"],
132
+ });
133
+ // De-dupe is silent; user gets one row per distinct name.
134
+ expect(u.assignedVaults).toEqual(["personal", "family"]);
135
+ const fresh = getUserById(db, u.id);
136
+ expect(fresh?.assignedVaults.length).toBe(2);
97
137
  } finally {
98
138
  cleanup();
99
139
  }
@@ -348,3 +388,350 @@ describe("validatePassword", () => {
348
388
  expect(validatePassword("aaaaaaaaaaaa").valid).toBe(true);
349
389
  });
350
390
  });
391
+
392
+ describe("resetUserPassword", () => {
393
+ test("returns false when user does not exist", async () => {
394
+ const { db, cleanup } = makeDb();
395
+ try {
396
+ expect(await resetUserPassword(db, "no-such-id", "twelvechars1")).toBe(false);
397
+ } finally {
398
+ cleanup();
399
+ }
400
+ });
401
+
402
+ test("returns false when user vanishes between pre-check and tx body", async () => {
403
+ // Reviewer-flagged race path (hub#427). The argon2 hash is computed
404
+ // outside the transaction (async), giving a window where a concurrent
405
+ // delete can land between the existence pre-check and the UPDATE tx.
406
+ // The helper must return false in that case so the caller can 404
407
+ // instead of cosmetically claiming success.
408
+ const { db, cleanup } = makeDb();
409
+ try {
410
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
411
+ passwordChanged: true,
412
+ });
413
+ // Simulate the race: delete the row, then invoke the reset. The
414
+ // pre-check runs in `resetUserPassword` against the now-empty table.
415
+ // (We can't intercept between pre-check and tx without forking the
416
+ // helper; deleting before the call is the equivalent post-condition
417
+ // — if the row is gone the tx body will UPDATE 0 rows.)
418
+ db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
419
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(false);
420
+ } finally {
421
+ cleanup();
422
+ }
423
+ });
424
+
425
+ test("rotates hash, flips password_changed back to 0, bumps updated_at", async () => {
426
+ const { db, cleanup } = makeDb();
427
+ try {
428
+ // Seed user as "already changed their password" (true) to prove the
429
+ // reset flips it back to false for the force-redirect rail.
430
+ const initial = await createUser(db, "alice", "alice-strong-passphrase", {
431
+ passwordChanged: true,
432
+ now: () => new Date(1000),
433
+ });
434
+ const oldHash = initial.passwordHash;
435
+ const oldUpdated = initial.updatedAt;
436
+ const later = new Date(2000);
437
+ expect(await resetUserPassword(db, initial.id, "new-temp-passphrase", () => later)).toBe(
438
+ true,
439
+ );
440
+ const fresh = getUserById(db, initial.id);
441
+ expect(fresh).not.toBeNull();
442
+ expect(fresh?.passwordHash).not.toBe(oldHash);
443
+ expect(fresh?.passwordChanged).toBe(false);
444
+ expect(fresh?.updatedAt).not.toBe(oldUpdated);
445
+ // Round-trip verify: old password no longer works, new one does.
446
+ expect(await verifyPassword(fresh!, "alice-strong-passphrase")).toBe(false);
447
+ expect(await verifyPassword(fresh!, "new-temp-passphrase")).toBe(true);
448
+ } finally {
449
+ cleanup();
450
+ }
451
+ });
452
+
453
+ test("revokes still-active tokens belonging to the user", async () => {
454
+ const { db, cleanup } = makeDb();
455
+ try {
456
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
457
+ passwordChanged: true,
458
+ });
459
+ const minted = await signAccessToken(db, {
460
+ sub: user.id,
461
+ scopes: ["vault:home:read"],
462
+ audience: "vault",
463
+ clientId: "notes-client",
464
+ issuer: "https://hub.test",
465
+ ttlSeconds: 600,
466
+ });
467
+ recordTokenMint(db, {
468
+ jti: minted.jti,
469
+ createdVia: "operator_mint",
470
+ subject: user.username,
471
+ userId: user.id,
472
+ clientId: "notes-client",
473
+ scopes: ["vault:home:read"],
474
+ expiresAt: minted.expiresAt,
475
+ });
476
+ // Pre-state: token row not yet revoked.
477
+ const before = db
478
+ .query<{ revoked_at: string | null }, [string]>(
479
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
480
+ )
481
+ .get(minted.jti);
482
+ expect(before?.revoked_at).toBeNull();
483
+
484
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
485
+
486
+ // Post-state: token row has revoked_at set, user_id retained (the
487
+ // user row sticks around, audit trail re-anchors naturally).
488
+ const after = db
489
+ .query<{ revoked_at: string | null; user_id: string | null }, [string]>(
490
+ "SELECT revoked_at, user_id FROM tokens WHERE jti = ?",
491
+ )
492
+ .get(minted.jti);
493
+ expect(after?.revoked_at).not.toBeNull();
494
+ expect(after?.user_id).toBe(user.id);
495
+ } finally {
496
+ cleanup();
497
+ }
498
+ });
499
+
500
+ test("does not re-revoke an already-revoked token", async () => {
501
+ // Defense-in-depth: a previously-revoked token shouldn't have its
502
+ // revoked_at timestamp overwritten by a fresh reset. The UPDATE's
503
+ // WHERE clause filters on `revoked_at IS NULL` so this is naturally
504
+ // enforced; pinning it here so a future refactor that drops the
505
+ // filter trips the test.
506
+ const { db, cleanup } = makeDb();
507
+ try {
508
+ const user = await createUser(db, "alice", "alice-strong-passphrase", {
509
+ passwordChanged: true,
510
+ });
511
+ const minted = await signAccessToken(db, {
512
+ sub: user.id,
513
+ scopes: ["vault:home:read"],
514
+ audience: "vault",
515
+ clientId: "notes-client",
516
+ issuer: "https://hub.test",
517
+ ttlSeconds: 600,
518
+ });
519
+ const earlierStamp = "2026-01-01T00:00:00.000Z";
520
+ recordTokenMint(db, {
521
+ jti: minted.jti,
522
+ createdVia: "operator_mint",
523
+ subject: user.username,
524
+ userId: user.id,
525
+ clientId: "notes-client",
526
+ scopes: ["vault:home:read"],
527
+ expiresAt: minted.expiresAt,
528
+ });
529
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(earlierStamp, minted.jti);
530
+
531
+ expect(await resetUserPassword(db, user.id, "new-temp-passphrase")).toBe(true);
532
+
533
+ const row = db
534
+ .query<{ revoked_at: string | null }, [string]>(
535
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
536
+ )
537
+ .get(minted.jti);
538
+ expect(row?.revoked_at).toBe(earlierStamp);
539
+ } finally {
540
+ cleanup();
541
+ }
542
+ });
543
+
544
+ test("leaves tokens for other users untouched", async () => {
545
+ const { db, cleanup } = makeDb();
546
+ try {
547
+ const alice = await createUser(db, "alice", "alice-strong-passphrase", {
548
+ passwordChanged: true,
549
+ });
550
+ const bob = await createUser(db, "bob", "bob-strong-passphrase", {
551
+ allowMulti: true,
552
+ passwordChanged: true,
553
+ });
554
+ const bobToken = await signAccessToken(db, {
555
+ sub: bob.id,
556
+ scopes: ["vault:home:read"],
557
+ audience: "vault",
558
+ clientId: "notes-client",
559
+ issuer: "https://hub.test",
560
+ ttlSeconds: 600,
561
+ });
562
+ recordTokenMint(db, {
563
+ jti: bobToken.jti,
564
+ createdVia: "operator_mint",
565
+ subject: bob.username,
566
+ userId: bob.id,
567
+ clientId: "notes-client",
568
+ scopes: ["vault:home:read"],
569
+ expiresAt: bobToken.expiresAt,
570
+ });
571
+
572
+ await resetUserPassword(db, alice.id, "new-temp-passphrase");
573
+
574
+ const bobRow = db
575
+ .query<{ revoked_at: string | null }, [string]>(
576
+ "SELECT revoked_at FROM tokens WHERE jti = ?",
577
+ )
578
+ .get(bobToken.jti);
579
+ expect(bobRow?.revoked_at).toBeNull();
580
+ } finally {
581
+ cleanup();
582
+ }
583
+ });
584
+ });
585
+
586
+ describe("setUserVaults (multi-user Phase 2 PR 2)", () => {
587
+ test("returns false when user does not exist", () => {
588
+ const { db, cleanup } = makeDb();
589
+ try {
590
+ expect(setUserVaults(db, "no-such-id", ["a"])).toBe(false);
591
+ } finally {
592
+ cleanup();
593
+ }
594
+ });
595
+
596
+ test("replaces a user's vault list atomically", async () => {
597
+ const { db, cleanup } = makeDb();
598
+ try {
599
+ const u = await createUser(db, "alice", "alice-strong-passphrase", {
600
+ assignedVaults: ["personal"],
601
+ });
602
+ expect(setUserVaults(db, u.id, ["family", "work"])).toBe(true);
603
+ const fresh = getUserById(db, u.id);
604
+ // Old vault dropped; new ones present.
605
+ expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["family", "work"]));
606
+ expect(fresh?.assignedVaults).not.toContain("personal");
607
+ } finally {
608
+ cleanup();
609
+ }
610
+ });
611
+
612
+ test("empty array clears every existing assignment (non-admin = no access)", async () => {
613
+ const { db, cleanup } = makeDb();
614
+ try {
615
+ const u = await createUser(db, "alice", "alice-strong-passphrase", {
616
+ assignedVaults: ["a", "b", "c"],
617
+ });
618
+ expect(setUserVaults(db, u.id, [])).toBe(true);
619
+ const fresh = getUserById(db, u.id);
620
+ expect(fresh?.assignedVaults).toEqual([]);
621
+ } finally {
622
+ cleanup();
623
+ }
624
+ });
625
+
626
+ test("de-duplicates repeated names without throwing", async () => {
627
+ const { db, cleanup } = makeDb();
628
+ try {
629
+ const u = await createUser(db, "alice", "alice-strong-passphrase");
630
+ expect(setUserVaults(db, u.id, ["a", "a", "b"])).toBe(true);
631
+ const fresh = getUserById(db, u.id);
632
+ expect(new Set(fresh?.assignedVaults)).toEqual(new Set(["a", "b"]));
633
+ expect(fresh?.assignedVaults.length).toBe(2);
634
+ } finally {
635
+ cleanup();
636
+ }
637
+ });
638
+
639
+ test("bumps updated_at so the SPA row reflects the change", async () => {
640
+ const { db, cleanup } = makeDb();
641
+ try {
642
+ const u = await createUser(db, "alice", "alice-strong-passphrase", {
643
+ now: () => new Date(1000),
644
+ });
645
+ const before = getUserById(db, u.id);
646
+ expect(setUserVaults(db, u.id, ["a"], () => new Date(2000))).toBe(true);
647
+ const after = getUserById(db, u.id);
648
+ expect(after?.updatedAt).not.toBe(before?.updatedAt);
649
+ } finally {
650
+ cleanup();
651
+ }
652
+ });
653
+
654
+ test("vault assignments cascade-delete with the user row", async () => {
655
+ const { db, cleanup } = makeDb();
656
+ try {
657
+ const u = await createUser(db, "alice", "alice-strong-passphrase", {
658
+ assignedVaults: ["a", "b"],
659
+ });
660
+ // Sanity: rows exist in user_vaults.
661
+ const before = db
662
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
663
+ .get(u.id);
664
+ expect(before?.n).toBe(2);
665
+ deleteUser(db, u.id);
666
+ const after = db
667
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM user_vaults WHERE user_id = ?")
668
+ .get(u.id);
669
+ expect(after?.n).toBe(0);
670
+ } finally {
671
+ cleanup();
672
+ }
673
+ });
674
+ });
675
+
676
+ describe("getFirstAdminId / isFirstAdmin", () => {
677
+ test("getFirstAdminId returns null on an empty users table", () => {
678
+ const { db, cleanup } = makeDb();
679
+ try {
680
+ expect(getFirstAdminId(db)).toBeNull();
681
+ } finally {
682
+ cleanup();
683
+ }
684
+ });
685
+
686
+ test("getFirstAdminId returns the earliest-created user id", async () => {
687
+ const { db, cleanup } = makeDb();
688
+ try {
689
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
690
+ await createUser(db, "alice", "pw2", {
691
+ allowMulti: true,
692
+ now: () => new Date(2000),
693
+ });
694
+ await createUser(db, "bob", "pw3", {
695
+ allowMulti: true,
696
+ now: () => new Date(3000),
697
+ });
698
+ expect(getFirstAdminId(db)).toBe(admin.id);
699
+ } finally {
700
+ cleanup();
701
+ }
702
+ });
703
+
704
+ test("isFirstAdmin matches earliest user, false for everyone else", async () => {
705
+ const { db, cleanup } = makeDb();
706
+ try {
707
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
708
+ const friend = await createUser(db, "alice", "pw2", {
709
+ allowMulti: true,
710
+ now: () => new Date(2000),
711
+ });
712
+ expect(isFirstAdmin(db, admin.id)).toBe(true);
713
+ expect(isFirstAdmin(db, friend.id)).toBe(false);
714
+ expect(isFirstAdmin(db, "no-such-id")).toBe(false);
715
+ } finally {
716
+ cleanup();
717
+ }
718
+ });
719
+
720
+ test("isFirstAdmin tracks the admin even after a later user is deleted", async () => {
721
+ // Deleting a non-first user doesn't promote anyone — the original
722
+ // admin still holds the "first" slot.
723
+ const { db, cleanup } = makeDb();
724
+ try {
725
+ const admin = await createUser(db, "admin", "pw1", { now: () => new Date(1000) });
726
+ const friend = await createUser(db, "alice", "pw2", {
727
+ allowMulti: true,
728
+ now: () => new Date(2000),
729
+ });
730
+ deleteUser(db, friend.id);
731
+ expect(getFirstAdminId(db)).toBe(admin.id);
732
+ expect(isFirstAdmin(db, admin.id)).toBe(true);
733
+ } finally {
734
+ cleanup();
735
+ }
736
+ });
737
+ });
@@ -426,10 +426,10 @@ describe("buildWellKnown", () => {
426
426
  // joined onto the canonical origin into a deep-linkable `url`.
427
427
  describe("uis hierarchical sub-units (hub#313)", () => {
428
428
  const app: ServiceEntry = {
429
- name: "parachute-app",
429
+ name: "parachute-surface",
430
430
  port: 1946,
431
- paths: ["/app"],
432
- health: "/app/healthz",
431
+ paths: ["/surface"],
432
+ health: "/surface/healthz",
433
433
  version: "0.1.0",
434
434
  };
435
435
 
@@ -455,7 +455,7 @@ describe("buildWellKnown", () => {
455
455
  services: [withUis],
456
456
  canonicalOrigin: "https://x.example",
457
457
  });
458
- const appSvc = doc.services.find((s) => s.name === "parachute-app");
458
+ const appSvc = doc.services.find((s) => s.name === "parachute-surface");
459
459
  expect(appSvc?.uis).toEqual([
460
460
  {
461
461
  name: "gitcoin-brain",
@@ -500,7 +500,7 @@ describe("buildWellKnown", () => {
500
500
  services: [empty],
501
501
  canonicalOrigin: "https://x.example",
502
502
  });
503
- const svc = doc.services.find((s) => s.name === "parachute-app");
503
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
504
504
  expect(svc).not.toHaveProperty("uis");
505
505
  });
506
506
 
@@ -519,7 +519,7 @@ describe("buildWellKnown", () => {
519
519
  services: [withIcon],
520
520
  canonicalOrigin: "https://x.example",
521
521
  });
522
- const svc = doc.services.find((s) => s.name === "parachute-app");
522
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
523
523
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://x.example/app/slug/icon.svg");
524
524
  });
525
525
 
@@ -538,7 +538,7 @@ describe("buildWellKnown", () => {
538
538
  services: [withIcon],
539
539
  canonicalOrigin: "https://x.example",
540
540
  });
541
- const svc = doc.services.find((s) => s.name === "parachute-app");
541
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
542
542
  expect(svc?.uis?.[0]?.iconUrl).toBe("https://cdn.example.com/icon.svg");
543
543
  });
544
544
 
@@ -562,7 +562,7 @@ describe("buildWellKnown", () => {
562
562
  services: [mixed],
563
563
  canonicalOrigin: "https://x.example",
564
564
  });
565
- const svc = doc.services.find((s) => s.name === "parachute-app");
565
+ const svc = doc.services.find((s) => s.name === "parachute-surface");
566
566
  const full = svc?.uis?.find((u) => u.name === "full");
567
567
  const minimal = svc?.uis?.find((u) => u.name === "minimal");
568
568
  expect(full?.tagline).toBe("Has it all");
@@ -593,7 +593,7 @@ describe("buildWellKnown", () => {
593
593
  services: [app1, app2],
594
594
  canonicalOrigin: "https://x.example",
595
595
  });
596
- const svc1 = doc.services.find((s) => s.name === "parachute-app");
596
+ const svc1 = doc.services.find((s) => s.name === "parachute-surface");
597
597
  const svc2 = doc.services.find((s) => s.name === "parachute-app-2");
598
598
  expect(svc1?.uis?.map((u) => u.name)).toEqual(["a"]);
599
599
  expect(svc2?.uis?.map((u) => u.name)).toEqual(["b"]);