@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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/
|
|
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
|
-
//
|
|
169
|
-
//
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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")).
|
|
512
|
-
|
|
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")).
|
|
515
|
-
|
|
523
|
+
expect(oauthTargets.get("/oauth/authorize")).toMatch(
|
|
524
|
+
/^http:\/\/127\.0\.0\.1:\d+\/oauth\/authorize$/,
|
|
516
525
|
);
|
|
517
|
-
expect(oauthTargets.get("/oauth/token")).
|
|
518
|
-
|
|
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("
|
|
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
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
1067
|
-
|
|
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
|
-
|
|
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
|
+
});
|