@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
@@ -122,6 +122,56 @@ describe("cli per-subcommand help", () => {
122
122
  expect(stderr).toMatch(/--cloudflare only applies to `public`/);
123
123
  });
124
124
 
125
+ test("expose public --tailnet --cloudflare rejected as mutually exclusive", async () => {
126
+ const { code, stderr } = await runCli([
127
+ "expose",
128
+ "public",
129
+ "--tailnet",
130
+ "--cloudflare",
131
+ "--domain",
132
+ "vault.example.com",
133
+ ]);
134
+ expect(code).toBe(1);
135
+ expect(stderr).toMatch(/mutually exclusive/);
136
+ });
137
+
138
+ test("expose tailnet --tailnet rejected (tailnet flag scoped to public layer)", async () => {
139
+ const { code, stderr } = await runCli(["expose", "tailnet", "--tailnet"]);
140
+ expect(code).toBe(1);
141
+ expect(stderr).toMatch(/--tailnet pins the public layer/);
142
+ });
143
+
144
+ test("expose --help mentions --tailnet, --skip-provider-check, --tunnel-name", async () => {
145
+ const { code, stdout } = await runCli(["expose", "--help"]);
146
+ expect(code).toBe(0);
147
+ expect(stdout).toMatch(/--tailnet\b/);
148
+ expect(stdout).toMatch(/--skip-provider-check\b/);
149
+ expect(stdout).toMatch(/--tunnel-name\b/);
150
+ });
151
+
152
+ test("expose public --skip-provider-check pins to Tailscale-Funnel default (skips auto-pick)", async () => {
153
+ // With PATH="" tailscale isn't on PATH, so exposePublic prints its own
154
+ // install hint. That's distinct from the auto-pick "no exposure provider
155
+ // is set up" output — proving the skip flag bypassed auto-pick and went
156
+ // straight to the Funnel path. If we regressed and skip-flag tumbled
157
+ // into auto-pick, we'd see the auto-pick neither-ready report instead.
158
+ const proc = Bun.spawn([process.execPath, CLI, "expose", "public", "--skip-provider-check"], {
159
+ stdout: "pipe",
160
+ stderr: "pipe",
161
+ env: {
162
+ ...process.env,
163
+ PATH: "",
164
+ HOME: "/tmp/parachute-hub-nonexistent-home",
165
+ PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
166
+ },
167
+ });
168
+ const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
169
+ expect(code).toBe(1);
170
+ expect(stdout).toMatch(/tailscale is not installed or not on PATH/);
171
+ expect(stdout).not.toMatch(/no exposure provider is set up/);
172
+ expect(stdout).not.toMatch(/Auto-detected/);
173
+ });
174
+
125
175
  test("expose with missing --domain value exits 1", async () => {
126
176
  const { code, stderr } = await runCli(["expose", "public", "--cloudflare", "--domain"]);
127
177
  expect(code).toBe(1);
@@ -0,0 +1,264 @@
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 {
6
+ InvalidRedirectUriError,
7
+ approveClient,
8
+ getClient,
9
+ isValidRedirectUri,
10
+ listClientsByStatus,
11
+ registerClient,
12
+ requireRegisteredRedirectUri,
13
+ verifyClientSecret,
14
+ } from "../clients.ts";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+
17
+ function makeDb() {
18
+ const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
19
+ const db = openHubDb(hubDbPath(configDir));
20
+ return {
21
+ db,
22
+ cleanup: () => {
23
+ db.close();
24
+ rmSync(configDir, { recursive: true, force: true });
25
+ },
26
+ };
27
+ }
28
+
29
+ describe("registerClient", () => {
30
+ test("public client has no client_secret", () => {
31
+ const { db, cleanup } = makeDb();
32
+ try {
33
+ const r = registerClient(db, {
34
+ redirectUris: ["https://example.com/cb"],
35
+ scopes: ["vault.read"],
36
+ clientName: "test",
37
+ });
38
+ expect(r.clientSecret).toBeNull();
39
+ expect(r.client.clientSecretHash).toBeNull();
40
+ expect(r.client.clientId.length).toBeGreaterThan(0);
41
+ expect(r.client.redirectUris).toEqual(["https://example.com/cb"]);
42
+ expect(r.client.scopes).toEqual(["vault.read"]);
43
+ expect(r.client.clientName).toBe("test");
44
+ } finally {
45
+ cleanup();
46
+ }
47
+ });
48
+
49
+ test("confidential client returns plaintext secret once and stores hash", () => {
50
+ const { db, cleanup } = makeDb();
51
+ try {
52
+ const r = registerClient(db, {
53
+ redirectUris: ["https://example.com/cb"],
54
+ confidential: true,
55
+ });
56
+ expect(r.clientSecret).not.toBeNull();
57
+ expect(r.clientSecret?.length).toBeGreaterThan(20);
58
+ // Hash is sha256 hex (64 chars).
59
+ expect(r.client.clientSecretHash).toMatch(/^[0-9a-f]{64}$/);
60
+ // The plaintext is not recoverable from the row.
61
+ const fetched = getClient(db, r.client.clientId);
62
+ expect(fetched?.clientSecretHash).toBe(r.client.clientSecretHash);
63
+ } finally {
64
+ cleanup();
65
+ }
66
+ });
67
+
68
+ test("rejects empty redirect_uris", () => {
69
+ const { db, cleanup } = makeDb();
70
+ try {
71
+ expect(() => registerClient(db, { redirectUris: [] })).toThrow(/redirect_uri/);
72
+ } finally {
73
+ cleanup();
74
+ }
75
+ });
76
+
77
+ test("rejects non-http(s) redirect_uri", () => {
78
+ const { db, cleanup } = makeDb();
79
+ try {
80
+ expect(() => registerClient(db, { redirectUris: ["javascript:alert(1)"] })).toThrow(
81
+ /invalid redirect_uri/,
82
+ );
83
+ expect(() => registerClient(db, { redirectUris: ["/relative/path"] })).toThrow(
84
+ /invalid redirect_uri/,
85
+ );
86
+ } finally {
87
+ cleanup();
88
+ }
89
+ });
90
+ });
91
+
92
+ describe("getClient", () => {
93
+ test("returns null for unknown clientId", () => {
94
+ const { db, cleanup } = makeDb();
95
+ try {
96
+ expect(getClient(db, "nope")).toBeNull();
97
+ } finally {
98
+ cleanup();
99
+ }
100
+ });
101
+
102
+ test("round-trips a registered client", () => {
103
+ const { db, cleanup } = makeDb();
104
+ try {
105
+ const r = registerClient(db, {
106
+ redirectUris: ["https://a.example/cb", "https://b.example/cb"],
107
+ scopes: ["vault.read", "vault.write"],
108
+ });
109
+ const fetched = getClient(db, r.client.clientId);
110
+ expect(fetched?.redirectUris).toEqual(["https://a.example/cb", "https://b.example/cb"]);
111
+ expect(fetched?.scopes).toEqual(["vault.read", "vault.write"]);
112
+ } finally {
113
+ cleanup();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("requireRegisteredRedirectUri", () => {
119
+ test("returns the matched URI on exact match", () => {
120
+ const { db, cleanup } = makeDb();
121
+ try {
122
+ const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
123
+ expect(requireRegisteredRedirectUri(r.client, "https://example.com/cb")).toBe(
124
+ "https://example.com/cb",
125
+ );
126
+ } finally {
127
+ cleanup();
128
+ }
129
+ });
130
+
131
+ test("throws on prefix-only / loose match (open-redirect guard)", () => {
132
+ const { db, cleanup } = makeDb();
133
+ try {
134
+ const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
135
+ expect(() => requireRegisteredRedirectUri(r.client, "https://example.com/cb/extra")).toThrow(
136
+ InvalidRedirectUriError,
137
+ );
138
+ expect(() => requireRegisteredRedirectUri(r.client, "https://evil.com/cb")).toThrow(
139
+ InvalidRedirectUriError,
140
+ );
141
+ } finally {
142
+ cleanup();
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("verifyClientSecret", () => {
148
+ test("matches the issued secret, rejects others", () => {
149
+ const { db, cleanup } = makeDb();
150
+ try {
151
+ const r = registerClient(db, {
152
+ redirectUris: ["https://example.com/cb"],
153
+ confidential: true,
154
+ });
155
+ expect(r.clientSecret).not.toBeNull();
156
+ expect(verifyClientSecret(r.client, r.clientSecret ?? "")).toBe(true);
157
+ expect(verifyClientSecret(r.client, "wrong")).toBe(false);
158
+ } finally {
159
+ cleanup();
160
+ }
161
+ });
162
+
163
+ test("returns false for public clients regardless of presented secret", () => {
164
+ const { db, cleanup } = makeDb();
165
+ try {
166
+ const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
167
+ expect(verifyClientSecret(r.client, "anything")).toBe(false);
168
+ } finally {
169
+ cleanup();
170
+ }
171
+ });
172
+ });
173
+
174
+ describe("approval gate (#74)", () => {
175
+ test("registerClient defaults status to approved (direct callers)", () => {
176
+ const { db, cleanup } = makeDb();
177
+ try {
178
+ const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
179
+ expect(r.client.status).toBe("approved");
180
+ } finally {
181
+ cleanup();
182
+ }
183
+ });
184
+
185
+ test("registerClient honors explicit status: pending (DCR path)", () => {
186
+ const { db, cleanup } = makeDb();
187
+ try {
188
+ const r = registerClient(db, {
189
+ redirectUris: ["https://example.com/cb"],
190
+ status: "pending",
191
+ });
192
+ expect(r.client.status).toBe("pending");
193
+ expect(getClient(db, r.client.clientId)?.status).toBe("pending");
194
+ } finally {
195
+ cleanup();
196
+ }
197
+ });
198
+
199
+ test("approveClient promotes pending → approved and is idempotent", () => {
200
+ const { db, cleanup } = makeDb();
201
+ try {
202
+ const r = registerClient(db, {
203
+ redirectUris: ["https://example.com/cb"],
204
+ status: "pending",
205
+ });
206
+ expect(approveClient(db, r.client.clientId)).toBe(true);
207
+ expect(getClient(db, r.client.clientId)?.status).toBe("approved");
208
+ // Second call is a no-op but still returns true.
209
+ expect(approveClient(db, r.client.clientId)).toBe(true);
210
+ expect(getClient(db, r.client.clientId)?.status).toBe("approved");
211
+ } finally {
212
+ cleanup();
213
+ }
214
+ });
215
+
216
+ test("approveClient returns false for unknown client", () => {
217
+ const { db, cleanup } = makeDb();
218
+ try {
219
+ expect(approveClient(db, "no-such-client")).toBe(false);
220
+ } finally {
221
+ cleanup();
222
+ }
223
+ });
224
+
225
+ test("listClientsByStatus filters and orders by registered_at", () => {
226
+ const { db, cleanup } = makeDb();
227
+ try {
228
+ const a = registerClient(db, {
229
+ redirectUris: ["https://a.example/cb"],
230
+ status: "pending",
231
+ now: () => new Date("2026-01-01T00:00:00Z"),
232
+ });
233
+ const b = registerClient(db, {
234
+ redirectUris: ["https://b.example/cb"],
235
+ status: "approved",
236
+ now: () => new Date("2026-01-02T00:00:00Z"),
237
+ });
238
+ const c = registerClient(db, {
239
+ redirectUris: ["https://c.example/cb"],
240
+ status: "pending",
241
+ now: () => new Date("2026-01-03T00:00:00Z"),
242
+ });
243
+ const pending = listClientsByStatus(db, "pending").map((r) => r.clientId);
244
+ expect(pending).toEqual([a.client.clientId, c.client.clientId]);
245
+ const approved = listClientsByStatus(db, "approved").map((r) => r.clientId);
246
+ expect(approved).toEqual([b.client.clientId]);
247
+ } finally {
248
+ cleanup();
249
+ }
250
+ });
251
+ });
252
+
253
+ describe("isValidRedirectUri", () => {
254
+ test("accepts http and https", () => {
255
+ expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
256
+ expect(isValidRedirectUri("https://example.com/cb")).toBe(true);
257
+ });
258
+ test("rejects javascript:, data:, relative paths, garbage", () => {
259
+ expect(isValidRedirectUri("javascript:alert(1)")).toBe(false);
260
+ expect(isValidRedirectUri("data:text/html,x")).toBe(false);
261
+ expect(isValidRedirectUri("/relative")).toBe(false);
262
+ expect(isValidRedirectUri("not a url")).toBe(false);
263
+ });
264
+ });
@@ -1,12 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  type CloudflaredState,
7
7
  CloudflaredStateError,
8
+ type CloudflaredTunnelRecord,
8
9
  clearCloudflaredState,
10
+ findTunnelRecord,
11
+ listTunnelRecords,
9
12
  readCloudflaredState,
13
+ withTunnelRecord,
14
+ withoutTunnelRecord,
10
15
  writeCloudflaredState,
11
16
  } from "../cloudflare/state.ts";
12
17
 
@@ -18,14 +23,18 @@ function makeTempPath(): { path: string; cleanup: () => void } {
18
23
  };
19
24
  }
20
25
 
21
- const sample: CloudflaredState = {
22
- version: 1,
26
+ const sampleRecord: CloudflaredTunnelRecord = {
23
27
  pid: 12345,
24
28
  tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
25
29
  tunnelName: "parachute",
26
30
  hostname: "vault.example.com",
27
31
  startedAt: "2026-04-22T12:00:00.000Z",
28
- configPath: "/home/x/.parachute/cloudflared/config.yml",
32
+ configPath: "/home/x/.parachute/cloudflared/parachute/config.yml",
33
+ };
34
+
35
+ const sample: CloudflaredState = {
36
+ version: 2,
37
+ tunnels: { parachute: sampleRecord },
29
38
  };
30
39
 
31
40
  describe("cloudflared state", () => {
@@ -63,23 +72,45 @@ describe("cloudflared state", () => {
63
72
  test("throws on unsupported version", () => {
64
73
  const { path, cleanup } = makeTempPath();
65
74
  try {
66
- writeFileSync(path, JSON.stringify({ ...sample, version: 99 }));
75
+ writeFileSync(path, JSON.stringify({ version: 99, tunnels: {} }));
67
76
  expect(() => readCloudflaredState(path)).toThrow(/unsupported version/);
68
77
  } finally {
69
78
  cleanup();
70
79
  }
71
80
  });
72
81
 
73
- test("throws on non-positive pid", () => {
82
+ test("throws on non-positive pid in a tunnel record", () => {
74
83
  const { path, cleanup } = makeTempPath();
75
84
  try {
76
- writeFileSync(path, JSON.stringify({ ...sample, pid: -1 }));
85
+ writeFileSync(
86
+ path,
87
+ JSON.stringify({
88
+ version: 2,
89
+ tunnels: { parachute: { ...sampleRecord, pid: -1 } },
90
+ }),
91
+ );
77
92
  expect(() => readCloudflaredState(path)).toThrow(CloudflaredStateError);
78
93
  } finally {
79
94
  cleanup();
80
95
  }
81
96
  });
82
97
 
98
+ test("throws when tunnel record's name doesn't match its key", () => {
99
+ const { path, cleanup } = makeTempPath();
100
+ try {
101
+ writeFileSync(
102
+ path,
103
+ JSON.stringify({
104
+ version: 2,
105
+ tunnels: { parachute: { ...sampleRecord, tunnelName: "different" } },
106
+ }),
107
+ );
108
+ expect(() => readCloudflaredState(path)).toThrow(/must equal its key/);
109
+ } finally {
110
+ cleanup();
111
+ }
112
+ });
113
+
83
114
  test("throws on malformed JSON", () => {
84
115
  const { path, cleanup } = makeTempPath();
85
116
  try {
@@ -89,4 +120,133 @@ describe("cloudflared state", () => {
89
120
  cleanup();
90
121
  }
91
122
  });
123
+
124
+ test("migrates v1 single-record state to v2 on read", () => {
125
+ const { path, cleanup } = makeTempPath();
126
+ try {
127
+ // v1 — pre-#32 shape with the single record at the top level. cloudflared
128
+ // installs in the wild may still have this on disk; reading it must not
129
+ // explode and must yield the canonical v2 shape.
130
+ const legacy = {
131
+ version: 1,
132
+ pid: 12345,
133
+ tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
134
+ tunnelName: "parachute",
135
+ hostname: "vault.example.com",
136
+ startedAt: "2026-04-22T12:00:00.000Z",
137
+ configPath: "/home/x/.parachute/cloudflared/config.yml",
138
+ };
139
+ writeFileSync(path, JSON.stringify(legacy));
140
+
141
+ const state = readCloudflaredState(path);
142
+ expect(state).toEqual({
143
+ version: 2,
144
+ tunnels: {
145
+ parachute: {
146
+ pid: 12345,
147
+ tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
148
+ tunnelName: "parachute",
149
+ hostname: "vault.example.com",
150
+ startedAt: "2026-04-22T12:00:00.000Z",
151
+ configPath: "/home/x/.parachute/cloudflared/config.yml",
152
+ },
153
+ },
154
+ });
155
+ } finally {
156
+ cleanup();
157
+ }
158
+ });
159
+
160
+ test("v1 migration is read-only until the next write", () => {
161
+ // The migration is silent on read but doesn't rewrite disk on its own —
162
+ // disk only flips when the next write commits. Mirrors how other state
163
+ // migrations in the repo behave; documents the contract.
164
+ const { path, cleanup } = makeTempPath();
165
+ try {
166
+ const legacy = {
167
+ version: 1,
168
+ pid: 12345,
169
+ tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
170
+ tunnelName: "parachute",
171
+ hostname: "vault.example.com",
172
+ startedAt: "2026-04-22T12:00:00.000Z",
173
+ configPath: "/home/x/.parachute/cloudflared/config.yml",
174
+ };
175
+ writeFileSync(path, JSON.stringify(legacy));
176
+ readCloudflaredState(path); // returns v2 in memory
177
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
178
+ expect(onDisk.version).toBe(1);
179
+
180
+ // After a write, disk reflects v2.
181
+ const state = readCloudflaredState(path);
182
+ writeCloudflaredState(state as CloudflaredState, path);
183
+ const onDiskAfter = JSON.parse(readFileSync(path, "utf8"));
184
+ expect(onDiskAfter.version).toBe(2);
185
+ expect(onDiskAfter.tunnels.parachute.tunnelName).toBe("parachute");
186
+ } finally {
187
+ cleanup();
188
+ }
189
+ });
190
+ });
191
+
192
+ describe("cloudflared state — record helpers", () => {
193
+ const recordA: CloudflaredTunnelRecord = {
194
+ pid: 1001,
195
+ tunnelUuid: "aaaaaaaa-0000-0000-0000-000000000001",
196
+ tunnelName: "alpha",
197
+ hostname: "alpha.example.com",
198
+ startedAt: "2026-04-23T10:00:00.000Z",
199
+ configPath: "/tmp/alpha/config.yml",
200
+ };
201
+ const recordB: CloudflaredTunnelRecord = {
202
+ pid: 1002,
203
+ tunnelUuid: "bbbbbbbb-0000-0000-0000-000000000002",
204
+ tunnelName: "beta",
205
+ hostname: "beta.example.com",
206
+ startedAt: "2026-04-23T11:00:00.000Z",
207
+ configPath: "/tmp/beta/config.yml",
208
+ };
209
+
210
+ test("findTunnelRecord returns undefined for unknown name and the record for known name", () => {
211
+ const state: CloudflaredState = { version: 2, tunnels: { alpha: recordA } };
212
+ expect(findTunnelRecord(state, "alpha")).toEqual(recordA);
213
+ expect(findTunnelRecord(state, "beta")).toBeUndefined();
214
+ expect(findTunnelRecord(undefined, "alpha")).toBeUndefined();
215
+ });
216
+
217
+ test("withTunnelRecord inserts into empty/undefined state", () => {
218
+ const next = withTunnelRecord(undefined, recordA);
219
+ expect(next).toEqual({ version: 2, tunnels: { alpha: recordA } });
220
+ });
221
+
222
+ test("withTunnelRecord adds a second tunnel without disturbing the first", () => {
223
+ const initial = withTunnelRecord(undefined, recordA);
224
+ const next = withTunnelRecord(initial, recordB);
225
+ expect(next.tunnels).toEqual({ alpha: recordA, beta: recordB });
226
+ });
227
+
228
+ test("withTunnelRecord replaces an existing record under the same name", () => {
229
+ const initial = withTunnelRecord(undefined, recordA);
230
+ const replaced: CloudflaredTunnelRecord = { ...recordA, pid: 9999 };
231
+ const next = withTunnelRecord(initial, replaced);
232
+ expect(next.tunnels.alpha).toEqual(replaced);
233
+ expect(Object.keys(next.tunnels)).toEqual(["alpha"]);
234
+ });
235
+
236
+ test("withoutTunnelRecord drops the named tunnel and returns undefined when empty", () => {
237
+ const initial = withTunnelRecord(undefined, recordA);
238
+ expect(withoutTunnelRecord(initial, "alpha")).toBeUndefined();
239
+ });
240
+
241
+ test("withoutTunnelRecord leaves other tunnels in place", () => {
242
+ const both = withTunnelRecord(withTunnelRecord(undefined, recordA), recordB);
243
+ const next = withoutTunnelRecord(both, "alpha");
244
+ expect(next).toEqual({ version: 2, tunnels: { beta: recordB } });
245
+ });
246
+
247
+ test("listTunnelRecords returns sorted-by-name order", () => {
248
+ const both = withTunnelRecord(withTunnelRecord(undefined, recordB), recordA);
249
+ expect(listTunnelRecords(both).map((r) => r.tunnelName)).toEqual(["alpha", "beta"]);
250
+ expect(listTunnelRecords(undefined)).toEqual([]);
251
+ });
92
252
  });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ CSRF_COOKIE_NAME,
4
+ CSRF_FIELD_NAME,
5
+ buildCsrfCookie,
6
+ ensureCsrfToken,
7
+ generateCsrfToken,
8
+ parseCsrfCookie,
9
+ renderCsrfHiddenInput,
10
+ verifyCsrfToken,
11
+ } from "../csrf.ts";
12
+
13
+ function reqWith(cookie: string | null, formToken?: string | null): Request {
14
+ const headers = new Headers();
15
+ if (cookie !== null) headers.set("cookie", cookie);
16
+ return new Request("https://hub.example/oauth/authorize", { headers });
17
+ }
18
+
19
+ describe("generateCsrfToken", () => {
20
+ test("returns a base64url string with > 32 chars of entropy", () => {
21
+ const token = generateCsrfToken();
22
+ expect(token.length).toBeGreaterThan(32);
23
+ expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
24
+ });
25
+
26
+ test("two calls produce distinct tokens", () => {
27
+ expect(generateCsrfToken()).not.toBe(generateCsrfToken());
28
+ });
29
+ });
30
+
31
+ describe("buildCsrfCookie", () => {
32
+ test("emits the expected attributes", () => {
33
+ const v = buildCsrfCookie("abc");
34
+ expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
35
+ expect(v).toContain("HttpOnly");
36
+ expect(v).toContain("Secure");
37
+ expect(v).toContain("SameSite=Lax");
38
+ expect(v).toContain("Path=/");
39
+ expect(v).toContain("Max-Age=");
40
+ });
41
+ });
42
+
43
+ describe("parseCsrfCookie", () => {
44
+ test("extracts the token from a cookie header", () => {
45
+ expect(parseCsrfCookie(`${CSRF_COOKIE_NAME}=xyz`)).toBe("xyz");
46
+ expect(parseCsrfCookie(`other=foo; ${CSRF_COOKIE_NAME}=xyz; bar=baz`)).toBe("xyz");
47
+ });
48
+
49
+ test("returns null when absent or empty", () => {
50
+ expect(parseCsrfCookie(null)).toBeNull();
51
+ expect(parseCsrfCookie("")).toBeNull();
52
+ expect(parseCsrfCookie("other=foo")).toBeNull();
53
+ });
54
+ });
55
+
56
+ describe("ensureCsrfToken", () => {
57
+ test("mints a fresh cookie when none is present", () => {
58
+ const result = ensureCsrfToken(reqWith(null));
59
+ expect(result.token.length).toBeGreaterThan(32);
60
+ expect(result.setCookie).toContain(`${CSRF_COOKIE_NAME}=${result.token}`);
61
+ });
62
+
63
+ test("reuses the existing cookie token without re-setting", () => {
64
+ const result = ensureCsrfToken(reqWith(`${CSRF_COOKIE_NAME}=existing-token`));
65
+ expect(result.token).toBe("existing-token");
66
+ expect(result.setCookie).toBeUndefined();
67
+ });
68
+
69
+ test("mints fresh when the cookie is empty", () => {
70
+ const result = ensureCsrfToken(reqWith(`${CSRF_COOKIE_NAME}=`));
71
+ expect(result.token.length).toBeGreaterThan(0);
72
+ expect(result.setCookie).toBeDefined();
73
+ });
74
+ });
75
+
76
+ describe("verifyCsrfToken", () => {
77
+ test("returns true when cookie and form match", () => {
78
+ const token = "match-me";
79
+ const req = reqWith(`${CSRF_COOKIE_NAME}=${token}`);
80
+ expect(verifyCsrfToken(req, token)).toBe(true);
81
+ });
82
+
83
+ test("returns false when the form token differs", () => {
84
+ const req = reqWith(`${CSRF_COOKIE_NAME}=cookie-token`);
85
+ expect(verifyCsrfToken(req, "form-token")).toBe(false);
86
+ });
87
+
88
+ test("returns false when cookie token is missing", () => {
89
+ expect(verifyCsrfToken(reqWith(null), "form-token")).toBe(false);
90
+ });
91
+
92
+ test("returns false when form token is missing", () => {
93
+ const req = reqWith(`${CSRF_COOKIE_NAME}=cookie-token`);
94
+ expect(verifyCsrfToken(req, null)).toBe(false);
95
+ });
96
+
97
+ test("returns false when lengths differ (avoids timingSafeEqual throw)", () => {
98
+ const req = reqWith(`${CSRF_COOKIE_NAME}=abcd`);
99
+ expect(verifyCsrfToken(req, "abcdef")).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("renderCsrfHiddenInput", () => {
104
+ test("renders an HTML hidden input with the field name", () => {
105
+ const html = renderCsrfHiddenInput("token-123");
106
+ expect(html).toContain(`name="${CSRF_FIELD_NAME}"`);
107
+ expect(html).toContain('value="token-123"');
108
+ expect(html).toContain('type="hidden"');
109
+ });
110
+
111
+ test("escapes hostile token content into the value attribute", () => {
112
+ const html = renderCsrfHiddenInput(`"><script>alert(1)</script>`);
113
+ expect(html).not.toContain("<script>");
114
+ expect(html).toContain("&quot;");
115
+ expect(html).toContain("&lt;script");
116
+ });
117
+ });