@openparachute/hub 0.3.0-rc.1 → 0.5.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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -143,6 +143,10 @@ describe("expose tailnet up", () => {
143
143
  expect(calls.every((c) => c[1] !== "funnel")).toBe(true);
144
144
 
145
145
  const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
146
+ // Vault paths consolidate to a single `/vault/` mount → hub (#144);
147
+ // the hub then picks the specific vault instance per request from
148
+ // services.json. Notes (and other non-vault services) keep their
149
+ // direct mount.
146
150
  expect(mounts).toEqual([
147
151
  "--set-path=/",
148
152
  "--set-path=/.well-known/oauth-authorization-server",
@@ -151,7 +155,7 @@ describe("expose tailnet up", () => {
151
155
  "--set-path=/oauth/authorize",
152
156
  "--set-path=/oauth/register",
153
157
  "--set-path=/oauth/token",
154
- "--set-path=/vault/default",
158
+ "--set-path=/vault/",
155
159
  ]);
156
160
 
157
161
  // Hub + well-known now point at localhost HTTP, not a file path.
@@ -165,12 +169,14 @@ describe("expose tailnet up", () => {
165
169
  /^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/parachute\.json$/,
166
170
  );
167
171
 
168
- // Service targets also include their mount path to prevent tailscale
169
- // from stripping the prefix before forwarding to a base-aware backend.
172
+ // Non-vault service targets include the mount path so tailscale's
173
+ // strip-then-forward is a no-op against base-aware backends.
170
174
  const notesCall = serveCalls.find((c) => c.includes("--set-path=/notes"));
171
175
  expect(notesCall?.[notesCall.length - 1]).toBe("http://127.0.0.1:5173/notes");
172
- const vaultCall = serveCalls.find((c) => c.includes("--set-path=/vault/default"));
173
- expect(vaultCall?.[vaultCall.length - 1]).toBe("http://127.0.0.1:1940/vault/default");
176
+ // Vault mount targets the hub's loopback port (the hub re-proxies to
177
+ // the right vault backend on each request) — port is dynamic per test.
178
+ const vaultCall = serveCalls.find((c) => c.includes("--set-path=/vault/"));
179
+ expect(vaultCall?.[vaultCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/vault\/$/);
174
180
 
175
181
  expect(existsSync(h.wellKnownPath)).toBe(true);
176
182
  expect(existsSync(h.hubPath)).toBe(true);
@@ -288,7 +294,10 @@ describe("expose tailnet up", () => {
288
294
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
289
295
  );
290
296
  const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
291
- expect(mounts).toContain("--set-path=/vault");
297
+ // Even with the legacy `/` remap, the vault rolls into the consolidated
298
+ // `/vault/` mount → hub. The remap warning still fires; the mount shape
299
+ // just doesn't reflect the original `/<shortname>` since #144.
300
+ expect(mounts).toContain("--set-path=/vault/");
292
301
  expect(mounts).toContain("--set-path=/");
293
302
  expect(mounts.filter((m) => m === "--set-path=/")).toHaveLength(1);
294
303
 
@@ -475,7 +484,7 @@ describe("expose tailnet up", () => {
475
484
  }
476
485
  });
477
486
 
478
- test("emits 4 OAuth proxies targeting vault when vault is installed", async () => {
487
+ test("emits 4 OAuth proxies targeting the hub origin (hub IS the IdP)", async () => {
479
488
  const h = makeHarness();
480
489
  try {
481
490
  seedServices(h.manifestPath);
@@ -508,17 +517,15 @@ describe("expose tailnet up", () => {
508
517
  oauthTargets.set(mount, c[c.length - 1] ?? "");
509
518
  }
510
519
  }
511
- expect(oauthTargets.get("/.well-known/oauth-authorization-server")).toBe(
512
- "http://127.0.0.1:1940/vault/default/.well-known/oauth-authorization-server",
520
+ expect(oauthTargets.get("/.well-known/oauth-authorization-server")).toMatch(
521
+ /^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/oauth-authorization-server$/,
513
522
  );
514
- expect(oauthTargets.get("/oauth/authorize")).toBe(
515
- "http://127.0.0.1:1940/vault/default/oauth/authorize",
523
+ expect(oauthTargets.get("/oauth/authorize")).toMatch(
524
+ /^http:\/\/127\.0\.0\.1:\d+\/oauth\/authorize$/,
516
525
  );
517
- expect(oauthTargets.get("/oauth/token")).toBe(
518
- "http://127.0.0.1:1940/vault/default/oauth/token",
519
- );
520
- expect(oauthTargets.get("/oauth/register")).toBe(
521
- "http://127.0.0.1:1940/vault/default/oauth/register",
526
+ expect(oauthTargets.get("/oauth/token")).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/token$/);
527
+ expect(oauthTargets.get("/oauth/register")).toMatch(
528
+ /^http:\/\/127\.0\.0\.1:\d+\/oauth\/register$/,
522
529
  );
523
530
 
524
531
  const state = readExposeState(h.statePath);
@@ -528,7 +535,7 @@ describe("expose tailnet up", () => {
528
535
  }
529
536
  });
530
537
 
531
- test("skips OAuth proxies when no vault is installed", async () => {
538
+ test("emits OAuth proxies even when no vault is installed (hub IS the IdP)", async () => {
532
539
  const h = makeHarness();
533
540
  try {
534
541
  upsertService(
@@ -560,10 +567,15 @@ describe("expose tailnet up", () => {
560
567
  const serveCalls = calls.filter(
561
568
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
562
569
  );
563
- // No vault → no OAuth proxies. Hub + well-known + notes = 3.
564
- expect(serveCalls).toHaveLength(3);
565
- const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
566
- expect(mounts.every((m) => m !== undefined && !m.includes("/oauth/"))).toBe(true);
570
+ // Hub + well-known + notes + 4 OAuth proxies = 7. The hub serves OAuth
571
+ // regardless of which services are installed.
572
+ expect(serveCalls).toHaveLength(7);
573
+ const oauthMounts = serveCalls
574
+ .map((c) => c.find((a) => a.startsWith("--set-path=")))
575
+ .filter(
576
+ (m): m is string => !!m && (m.includes("/oauth/") || m.endsWith("authorization-server")),
577
+ );
578
+ expect(oauthMounts).toHaveLength(4);
567
579
  } finally {
568
580
  h.cleanup();
569
581
  }
@@ -1063,8 +1075,10 @@ describe("expose publicExposure filter", () => {
1063
1075
  (c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
1064
1076
  );
1065
1077
  const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
1066
- // Vault + its 4 OAuth proxies + hub + well-known — but no /scribe.
1067
- expect(mounts).toContain("--set-path=/vault/default");
1078
+ // Vault rolls into the consolidated `/vault/` mount hub (#144);
1079
+ // its 4 OAuth proxies, hub, and well-known are still individually
1080
+ // mounted. /scribe is loopback-only and absent.
1081
+ expect(mounts).toContain("--set-path=/vault/");
1068
1082
  expect(mounts).not.toContain("--set-path=/scribe");
1069
1083
 
1070
1084
  // Operator-visible notice explaining the withhold.
@@ -1207,7 +1221,8 @@ describe("expose publicExposure filter", () => {
1207
1221
  const mounts = calls
1208
1222
  .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1209
1223
  .map((c) => c.find((a) => a.startsWith("--set-path=")));
1210
- expect(mounts).toContain("--set-path=/vault/default");
1224
+ // Vault → consolidated `/vault/` mount (#144).
1225
+ expect(mounts).toContain("--set-path=/vault/");
1211
1226
  } finally {
1212
1227
  h.cleanup();
1213
1228
  }
@@ -1579,3 +1594,180 @@ describe("expose teardown tolerance for already-gone entries", () => {
1579
1594
  }
1580
1595
  });
1581
1596
  });
1597
+
1598
+ describe("expose vault mount consolidation (#144)", () => {
1599
+ // Pre-#144: tailscale plan emitted one mount per vault path. New vault →
1600
+ // had to re-run `parachute expose` to get a tailnet route. Post-#144: a
1601
+ // single `/vault/` → hub mount, hub does the per-request lookup.
1602
+
1603
+ test("single vault, single path → exactly one /vault/ mount, no per-instance entry", async () => {
1604
+ const h = makeHarness();
1605
+ try {
1606
+ upsertService(
1607
+ {
1608
+ name: "parachute-vault",
1609
+ port: 1940,
1610
+ paths: ["/vault/default"],
1611
+ health: "/vault/default/health",
1612
+ version: "0.4.0",
1613
+ },
1614
+ h.manifestPath,
1615
+ );
1616
+ const { runner, calls } = makeRunner();
1617
+ const { spawner } = makeHubSpawner(1111);
1618
+ const code = await exposeTailnet("up", {
1619
+ runner,
1620
+ manifestPath: h.manifestPath,
1621
+ statePath: h.statePath,
1622
+ wellKnownPath: h.wellKnownPath,
1623
+ hubPath: h.hubPath,
1624
+ wellKnownDir: h.wellKnownDir,
1625
+ configDir: h.configDir,
1626
+ hubEnsureOpts: hubEnsureOpts(spawner),
1627
+ servicePortProbe: allServicesUp,
1628
+ log: () => {},
1629
+ });
1630
+ expect(code).toBe(0);
1631
+ const mounts = calls
1632
+ .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1633
+ .map((c) => c.find((a) => a.startsWith("--set-path=")));
1634
+ expect(mounts).toContain("--set-path=/vault/");
1635
+ expect(mounts).not.toContain("--set-path=/vault/default");
1636
+ } finally {
1637
+ h.cleanup();
1638
+ }
1639
+ });
1640
+
1641
+ test("multi-path single ServiceEntry still emits exactly one /vault/ mount", async () => {
1642
+ // The current vault manifest shape: one ServiceEntry whose `paths` lists
1643
+ // every instance. The plan must collapse them all to the hub-routed
1644
+ // `/vault/` instead of one mount per instance.
1645
+ const h = makeHarness();
1646
+ try {
1647
+ upsertService(
1648
+ {
1649
+ name: "parachute-vault",
1650
+ port: 1940,
1651
+ paths: ["/vault/default", "/vault/techne"],
1652
+ health: "/vault/default/health",
1653
+ version: "0.4.0",
1654
+ },
1655
+ h.manifestPath,
1656
+ );
1657
+ const { runner, calls } = makeRunner();
1658
+ const { spawner } = makeHubSpawner(1111);
1659
+ const code = await exposeTailnet("up", {
1660
+ runner,
1661
+ manifestPath: h.manifestPath,
1662
+ statePath: h.statePath,
1663
+ wellKnownPath: h.wellKnownPath,
1664
+ hubPath: h.hubPath,
1665
+ wellKnownDir: h.wellKnownDir,
1666
+ configDir: h.configDir,
1667
+ hubEnsureOpts: hubEnsureOpts(spawner),
1668
+ servicePortProbe: allServicesUp,
1669
+ log: () => {},
1670
+ });
1671
+ expect(code).toBe(0);
1672
+ const mounts = calls
1673
+ .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1674
+ .map((c) => c.find((a) => a.startsWith("--set-path=")));
1675
+ const vaultMounts = mounts.filter((m) => m?.startsWith("--set-path=/vault"));
1676
+ expect(vaultMounts).toEqual(["--set-path=/vault/"]);
1677
+ } finally {
1678
+ h.cleanup();
1679
+ }
1680
+ });
1681
+
1682
+ test("multiple separate vault ServiceEntries still emit exactly one /vault/ mount", async () => {
1683
+ // Pathological but representable: someone might install a second
1684
+ // parachute-vault-archive backend alongside the bare parachute-vault.
1685
+ // Both fold into the single `/vault/` mount; hub disambiguates per
1686
+ // request.
1687
+ const h = makeHarness();
1688
+ try {
1689
+ upsertService(
1690
+ {
1691
+ name: "parachute-vault",
1692
+ port: 1940,
1693
+ paths: ["/vault/default"],
1694
+ health: "/vault/default/health",
1695
+ version: "0.4.0",
1696
+ },
1697
+ h.manifestPath,
1698
+ );
1699
+ upsertService(
1700
+ {
1701
+ name: "parachute-vault-archive",
1702
+ port: 1941,
1703
+ paths: ["/vault/archive"],
1704
+ health: "/vault/archive/health",
1705
+ version: "0.4.0",
1706
+ },
1707
+ h.manifestPath,
1708
+ );
1709
+ const { runner, calls } = makeRunner();
1710
+ const { spawner } = makeHubSpawner(1111);
1711
+ const code = await exposeTailnet("up", {
1712
+ runner,
1713
+ manifestPath: h.manifestPath,
1714
+ statePath: h.statePath,
1715
+ wellKnownPath: h.wellKnownPath,
1716
+ hubPath: h.hubPath,
1717
+ wellKnownDir: h.wellKnownDir,
1718
+ configDir: h.configDir,
1719
+ hubEnsureOpts: hubEnsureOpts(spawner),
1720
+ servicePortProbe: allServicesUp,
1721
+ log: () => {},
1722
+ });
1723
+ expect(code).toBe(0);
1724
+ const mounts = calls
1725
+ .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1726
+ .map((c) => c.find((a) => a.startsWith("--set-path=")));
1727
+ const vaultMounts = mounts.filter((m) => m?.startsWith("--set-path=/vault"));
1728
+ expect(vaultMounts).toEqual(["--set-path=/vault/"]);
1729
+ } finally {
1730
+ h.cleanup();
1731
+ }
1732
+ });
1733
+
1734
+ test("no vault installed → no /vault/ mount in the plan", async () => {
1735
+ const h = makeHarness();
1736
+ try {
1737
+ upsertService(
1738
+ {
1739
+ name: "parachute-notes",
1740
+ port: 5173,
1741
+ paths: ["/notes"],
1742
+ health: "/notes/health",
1743
+ version: "0.0.1",
1744
+ },
1745
+ h.manifestPath,
1746
+ );
1747
+ const { runner, calls } = makeRunner();
1748
+ const { spawner } = makeHubSpawner(1111);
1749
+ const code = await exposeTailnet("up", {
1750
+ runner,
1751
+ manifestPath: h.manifestPath,
1752
+ statePath: h.statePath,
1753
+ wellKnownPath: h.wellKnownPath,
1754
+ hubPath: h.hubPath,
1755
+ wellKnownDir: h.wellKnownDir,
1756
+ configDir: h.configDir,
1757
+ hubEnsureOpts: hubEnsureOpts(spawner),
1758
+ servicePortProbe: allServicesUp,
1759
+ log: () => {},
1760
+ });
1761
+ expect(code).toBe(0);
1762
+ const mounts = calls
1763
+ .filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
1764
+ .map((c) => c.find((a) => a.startsWith("--set-path=")));
1765
+ expect(mounts).not.toContain("--set-path=/vault/");
1766
+ // /notes is still individually mounted — non-vault services keep
1767
+ // their direct route.
1768
+ expect(mounts).toContain("--set-path=/notes");
1769
+ } finally {
1770
+ h.cleanup();
1771
+ }
1772
+ });
1773
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { registerClient } from "../clients.ts";
6
+ import {
7
+ findGrant,
8
+ isCoveredByGrant,
9
+ listGrantsForUser,
10
+ recordGrant,
11
+ revokeGrant,
12
+ } from "../grants.ts";
13
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
14
+ import { createUser } from "../users.ts";
15
+
16
+ async function harness() {
17
+ const dir = mkdtempSync(join(tmpdir(), "phub-grants-"));
18
+ const db = openHubDb(hubDbPath(dir));
19
+ const user = await createUser(db, "owner", "pw");
20
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
21
+ return {
22
+ db,
23
+ userId: user.id,
24
+ clientId: reg.client.clientId,
25
+ cleanup: () => {
26
+ db.close();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("grants module (#75)", () => {
33
+ test("findGrant returns null when no row exists", async () => {
34
+ const h = await harness();
35
+ try {
36
+ expect(findGrant(h.db, h.userId, h.clientId)).toBeNull();
37
+ } finally {
38
+ h.cleanup();
39
+ }
40
+ });
41
+
42
+ test("recordGrant inserts a row with sorted scopes", async () => {
43
+ const h = await harness();
44
+ try {
45
+ const grant = recordGrant(h.db, h.userId, h.clientId, ["b", "a", "c"]);
46
+ // Sorted to keep on-disk order deterministic; test pins the contract.
47
+ expect(grant.scopes).toEqual(["a", "b", "c"]);
48
+ const fromDb = findGrant(h.db, h.userId, h.clientId);
49
+ expect(fromDb?.scopes).toEqual(["a", "b", "c"]);
50
+ } finally {
51
+ h.cleanup();
52
+ }
53
+ });
54
+
55
+ test("recordGrant unions new scopes into existing grant", async () => {
56
+ const h = await harness();
57
+ try {
58
+ recordGrant(h.db, h.userId, h.clientId, ["a", "b", "c"]);
59
+ recordGrant(h.db, h.userId, h.clientId, ["a", "d"]);
60
+ const grant = findGrant(h.db, h.userId, h.clientId);
61
+ expect(new Set(grant?.scopes)).toEqual(new Set(["a", "b", "c", "d"]));
62
+ } finally {
63
+ h.cleanup();
64
+ }
65
+ });
66
+
67
+ test("recordGrant skips empty strings inside the scope list", async () => {
68
+ const h = await harness();
69
+ try {
70
+ recordGrant(h.db, h.userId, h.clientId, ["", "a", ""]);
71
+ const grant = findGrant(h.db, h.userId, h.clientId);
72
+ expect(grant?.scopes).toEqual(["a"]);
73
+ } finally {
74
+ h.cleanup();
75
+ }
76
+ });
77
+
78
+ test("isCoveredByGrant: false when no grant exists", async () => {
79
+ const h = await harness();
80
+ try {
81
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["a"])).toBe(false);
82
+ } finally {
83
+ h.cleanup();
84
+ }
85
+ });
86
+
87
+ test("isCoveredByGrant: true for exact match and subset, false for superset", async () => {
88
+ const h = await harness();
89
+ try {
90
+ recordGrant(h.db, h.userId, h.clientId, ["a", "b", "c"]);
91
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["a", "b", "c"])).toBe(true);
92
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["a"])).toBe(true);
93
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["a", "b"])).toBe(true);
94
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["a", "d"])).toBe(false);
95
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, ["d"])).toBe(false);
96
+ } finally {
97
+ h.cleanup();
98
+ }
99
+ });
100
+
101
+ test("isCoveredByGrant: empty request returns false (no auto-approve for empty)", async () => {
102
+ const h = await harness();
103
+ try {
104
+ recordGrant(h.db, h.userId, h.clientId, ["a"]);
105
+ // Empty scope flow is suspicious — surface it through consent rather
106
+ // than silently auto-approving.
107
+ expect(isCoveredByGrant(h.db, h.userId, h.clientId, [])).toBe(false);
108
+ } finally {
109
+ h.cleanup();
110
+ }
111
+ });
112
+
113
+ test("revokeGrant returns true when a row was removed, false when none existed", async () => {
114
+ const h = await harness();
115
+ try {
116
+ expect(revokeGrant(h.db, h.userId, h.clientId)).toBe(false);
117
+ recordGrant(h.db, h.userId, h.clientId, ["a"]);
118
+ expect(revokeGrant(h.db, h.userId, h.clientId)).toBe(true);
119
+ expect(findGrant(h.db, h.userId, h.clientId)).toBeNull();
120
+ } finally {
121
+ h.cleanup();
122
+ }
123
+ });
124
+
125
+ test("recordGrant: concurrent calls produce one row with the union of scopes (#119)", async () => {
126
+ const h = await harness();
127
+ try {
128
+ // Fire two recordGrant calls "concurrently" via Promise.all. The
129
+ // transaction wrapper means the read-merge-write is atomic, so the
130
+ // second writer always sees the first writer's scopes — neither set
131
+ // gets dropped.
132
+ await Promise.all([
133
+ Promise.resolve().then(() => recordGrant(h.db, h.userId, h.clientId, ["a", "b"])),
134
+ Promise.resolve().then(() => recordGrant(h.db, h.userId, h.clientId, ["c", "d"])),
135
+ ]);
136
+ const rowCount = (
137
+ h.db
138
+ .prepare("SELECT COUNT(*) AS n FROM grants WHERE user_id = ? AND client_id = ?")
139
+ .get(h.userId, h.clientId) as { n: number }
140
+ ).n;
141
+ expect(rowCount).toBe(1);
142
+ const grant = findGrant(h.db, h.userId, h.clientId);
143
+ expect(new Set(grant?.scopes)).toEqual(new Set(["a", "b", "c", "d"]));
144
+ } finally {
145
+ h.cleanup();
146
+ }
147
+ });
148
+
149
+ test("listGrantsForUser orders most-recent first", async () => {
150
+ const h = await harness();
151
+ try {
152
+ const reg2 = registerClient(h.db, { redirectUris: ["https://other.example/cb"] });
153
+ // Older grant first.
154
+ recordGrant(h.db, h.userId, h.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
155
+ recordGrant(h.db, h.userId, reg2.client.clientId, ["b"], new Date("2026-04-15T00:00:00Z"));
156
+ const grants = listGrantsForUser(h.db, h.userId);
157
+ expect(grants).toHaveLength(2);
158
+ expect(grants[0]?.clientId).toBe(reg2.client.clientId);
159
+ expect(grants[1]?.clientId).toBe(h.clientId);
160
+ } finally {
161
+ h.cleanup();
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+
7
+ interface Harness {
8
+ configDir: string;
9
+ dbPath: string;
10
+ cleanup: () => void;
11
+ }
12
+
13
+ function makeHarness(): Harness {
14
+ const configDir = mkdtempSync(join(tmpdir(), "phub-db-"));
15
+ return {
16
+ configDir,
17
+ dbPath: hubDbPath(configDir),
18
+ cleanup: () => rmSync(configDir, { recursive: true, force: true }),
19
+ };
20
+ }
21
+
22
+ describe("openHubDb + migrate", () => {
23
+ test("creates schema_version + signing_keys on a fresh db", () => {
24
+ const h = makeHarness();
25
+ try {
26
+ const db = openHubDb(h.dbPath);
27
+ try {
28
+ const tables = (
29
+ db
30
+ .query<{ name: string }, []>(
31
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
32
+ )
33
+ .all() ?? []
34
+ ).map((r) => r.name);
35
+ expect(tables).toContain("schema_version");
36
+ expect(tables).toContain("signing_keys");
37
+ const versions = (
38
+ db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
39
+ ).map((r) => r.version);
40
+ expect(versions).toContain(1);
41
+ } finally {
42
+ db.close();
43
+ }
44
+ } finally {
45
+ h.cleanup();
46
+ }
47
+ });
48
+
49
+ test("re-opening an already-migrated db is a no-op (no duplicate version rows)", () => {
50
+ const h = makeHarness();
51
+ try {
52
+ const db1 = openHubDb(h.dbPath);
53
+ db1.close();
54
+ const db2 = openHubDb(h.dbPath);
55
+ try {
56
+ const rows = db2
57
+ .query<{ version: number; applied_at: string }, []>(
58
+ "SELECT version, applied_at FROM schema_version",
59
+ )
60
+ .all();
61
+ // Each migration recorded exactly once — re-open is idempotent.
62
+ const versions = rows.map((r) => r.version).sort();
63
+ expect(new Set(versions).size).toBe(versions.length);
64
+ expect(versions).toContain(1);
65
+ expect(versions).toContain(2);
66
+ } finally {
67
+ db2.close();
68
+ }
69
+ } finally {
70
+ h.cleanup();
71
+ }
72
+ });
73
+
74
+ test("signing_keys schema enforces required columns", () => {
75
+ const h = makeHarness();
76
+ try {
77
+ const db = openHubDb(h.dbPath);
78
+ try {
79
+ // Missing private_key_pem must fail (NOT NULL).
80
+ expect(() =>
81
+ db
82
+ .prepare(
83
+ "INSERT INTO signing_keys (kid, public_key_pem, algorithm, created_at) VALUES (?, ?, ?, ?)",
84
+ )
85
+ .run("k1", "pem", "RS256", new Date().toISOString()),
86
+ ).toThrow();
87
+ // Full row works; retired_at is nullable.
88
+ db.prepare(
89
+ `INSERT INTO signing_keys (kid, public_key_pem, private_key_pem, algorithm, created_at)
90
+ VALUES (?, ?, ?, ?, ?)`,
91
+ ).run("k2", "pub", "priv", "RS256", new Date().toISOString());
92
+ const row = db
93
+ .query<{ retired_at: string | null }, [string]>(
94
+ "SELECT retired_at FROM signing_keys WHERE kid = ?",
95
+ )
96
+ .get("k2");
97
+ expect(row?.retired_at).toBeNull();
98
+ } finally {
99
+ db.close();
100
+ }
101
+ } finally {
102
+ h.cleanup();
103
+ }
104
+ });
105
+
106
+ test("v2 creates users + tokens tables with the expected columns", () => {
107
+ const h = makeHarness();
108
+ try {
109
+ const db = openHubDb(h.dbPath);
110
+ try {
111
+ const tables = (
112
+ db
113
+ .query<{ name: string }, []>(
114
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
115
+ )
116
+ .all() ?? []
117
+ ).map((r) => r.name);
118
+ expect(tables).toContain("users");
119
+ expect(tables).toContain("tokens");
120
+ const versions = (
121
+ db.query<{ version: number }, []>("SELECT version FROM schema_version").all() ?? []
122
+ ).map((r) => r.version);
123
+ expect(versions).toContain(2);
124
+
125
+ // users.username UNIQUE constraint enforced.
126
+ db.prepare(
127
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
128
+ ).run("u1", "owner", "h", "2026-01-01", "2026-01-01");
129
+ expect(() =>
130
+ db
131
+ .prepare(
132
+ "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
133
+ )
134
+ .run("u2", "owner", "h2", "2026-01-01", "2026-01-01"),
135
+ ).toThrow();
136
+
137
+ // tokens.user_id FK enforced.
138
+ expect(() =>
139
+ db
140
+ .prepare(
141
+ `INSERT INTO tokens (jti, user_id, client_id, scopes, expires_at, created_at)
142
+ VALUES (?, ?, ?, ?, ?, ?)`,
143
+ )
144
+ .run("t1", "no-such-user", "c", "s", "2030-01-01", "2026-01-01"),
145
+ ).toThrow();
146
+ } finally {
147
+ db.close();
148
+ }
149
+ } finally {
150
+ h.cleanup();
151
+ }
152
+ });
153
+ });