@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
@@ -31,7 +31,6 @@ import {
31
31
  buildWellKnown,
32
32
  isVaultEntry,
33
33
  shortName,
34
- vaultInstanceName,
35
34
  writeWellKnownFile,
36
35
  } from "../well-known.ts";
37
36
  import { restart } from "./lifecycle.ts";
@@ -59,7 +58,7 @@ export interface ExposeOpts {
59
58
  statePath?: string;
60
59
  wellKnownPath?: string;
61
60
  hubPath?: string;
62
- /** Directory holding hub.html + parachute.json (passed to the hub server). */
61
+ /** Directory holding hub.html (passed to the hub server). */
63
62
  wellKnownDir?: string;
64
63
  configDir?: string;
65
64
  port?: number;
@@ -105,11 +104,14 @@ export interface ExposeOpts {
105
104
  const HUB_DEPENDENT_SHORTS = ["vault"] as const;
106
105
 
107
106
  /**
108
- * OAuth paths the hub fronts on behalf of vault (Phase 0: vault implements
109
- * OAuth, hub owns the public URL). The mount path is what clients see; the
110
- * target tail is what vault expects. tailscale strips the mount before
111
- * forwarding, so the target must include vault's `/vault/<name>` prefix to
112
- * land at the right handler.
107
+ * OAuth paths the hub serves natively. The mount path is what clients see;
108
+ * the target is the hub's loopback origin (where `hub-server.ts` is
109
+ * listening). tailscale strips the mount before forwarding, so the target
110
+ * must include the same path so the hub-server router sees the full URL.
111
+ *
112
+ * Pre-cli#58 (PR (c)) these were proxied to vault's `/vault/<name>/oauth/*`
113
+ * handlers; after PR (c) the hub IS the OAuth IdP and vault validates
114
+ * hub-issued JWTs (vault#169).
113
115
  */
114
116
  const OAUTH_PATHS = [
115
117
  "/.well-known/oauth-authorization-server",
@@ -119,12 +121,17 @@ const OAUTH_PATHS = [
119
121
  ] as const;
120
122
 
121
123
  /**
122
- * Single-vault launch assumption: find the first `parachute-vault` entry.
123
- * Multi-vault OAuth routing is Phase 2+ (design note open-question #4).
124
+ * Single tailscale serve mount that catches every `/vault/<name>/...` request
125
+ * and routes it through the hub. The hub then reads services.json on each
126
+ * request to pick the right vault backend (#144). Consolidating to one mount
127
+ * means `parachute vault create techne` is reachable on the tailnet without
128
+ * re-running `parachute expose` — only the hub-internal lookup needs to know
129
+ * about the new path.
130
+ *
131
+ * Trailing slash distinguishes the mount from `/vaults` (the create-vault
132
+ * POST endpoint, an exact-match on hub).
124
133
  */
125
- function primaryVault(services: readonly ServiceEntry[]): ServiceEntry | undefined {
126
- return services.find((s) => isVaultEntry(s));
127
- }
134
+ const VAULT_MOUNT = "/vault/";
128
135
 
129
136
  /**
130
137
  * Remap legacy `paths: ["/"]` entries to `/<shortname>` so they don't collide
@@ -212,7 +219,15 @@ function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeE
212
219
  target: serviceProxyTarget(hubPort, HUB_MOUNT),
213
220
  service: "hub",
214
221
  });
222
+ let anyVault = false;
215
223
  for (const s of services) {
224
+ if (isVaultEntry(s)) {
225
+ // Vault paths route through the single `/vault/` → hub mount below so
226
+ // `parachute vault create <name>` is reachable on the tailnet without
227
+ // a re-expose. Hub does the per-request services.json lookup (#144).
228
+ anyVault = true;
229
+ continue;
230
+ }
216
231
  const mount = s.paths[0] ?? `/${shortName(s.name)}`;
217
232
  entries.push({
218
233
  kind: "proxy",
@@ -221,6 +236,14 @@ function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeE
221
236
  service: s.name,
222
237
  });
223
238
  }
239
+ if (anyVault) {
240
+ entries.push({
241
+ kind: "proxy",
242
+ mount: VAULT_MOUNT,
243
+ target: serviceProxyTarget(hubPort, VAULT_MOUNT),
244
+ service: "vault",
245
+ });
246
+ }
224
247
  entries.push({
225
248
  kind: "proxy",
226
249
  mount: WELL_KNOWN_MOUNT,
@@ -228,21 +251,17 @@ function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeE
228
251
  service: "well-known",
229
252
  });
230
253
 
231
- // Phase 0 OAuth seam: hub origin owns the public OAuth URLs; vault owns
232
- // the implementation. When vault is installed, mount the four endpoints
233
- // at the hub origin and proxy them into vault's `/vault/<name>/oauth/*`.
234
- const vault = primaryVault(services);
235
- if (vault) {
236
- const vaultMount = vault.paths[0] ?? `/vault/${vaultInstanceName(vault)}`;
237
- const vaultBase = vaultMount.replace(/\/$/, "");
238
- for (const oauthPath of OAUTH_PATHS) {
239
- entries.push({
240
- kind: "proxy",
241
- mount: oauthPath,
242
- target: `http://127.0.0.1:${vault.port}${vaultBase}${oauthPath}`,
243
- service: `${vault.name}:oauth`,
244
- });
245
- }
254
+ // The hub is the OAuth IdP mount the four endpoints at the canonical
255
+ // origin and proxy them to the hub's loopback. tailscale strips the mount
256
+ // before forwarding, so the target keeps the same path (matches the
257
+ // `serviceProxyTarget` rule of thumb in the doc above).
258
+ for (const oauthPath of OAUTH_PATHS) {
259
+ entries.push({
260
+ kind: "proxy",
261
+ mount: oauthPath,
262
+ target: serviceProxyTarget(hubPort, oauthPath),
263
+ service: "hub:oauth",
264
+ });
246
265
  }
247
266
  return entries;
248
267
  }
@@ -372,12 +391,22 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
372
391
  );
373
392
  }
374
393
 
394
+ // Kept for manual debugging / inspection only — the hub server now builds
395
+ // /.well-known/parachute.json dynamically from services.json at request time
396
+ // (#135), so this on-disk copy is no longer load-bearing for any consumer.
375
397
  const wellKnownDoc = buildWellKnown({ services, canonicalOrigin });
376
398
  writeWellKnownFile(wellKnownDoc, wellKnownFilePath);
377
399
  log(`Wrote ${wellKnownFilePath}`);
378
400
  writeHubFile(hubFilePath);
379
401
  log(`Wrote ${hubFilePath}`);
380
402
 
403
+ // Resolve the public hub origin before spawning the hub server — it gets
404
+ // baked into the OAuth `iss` claim via the `--issuer` flag. Falling back to
405
+ // the request origin would put `http://127.0.0.1:<port>` in tokens, which
406
+ // any client following RFC 8414 would reject.
407
+ const hubOrigin =
408
+ deriveHubOrigin({ override: opts.hubOrigin, exposeFqdn: fqdn }) ?? canonicalOrigin;
409
+
381
410
  let hubPort: number;
382
411
  if (opts.skipHub) {
383
412
  const existing = readHubPort(configDir);
@@ -391,6 +420,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
391
420
  ...(opts.hubEnsureOpts ?? {}),
392
421
  configDir,
393
422
  wellKnownDir,
423
+ issuer: hubOrigin,
394
424
  log,
395
425
  });
396
426
  hubPort = hub.port;
@@ -415,8 +445,6 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
415
445
  return code;
416
446
  }
417
447
 
418
- const hubOrigin =
419
- deriveHubOrigin({ override: opts.hubOrigin, exposeFqdn: fqdn }) ?? canonicalOrigin;
420
448
  const state: ExposeState = {
421
449
  version: 1,
422
450
  layer,
@@ -433,13 +461,14 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
433
461
  if (layer === "public") {
434
462
  log(`✓ Public exposure active (Funnel). Open: ${canonicalOrigin}/`);
435
463
  log(" This node is reachable from the public internet.");
464
+ log(
465
+ " Note: public is exploratory. Tailnet is the supported exposure shape today; the hub's OAuth + scope work targets tailnet first. Prefer `parachute expose tailnet` unless you specifically need a public URL.",
466
+ );
436
467
  } else {
437
468
  log(`✓ Tailnet exposure active. Open: ${canonicalOrigin}/`);
438
469
  }
439
470
  log(` Discovery: ${canonicalOrigin}${WELL_KNOWN_MOUNT}`);
440
- if (primaryVault(services)) {
441
- log(` OAuth issuer: ${hubOrigin}`);
442
- }
471
+ log(` OAuth issuer: ${hubOrigin}`);
443
472
 
444
473
  // Auto-restart services that cache the hub origin. Aaron hit this on launch
445
474
  // day: after `expose public` first-run, vault kept its stale (loopback)
@@ -494,6 +523,8 @@ export async function exposeOff(layer: ExposeLayer, opts: ExposeOpts = {}): Prom
494
523
  }
495
524
 
496
525
  clearExposeState(statePath);
526
+ // Pair to the debug-only write at expose-up — clean up the inspection artifact
527
+ // on teardown so it doesn't outlive the layer it described.
497
528
  if (existsSync(wellKnownFilePath)) {
498
529
  unlinkSync(wellKnownFilePath);
499
530
  }