@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
package/src/cli.ts CHANGED
@@ -13,7 +13,9 @@ import { exposePublic, exposeTailnet } from "./commands/expose.ts";
13
13
  import { install } from "./commands/install.ts";
14
14
  import { logs, restart, start, stop } from "./commands/lifecycle.ts";
15
15
  import { migrate } from "./commands/migrate.ts";
16
+ import { setup } from "./commands/setup.ts";
16
17
  import { status } from "./commands/status.ts";
18
+ import { upgrade } from "./commands/upgrade.ts";
17
19
  import { dispatchVault } from "./commands/vault.ts";
18
20
  import { ExposeStateError } from "./expose-state.ts";
19
21
  import {
@@ -22,11 +24,14 @@ import {
22
24
  logsHelp,
23
25
  migrateHelp,
24
26
  restartHelp,
27
+ setupHelp,
25
28
  startHelp,
26
29
  statusHelp,
27
30
  stopHelp,
28
31
  topLevelHelp,
32
+ upgradeHelp,
29
33
  } from "./help.ts";
34
+ import { HUB_SVC } from "./hub-control.ts";
30
35
  import { knownServices } from "./service-spec.ts";
31
36
  import { ServicesManifestError } from "./services-manifest.ts";
32
37
  import { TailscaleError } from "./tailscale/run.ts";
@@ -139,41 +144,124 @@ function extractNamedFlag(
139
144
  }
140
145
 
141
146
  /**
142
- * Extract the Cloudflare-mode flags from `parachute expose public …`:
143
- * `--cloudflare` (boolean) + `--domain=<host>` / `--domain <host>`. Returns
144
- * the stripped argv so the layer/action parser sees `[layer, action?]`
145
- * regardless of flag placement.
147
+ * Extract every `parachute expose …` provider flag in one pass:
148
+ *
149
+ * --cloudflare boolean pin to Cloudflare Tunnel
150
+ * --tailnet boolean pin to Tailscale Funnel (#29)
151
+ * --skip-provider-check boolean — bypass auto-detection in non-TTY,
152
+ * fall through to today's Tailscale
153
+ * default (CI escape hatch, #29)
154
+ * --domain=<host> hostname for the Cloudflare path
155
+ * --tunnel-name=<name> named tunnel override (#32)
156
+ *
157
+ * Returns the stripped argv so the layer/action parser sees `[layer, action?]`
158
+ * regardless of flag placement. `--tailnet` + `--cloudflare` together is
159
+ * caller-rejected; this extractor doesn't enforce mutual exclusion so help-
160
+ * driven error messages can stay close to the dispatch site.
146
161
  */
147
- function extractCloudflareFlags(args: string[]): {
162
+ function extractExposeProviderFlags(args: string[]): {
148
163
  cloudflare: boolean;
164
+ tailnet: boolean;
165
+ skipProviderCheck: boolean;
149
166
  domain?: string;
167
+ tunnelName?: string;
150
168
  rest: string[];
151
169
  error?: string;
152
170
  } {
153
171
  const rest: string[] = [];
154
172
  let cloudflare = false;
173
+ let tailnet = false;
174
+ let skipProviderCheck = false;
155
175
  let domain: string | undefined;
176
+ let tunnelName: string | undefined;
156
177
  for (let i = 0; i < args.length; i++) {
157
178
  const a = args[i];
158
179
  if (a === "--cloudflare") {
159
180
  cloudflare = true;
160
181
  continue;
161
182
  }
183
+ if (a === "--tailnet" || a === "--tailscale") {
184
+ tailnet = true;
185
+ continue;
186
+ }
187
+ if (a === "--skip-provider-check") {
188
+ skipProviderCheck = true;
189
+ continue;
190
+ }
162
191
  if (a === "--domain") {
163
192
  const v = args[i + 1];
164
- if (!v) return { cloudflare, rest, error: "--domain requires a hostname argument" };
193
+ if (!v) {
194
+ return {
195
+ cloudflare,
196
+ tailnet,
197
+ skipProviderCheck,
198
+ rest,
199
+ error: "--domain requires a hostname argument",
200
+ };
201
+ }
165
202
  domain = v;
166
203
  i++;
167
204
  continue;
168
205
  }
169
206
  if (a?.startsWith("--domain=")) {
170
207
  domain = a.slice("--domain=".length);
171
- if (!domain) return { cloudflare, rest, error: "--domain requires a hostname argument" };
208
+ if (!domain) {
209
+ return {
210
+ cloudflare,
211
+ tailnet,
212
+ skipProviderCheck,
213
+ rest,
214
+ error: "--domain requires a hostname argument",
215
+ };
216
+ }
217
+ continue;
218
+ }
219
+ if (a === "--tunnel-name") {
220
+ const v = args[i + 1];
221
+ if (!v) {
222
+ return {
223
+ cloudflare,
224
+ tailnet,
225
+ skipProviderCheck,
226
+ rest,
227
+ error: "--tunnel-name requires a name argument",
228
+ };
229
+ }
230
+ tunnelName = v;
231
+ i++;
232
+ continue;
233
+ }
234
+ if (a?.startsWith("--tunnel-name=")) {
235
+ tunnelName = a.slice("--tunnel-name=".length);
236
+ if (!tunnelName) {
237
+ return {
238
+ cloudflare,
239
+ tailnet,
240
+ skipProviderCheck,
241
+ rest,
242
+ error: "--tunnel-name requires a name argument",
243
+ };
244
+ }
172
245
  continue;
173
246
  }
174
247
  if (a !== undefined) rest.push(a);
175
248
  }
176
- return { cloudflare, domain, rest };
249
+ const out: {
250
+ cloudflare: boolean;
251
+ tailnet: boolean;
252
+ skipProviderCheck: boolean;
253
+ domain?: string;
254
+ tunnelName?: string;
255
+ rest: string[];
256
+ } = {
257
+ cloudflare,
258
+ tailnet,
259
+ skipProviderCheck,
260
+ rest,
261
+ };
262
+ if (domain !== undefined) out.domain = domain;
263
+ if (tunnelName !== undefined) out.tunnelName = tunnelName;
264
+ return out;
177
265
  }
178
266
 
179
267
  async function main(argv: string[]): Promise<number> {
@@ -192,6 +280,29 @@ async function main(argv: string[]): Promise<number> {
192
280
  console.log(pkg.version);
193
281
  return 0;
194
282
 
283
+ case "setup": {
284
+ if (isHelpFlag(rest[0])) {
285
+ console.log(setupHelp());
286
+ return 0;
287
+ }
288
+ const tagExtract = extractTag(rest);
289
+ if (tagExtract.error) {
290
+ console.error(`parachute setup: ${tagExtract.error}`);
291
+ return 1;
292
+ }
293
+ const noStart = tagExtract.rest.includes("--no-start");
294
+ const remaining = tagExtract.rest.filter((a) => a !== "--no-start");
295
+ if (remaining.length > 0) {
296
+ console.error(`parachute setup: unknown argument "${remaining[0]}"`);
297
+ console.error("usage: parachute setup [--tag <name>] [--no-start]");
298
+ return 1;
299
+ }
300
+ const setupOpts: Parameters<typeof setup>[0] = {};
301
+ if (tagExtract.tag) setupOpts.tag = tagExtract.tag;
302
+ if (noStart) setupOpts.noStart = true;
303
+ return await setup(setupOpts);
304
+ }
305
+
195
306
  case "install": {
196
307
  if (isHelpFlag(rest[0])) {
197
308
  console.log(installHelp());
@@ -253,12 +364,18 @@ async function main(argv: string[]): Promise<number> {
253
364
  console.error(`parachute expose: ${hubExtract.error}`);
254
365
  return 1;
255
366
  }
256
- const cfExtract = extractCloudflareFlags(hubExtract.rest);
257
- if (cfExtract.error) {
258
- console.error(`parachute expose: ${cfExtract.error}`);
367
+ const flagExtract = extractExposeProviderFlags(hubExtract.rest);
368
+ if (flagExtract.error) {
369
+ console.error(`parachute expose: ${flagExtract.error}`);
259
370
  return 1;
260
371
  }
261
- const exposeArgs = cfExtract.rest;
372
+ if (flagExtract.cloudflare && flagExtract.tailnet) {
373
+ console.error(
374
+ "parachute expose: --tailnet and --cloudflare are mutually exclusive. Pick one.",
375
+ );
376
+ return 1;
377
+ }
378
+ const exposeArgs = flagExtract.rest;
262
379
  const layer = exposeArgs[0];
263
380
  const mode = exposeArgs[1];
264
381
  if (isHelpFlag(layer)) {
@@ -284,10 +401,17 @@ async function main(argv: string[]): Promise<number> {
284
401
  }
285
402
  const action = mode === "off" ? "off" : "up";
286
403
 
404
+ if (flagExtract.tailnet && layer !== "public") {
405
+ console.error(
406
+ "parachute expose: --tailnet pins the public layer to Tailscale Funnel; it doesn't apply to `expose tailnet`.",
407
+ );
408
+ return 1;
409
+ }
410
+
287
411
  // Cloudflare mode is a separate execution path — different detector,
288
412
  // different state file, different process model (it spawns cloudflared
289
413
  // rather than driving tailscale serve/funnel). Route to it early.
290
- if (cfExtract.cloudflare) {
414
+ if (flagExtract.cloudflare) {
291
415
  if (layer !== "public") {
292
416
  console.error(
293
417
  "parachute expose: --cloudflare only applies to `public` (it's a public-internet path).",
@@ -297,10 +421,11 @@ async function main(argv: string[]): Promise<number> {
297
421
  const { exposeCloudflareUp, exposeCloudflareOff } = await import(
298
422
  "./commands/expose-cloudflare.ts"
299
423
  );
424
+ const cfOpts = flagExtract.tunnelName ? { tunnelName: flagExtract.tunnelName } : {};
300
425
  if (action === "off") {
301
- return await exposeCloudflareOff();
426
+ return await exposeCloudflareOff(cfOpts);
302
427
  }
303
- if (!cfExtract.domain) {
428
+ if (!flagExtract.domain) {
304
429
  // Partial flag promotion: the user told us they want Cloudflare but
305
430
  // didn't supply a hostname. In a TTY, prompt only for what's
306
431
  // missing instead of forcing them to retype the whole command. In a
@@ -322,21 +447,46 @@ async function main(argv: string[]): Promise<number> {
322
447
  console.error(" parachute expose public");
323
448
  return 1;
324
449
  }
325
- return await exposeCloudflareUp(cfExtract.domain);
450
+ return await exposeCloudflareUp(flagExtract.domain, cfOpts);
326
451
  }
327
452
 
328
453
  const exposeOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
329
454
 
330
- // Interactive picker: `parachute expose public` with no provider/domain
331
- // flags, running under a TTY on both stdin and stdout, routes through a
332
- // guided flow that offers Tailscale vs. Cloudflare, walks provider
333
- // setup, and hands back to the flag-driven entry points. Non-TTY or any
334
- // scripted use (flags present) keeps today's behavior exactly.
455
+ // `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
456
+ // interactive picker and the non-TTY auto-pick. Goes straight to
457
+ // exposePublic so today's Funnel flow keeps working unchanged.
458
+ if (layer === "public" && action === "up" && flagExtract.tailnet) {
459
+ return await exposePublic("up", exposeOpts);
460
+ }
461
+
462
+ // Interactive picker: `parachute expose public` with no provider flags,
463
+ // running under a TTY on both stdin and stdout, routes through a guided
464
+ // flow that offers Tailscale vs. Cloudflare, walks provider setup, and
465
+ // hands back to the flag-driven entry points.
335
466
  if (layer === "public" && action === "up" && isTtyInteractive()) {
336
467
  const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
337
468
  return await exposePublicInteractive({ exposeOpts });
338
469
  }
339
470
 
471
+ // Non-TTY auto-pick: detect which provider is configured and run it.
472
+ // `--skip-provider-check` (CI escape hatch) skips detection and falls
473
+ // through to today's Tailscale-Funnel default — useful when the
474
+ // environment is already pre-flighted and the auto-pick would just
475
+ // print noise. Both paths run only on `expose public up`; tailnet
476
+ // exposure has only one provider so nothing to pick.
477
+ //
478
+ // `domain` and `tunnelName` are deliberately *not* threaded into
479
+ // auto-pick. Both are Cloudflare-only flags; if a user passes them
480
+ // without `--cloudflare`, threading would silently route them to
481
+ // Cloudflare. Better to drop the flags here and let auto-pick decide
482
+ // purely from what's installed — if it lands on cloudflare-only-ready,
483
+ // it prints the explicit `--cloudflare --domain` hint instead of
484
+ // guessing intent.
485
+ if (layer === "public" && action === "up" && !flagExtract.skipProviderCheck) {
486
+ const { exposePublicAutoPick } = await import("./commands/expose-public-auto.ts");
487
+ return await exposePublicAutoPick({ tailscaleOpts: exposeOpts });
488
+ }
489
+
340
490
  // `expose public off` (no `--cloudflare`) auto-detects which provider is
341
491
  // live. The explicit `--cloudflare` off branch above still wins — this
342
492
  // path is only for users who typed plain `off` and don't want to
@@ -346,9 +496,15 @@ async function main(argv: string[]): Promise<number> {
346
496
  return await runExposePublicOffAutoDetect({ tailscaleOffOpts: exposeOpts });
347
497
  }
348
498
 
349
- return layer === "public"
350
- ? await exposePublic(action, exposeOpts)
351
- : await exposeTailnet(action, exposeOpts);
499
+ // `--skip-provider-check` fallthrough: pin to today's Tailscale-Funnel
500
+ // default for `expose public up`. Made explicit (rather than letting
501
+ // it tumble through the layer ternary) so the escape-hatch branch is
502
+ // visible at a glance.
503
+ if (layer === "public" && action === "up" && flagExtract.skipProviderCheck) {
504
+ return await exposePublic("up", exposeOpts);
505
+ }
506
+
507
+ return await exposeTailnet(action, exposeOpts);
352
508
  }
353
509
 
354
510
  case "start": {
@@ -381,6 +537,27 @@ async function main(argv: string[]): Promise<number> {
381
537
  return await restart(rest[0]);
382
538
  }
383
539
 
540
+ case "upgrade": {
541
+ if (isHelpFlag(rest[0])) {
542
+ console.log(upgradeHelp());
543
+ return 0;
544
+ }
545
+ const tagExtract = extractTag(rest);
546
+ if (tagExtract.error) {
547
+ console.error(`parachute upgrade: ${tagExtract.error}`);
548
+ return 1;
549
+ }
550
+ const remaining = tagExtract.rest;
551
+ if (remaining.length > 1) {
552
+ console.error(`parachute upgrade: unexpected argument "${remaining[1]}"`);
553
+ console.error("usage: parachute upgrade [<service>] [--tag <name>]");
554
+ return 1;
555
+ }
556
+ const upgradeOpts: Parameters<typeof upgrade>[1] = {};
557
+ if (tagExtract.tag) upgradeOpts.tag = tagExtract.tag;
558
+ return await upgrade(remaining[0], upgradeOpts);
559
+ }
560
+
384
561
  case "logs": {
385
562
  if (isHelpFlag(rest[0])) {
386
563
  console.log(logsHelp());
@@ -389,7 +566,7 @@ async function main(argv: string[]): Promise<number> {
389
566
  const svc = rest[0];
390
567
  if (!svc) {
391
568
  console.error("usage: parachute logs <service> [-f]");
392
- console.error(`services: ${knownServices().join(", ")}`);
569
+ console.error(`services: ${[HUB_SVC, ...knownServices()].join(", ")}`);
393
570
  return 1;
394
571
  }
395
572
  const follow = rest.includes("-f") || rest.includes("--follow");
package/src/clients.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * OAuth client registry. Backs the `/oauth/register` endpoint (RFC 7591
3
+ * Dynamic Client Registration) and the client-lookup side of
4
+ * `/oauth/authorize` and `/oauth/token`.
5
+ *
6
+ * Two flavors:
7
+ * - **Public clients** (PKCE-only): no `client_secret`. Browser-side apps
8
+ * register themselves with one or more `redirect_uris` and rely on PKCE
9
+ * for the auth-code exchange. `client_secret_hash` is NULL for these.
10
+ * - **Confidential clients**: server-side apps. We mint a random
11
+ * `client_secret` on registration, store its sha256 hash, return the
12
+ * plaintext exactly once. The token endpoint enforces client_secret per
13
+ * RFC 6749 §3.2.1 (closes #72).
14
+ *
15
+ * Approval gate (closes #74): every row carries a `status` of `pending` or
16
+ * `approved`. New self-registrations default to `pending`; only registrations
17
+ * that authenticate with an operator token bearing `hub:admin` (the install-
18
+ * time path for first-party modules) land as `approved`. The OAuth flow
19
+ * rejects `pending` clients at `/oauth/authorize` and `/oauth/token`. An
20
+ * operator promotes a pending client via `parachute auth approve-client`.
21
+ */
22
+ import type { Database } from "bun:sqlite";
23
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
24
+
25
+ export type ClientStatus = "pending" | "approved";
26
+
27
+ export interface OAuthClient {
28
+ clientId: string;
29
+ /** SHA-256 hex digest of the client secret. Null for public clients. */
30
+ clientSecretHash: string | null;
31
+ redirectUris: string[];
32
+ scopes: string[];
33
+ clientName: string | null;
34
+ registeredAt: string;
35
+ /** Whether the client may participate in OAuth flows. See file header. */
36
+ status: ClientStatus;
37
+ }
38
+
39
+ export class ClientNotFoundError extends Error {
40
+ constructor(clientId: string) {
41
+ super(`oauth client "${clientId}" is not registered`);
42
+ this.name = "ClientNotFoundError";
43
+ }
44
+ }
45
+
46
+ export class InvalidRedirectUriError extends Error {
47
+ constructor(uri: string) {
48
+ super(`redirect_uri "${uri}" is not registered for this client`);
49
+ this.name = "InvalidRedirectUriError";
50
+ }
51
+ }
52
+
53
+ interface Row {
54
+ client_id: string;
55
+ client_secret_hash: string | null;
56
+ redirect_uris: string;
57
+ scopes: string;
58
+ client_name: string | null;
59
+ registered_at: string;
60
+ status: string;
61
+ }
62
+
63
+ function rowToClient(r: Row): OAuthClient {
64
+ return {
65
+ clientId: r.client_id,
66
+ clientSecretHash: r.client_secret_hash,
67
+ redirectUris: JSON.parse(r.redirect_uris) as string[],
68
+ scopes: r.scopes.split(" ").filter((s) => s.length > 0),
69
+ clientName: r.client_name,
70
+ registeredAt: r.registered_at,
71
+ status: r.status === "approved" ? "approved" : "pending",
72
+ };
73
+ }
74
+
75
+ export interface RegisterClientOpts {
76
+ redirectUris: string[];
77
+ scopes?: string[];
78
+ clientName?: string;
79
+ /** Defaults to public (PKCE-only). Set to true for a server-side client. */
80
+ confidential?: boolean;
81
+ /** Override the generated client_id. Mostly for tests + first-party seeds. */
82
+ clientId?: string;
83
+ /**
84
+ * Approval status to write. Defaults to `approved` — direct callers
85
+ * (tests, install-time first-party seeds) want a row that can OAuth.
86
+ * The public DCR endpoint (`POST /oauth/register`) passes `pending`
87
+ * explicitly so self-served registrations require operator approval
88
+ * before they can run an OAuth flow (closes #74).
89
+ */
90
+ status?: ClientStatus;
91
+ now?: () => Date;
92
+ }
93
+
94
+ export interface RegisteredClient {
95
+ client: OAuthClient;
96
+ /** Plaintext secret for confidential clients. NOT recoverable from the DB. */
97
+ clientSecret: string | null;
98
+ }
99
+
100
+ export function registerClient(db: Database, opts: RegisterClientOpts): RegisteredClient {
101
+ if (opts.redirectUris.length === 0) {
102
+ throw new Error("registerClient: at least one redirect_uri is required");
103
+ }
104
+ for (const uri of opts.redirectUris) {
105
+ if (!isValidRedirectUri(uri)) {
106
+ throw new Error(`registerClient: invalid redirect_uri "${uri}"`);
107
+ }
108
+ }
109
+ const clientId = opts.clientId ?? randomUUID();
110
+ const clientSecret = opts.confidential ? randomBytes(32).toString("base64url") : null;
111
+ const clientSecretHash = clientSecret
112
+ ? createHash("sha256").update(clientSecret).digest("hex")
113
+ : null;
114
+ const registeredAt = (opts.now?.() ?? new Date()).toISOString();
115
+ const scopes = (opts.scopes ?? []).join(" ");
116
+ const status: ClientStatus = opts.status ?? "approved";
117
+ db.prepare(
118
+ `INSERT INTO clients
119
+ (client_id, client_secret_hash, redirect_uris, scopes, client_name, registered_at, status)
120
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
121
+ ).run(
122
+ clientId,
123
+ clientSecretHash,
124
+ JSON.stringify(opts.redirectUris),
125
+ scopes,
126
+ opts.clientName ?? null,
127
+ registeredAt,
128
+ status,
129
+ );
130
+ return {
131
+ client: {
132
+ clientId,
133
+ clientSecretHash,
134
+ redirectUris: opts.redirectUris,
135
+ scopes: opts.scopes ?? [],
136
+ clientName: opts.clientName ?? null,
137
+ registeredAt,
138
+ status,
139
+ },
140
+ clientSecret,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Promote a `pending` client to `approved`. Idempotent — calling on an
146
+ * already-approved row is a no-op. Returns true when the row was found and
147
+ * is now approved (whether by this call or already), false when no such
148
+ * client exists. Used by `parachute auth approve-client`.
149
+ */
150
+ export function approveClient(db: Database, clientId: string): boolean {
151
+ const existing = getClient(db, clientId);
152
+ if (!existing) return false;
153
+ if (existing.status === "approved") return true;
154
+ db.prepare("UPDATE clients SET status = 'approved' WHERE client_id = ?").run(clientId);
155
+ return true;
156
+ }
157
+
158
+ /** List clients filtered by status. Used by `parachute auth pending-clients`. */
159
+ export function listClientsByStatus(db: Database, status: ClientStatus): OAuthClient[] {
160
+ const rows = db
161
+ .query<Row, [string]>("SELECT * FROM clients WHERE status = ? ORDER BY registered_at")
162
+ .all(status);
163
+ return rows.map(rowToClient);
164
+ }
165
+
166
+ export function getClient(db: Database, clientId: string): OAuthClient | null {
167
+ const row = db.query<Row, [string]>("SELECT * FROM clients WHERE client_id = ?").get(clientId);
168
+ return row ? rowToClient(row) : null;
169
+ }
170
+
171
+ /**
172
+ * Returns the registered redirect URI matching `candidate` exactly, or throws.
173
+ * RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
174
+ * loose comparison) — anything looser is an open-redirect waiting to happen.
175
+ */
176
+ export function requireRegisteredRedirectUri(client: OAuthClient, candidate: string): string {
177
+ if (!client.redirectUris.includes(candidate)) {
178
+ throw new InvalidRedirectUriError(candidate);
179
+ }
180
+ return candidate;
181
+ }
182
+
183
+ export function verifyClientSecret(client: OAuthClient, presented: string): boolean {
184
+ if (!client.clientSecretHash) return false;
185
+ const presentedHash = createHash("sha256").update(presented).digest("hex");
186
+ return timingSafeEqualHex(client.clientSecretHash, presentedHash);
187
+ }
188
+
189
+ function timingSafeEqualHex(a: string, b: string): boolean {
190
+ if (a.length !== b.length) return false;
191
+ let diff = 0;
192
+ for (let i = 0; i < a.length; i++) {
193
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
194
+ }
195
+ return diff === 0;
196
+ }
197
+
198
+ /**
199
+ * Light validation — refuses obviously-wrong shapes (relative paths, javascript:
200
+ * URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
201
+ */
202
+ export function isValidRedirectUri(uri: string): boolean {
203
+ try {
204
+ const u = new URL(uri);
205
+ if (u.protocol === "javascript:" || u.protocol === "data:") return false;
206
+ return u.protocol === "http:" || u.protocol === "https:";
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
@@ -3,8 +3,30 @@ import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
5
  export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
6
- export const CLOUDFLARED_CONFIG_PATH = join(CLOUDFLARED_DIR, "config.yml");
7
- export const CLOUDFLARED_LOG_PATH = join(CLOUDFLARED_DIR, "cloudflared.log");
6
+
7
+ export const DEFAULT_TUNNEL_NAME = "parachute";
8
+
9
+ /**
10
+ * Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
11
+ * under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
12
+ * box don't trample each other's config.yml or interleave log lines.
13
+ *
14
+ * The default tunnel ("parachute") lives at
15
+ * `~/.parachute/cloudflared/parachute/{config.yml,cloudflared.log}` — a
16
+ * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
17
+ * Re-running `parachute expose public --cloudflare` regenerates the file
18
+ * at the new path; the legacy file is left in place but unused.
19
+ */
20
+ export function cloudflaredPathsFor(tunnelName: string): {
21
+ configPath: string;
22
+ logPath: string;
23
+ } {
24
+ const dir = join(CLOUDFLARED_DIR, tunnelName);
25
+ return {
26
+ configPath: join(dir, "config.yml"),
27
+ logPath: join(dir, "cloudflared.log"),
28
+ };
29
+ }
8
30
 
9
31
  export interface TunnelConfigOpts {
10
32
  tunnelUuid: string;
@@ -48,10 +70,7 @@ ingress:
48
70
  `;
49
71
  }
50
72
 
51
- export function writeConfig(
52
- opts: TunnelConfigOpts,
53
- configPath: string = CLOUDFLARED_CONFIG_PATH,
54
- ): string {
73
+ export function writeConfig(opts: TunnelConfigOpts, configPath: string): string {
55
74
  mkdirSync(dirname(configPath), { recursive: true });
56
75
  writeFileSync(configPath, renderConfig(opts));
57
76
  return configPath;