@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
@@ -0,0 +1,429 @@
1
+ /**
2
+ * `parachute upgrade [<service>]` — pull / re-install / restart in one step.
3
+ *
4
+ * Detects whether the target service is bun-linked from a local checkout (the
5
+ * dev-mode install shape) or npm-installed from a published artifact, then
6
+ * does the right thing for each:
7
+ *
8
+ * bun-linked: git -C <checkout> pull --ff-only;
9
+ * bun install --frozen-lockfile (if package.json/bun.lock changed);
10
+ * bun run build (frontend kind, if `build` script exists);
11
+ * parachute restart <svc>.
12
+ *
13
+ * npm-installed: bun add -g <pkg>@<tag>; parachute restart <svc>.
14
+ *
15
+ * Skip-restart heuristics: bun-linked path skips when HEAD is unchanged after
16
+ * pull; npm path skips when the installed package.json `version` is unchanged
17
+ * after `bun add -g`. Idempotent — re-running the command on an up-to-date
18
+ * install is a fast no-op rather than a needless restart.
19
+ *
20
+ * Refuses to operate on a dirty git working tree. The whole point of the
21
+ * dev-mode flow is to make the operator's checkout a first-class artifact;
22
+ * blowing past their uncommitted changes with `git pull` is exactly what we
23
+ * shouldn't do. They can stash and re-run.
24
+ *
25
+ * Detection of "is this a git checkout?" goes through git itself (`git
26
+ * rev-parse --is-inside-work-tree`). A bare bun-link symlink isn't enough —
27
+ * `bun add -g <abspath>` produces a non-symlinked install dir whose realpath
28
+ * is still the operator's checkout, and we want to treat that the same as a
29
+ * `bun link` install. The git-repo test is the right load-bearing signal.
30
+ *
31
+ * The npm-install branch reads the package.json `version` before and after
32
+ * `bun add -g` to detect "already at latest" (the dist-tag didn't move).
33
+ * Doing this avoids an unnecessary restart on a stable channel — a lot
34
+ * cheaper than re-spawning a daemon that's already running the right code.
35
+ */
36
+
37
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { dirname, join } from "node:path";
40
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
41
+ import { ModuleManifestError } from "../module-manifest.ts";
42
+ import {
43
+ type ServiceSpec,
44
+ getSpec,
45
+ getSpecFromInstallDir,
46
+ knownServices,
47
+ shortNameForManifest,
48
+ } from "../service-spec.ts";
49
+ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
50
+ import { type LifecycleOpts, restart as lifecycleRestart } from "./lifecycle.ts";
51
+
52
+ export interface UpgradeRunner {
53
+ /** Run a command, inheriting stdio. Returns the child's exit code. */
54
+ run(cmd: readonly string[], opts?: { cwd?: string }): Promise<number>;
55
+ /** Run a command, capturing combined stdout+stderr. Used for git rev-parse / status / diff. */
56
+ capture(
57
+ cmd: readonly string[],
58
+ opts?: { cwd?: string },
59
+ ): Promise<{ code: number; stdout: string }>;
60
+ }
61
+
62
+ export const defaultRunner: UpgradeRunner = {
63
+ async run(cmd, opts) {
64
+ const proc = Bun.spawn([...cmd], {
65
+ cwd: opts?.cwd,
66
+ stdio: ["inherit", "inherit", "inherit"],
67
+ });
68
+ return await proc.exited;
69
+ },
70
+ async capture(cmd, opts) {
71
+ const proc = Bun.spawn([...cmd], {
72
+ cwd: opts?.cwd,
73
+ stdout: "pipe",
74
+ stderr: "pipe",
75
+ });
76
+ const [stdout, stderr] = await Promise.all([
77
+ new Response(proc.stdout).text(),
78
+ new Response(proc.stderr).text(),
79
+ ]);
80
+ const code = await proc.exited;
81
+ return { code, stdout: stdout + stderr };
82
+ },
83
+ };
84
+
85
+ export interface UpgradeOpts {
86
+ runner?: UpgradeRunner;
87
+ manifestPath?: string;
88
+ configDir?: string;
89
+ log?: (line: string) => void;
90
+ /**
91
+ * Override how we locate a package's bun-globals install. Defaults to
92
+ * scanning bun's standard global node_modules prefixes for
93
+ * `<prefix>/<pkg>/package.json`. Tests inject a deterministic stub that
94
+ * points at a tmp dir.
95
+ */
96
+ findGlobalInstall?: (pkg: string) => string | null;
97
+ /**
98
+ * Override the lifecycle restart call. Production proxies to
99
+ * `lifecycle.restart(svc, opts)`; tests inject `async () => 0` so the
100
+ * upgrade flow can be exercised without spawning real children.
101
+ */
102
+ restartFn?: (svc: string, opts: LifecycleOpts) => Promise<number>;
103
+ /** npm dist-tag for the npm-installed branch. Defaults to `latest`. Ignored when bun-linked. */
104
+ tag?: string;
105
+ }
106
+
107
+ interface ResolvedTarget {
108
+ short: string;
109
+ entry: ServiceEntry;
110
+ spec: ServiceSpec | undefined;
111
+ packageName: string;
112
+ }
113
+
114
+ interface Resolved {
115
+ runner: UpgradeRunner;
116
+ manifestPath: string;
117
+ configDir: string;
118
+ log: (line: string) => void;
119
+ findGlobalInstall: (pkg: string) => string | null;
120
+ restartFn: (svc: string, opts: LifecycleOpts) => Promise<number>;
121
+ tag: string;
122
+ }
123
+
124
+ function bunGlobalPrefixes(): string[] {
125
+ const prefixes: string[] = [];
126
+ const fromEnv = process.env.BUN_INSTALL;
127
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
128
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
129
+ return prefixes;
130
+ }
131
+
132
+ function defaultFindGlobalInstall(pkg: string): string | null {
133
+ for (const prefix of bunGlobalPrefixes()) {
134
+ const pkgJsonPath = join(prefix, ...pkg.split("/"), "package.json");
135
+ if (existsSync(pkgJsonPath)) return pkgJsonPath;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function resolve(opts: UpgradeOpts): Resolved {
141
+ return {
142
+ runner: opts.runner ?? defaultRunner,
143
+ manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
144
+ configDir: opts.configDir ?? CONFIG_DIR,
145
+ log: opts.log ?? ((line) => console.log(line)),
146
+ findGlobalInstall: opts.findGlobalInstall ?? defaultFindGlobalInstall,
147
+ restartFn: opts.restartFn ?? ((svc, lifecycleOpts) => lifecycleRestart(svc, lifecycleOpts)),
148
+ tag: opts.tag ?? "latest",
149
+ };
150
+ }
151
+
152
+ async function resolveTargets(
153
+ svc: string | undefined,
154
+ manifestPath: string,
155
+ ): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
156
+ const manifest = readManifest(manifestPath);
157
+ if (manifest.services.length === 0) {
158
+ return { error: "No services installed yet. Try: parachute install <service>" };
159
+ }
160
+
161
+ if (svc !== undefined) {
162
+ const firstPartySpec = getSpec(svc);
163
+ if (firstPartySpec) {
164
+ const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
165
+ if (!entry) {
166
+ return { error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.` };
167
+ }
168
+ return {
169
+ targets: [{ short: svc, entry, spec: firstPartySpec, packageName: firstPartySpec.package }],
170
+ };
171
+ }
172
+ const entry = manifest.services.find((s) => s.name === svc);
173
+ if (entry?.installDir) {
174
+ try {
175
+ const spec = (await getSpecFromInstallDir(entry.installDir, entry.name)) ?? undefined;
176
+ return {
177
+ targets: [{ short: svc, entry, spec, packageName: spec?.package ?? entry.name }],
178
+ };
179
+ } catch (err) {
180
+ if (err instanceof ModuleManifestError) {
181
+ return { error: `${svc}: invalid module.json — ${err.message}` };
182
+ }
183
+ throw err;
184
+ }
185
+ }
186
+ return { error: `unknown service "${svc}". known: ${knownServices().join(", ")}` };
187
+ }
188
+
189
+ const targets: ResolvedTarget[] = [];
190
+ for (const entry of manifest.services) {
191
+ const short = shortNameForManifest(entry.name);
192
+ if (short) {
193
+ const spec = getSpec(short);
194
+ if (spec) targets.push({ short, entry, spec, packageName: spec.package });
195
+ continue;
196
+ }
197
+ if (entry.installDir) {
198
+ try {
199
+ const spec = (await getSpecFromInstallDir(entry.installDir, entry.name)) ?? undefined;
200
+ targets.push({
201
+ short: entry.name,
202
+ entry,
203
+ spec,
204
+ packageName: spec?.package ?? entry.name,
205
+ });
206
+ } catch {
207
+ // Malformed third-party manifest — skip silently here; lifecycle/install
208
+ // surface that error in their own paths.
209
+ }
210
+ }
211
+ }
212
+ if (targets.length === 0) return { error: "No upgradeable services in services.json." };
213
+ return { targets };
214
+ }
215
+
216
+ /**
217
+ * Realpath the package.json in bun's globals to find where the source
218
+ * actually lives. For npm installs this stays inside bun globals; for `bun
219
+ * link` and `bun add -g <abspath>` it follows out to the operator's checkout.
220
+ */
221
+ function resolveSourceDir(
222
+ packageName: string,
223
+ findGlobalInstall: (pkg: string) => string | null,
224
+ fallbackInstallDir: string | undefined,
225
+ ): string | null {
226
+ const fromGlobals = findGlobalInstall(packageName);
227
+ if (fromGlobals) {
228
+ try {
229
+ return dirname(realpathSync(fromGlobals));
230
+ } catch {
231
+ // fall through to manifest-recorded installDir
232
+ }
233
+ }
234
+ if (fallbackInstallDir) {
235
+ try {
236
+ return realpathSync(fallbackInstallDir);
237
+ } catch {
238
+ return fallbackInstallDir;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+
244
+ async function isGitCheckout(dir: string, runner: UpgradeRunner): Promise<boolean> {
245
+ const { code } = await runner.capture(["git", "rev-parse", "--is-inside-work-tree"], {
246
+ cwd: dir,
247
+ });
248
+ return code === 0;
249
+ }
250
+
251
+ async function readGitHead(
252
+ dir: string,
253
+ runner: UpgradeRunner,
254
+ ): Promise<{ code: number; sha: string }> {
255
+ const { code, stdout } = await runner.capture(["git", "rev-parse", "HEAD"], { cwd: dir });
256
+ return { code, sha: stdout.trim() };
257
+ }
258
+
259
+ async function listChangedFiles(
260
+ dir: string,
261
+ before: string,
262
+ after: string,
263
+ runner: UpgradeRunner,
264
+ ): Promise<string[]> {
265
+ if (before === after) return [];
266
+ const { code, stdout } = await runner.capture(
267
+ ["git", "diff", "--name-only", `${before}..${after}`],
268
+ { cwd: dir },
269
+ );
270
+ if (code !== 0) return [];
271
+ return stdout.trim().split("\n").filter(Boolean);
272
+ }
273
+
274
+ function packageHasScript(pkgJsonPath: string, name: string): boolean {
275
+ try {
276
+ const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
277
+ return Boolean(json?.scripts && typeof json.scripts[name] === "string");
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ function readPackageVersion(pkgJsonPath: string): string | null {
284
+ try {
285
+ const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
286
+ return typeof json?.version === "string" ? json.version : null;
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
292
+ async function upgradeLinked(
293
+ target: ResolvedTarget,
294
+ sourceDir: string,
295
+ r: Resolved,
296
+ ): Promise<number> {
297
+ r.log(`${target.short}: bun-linked checkout at ${sourceDir}`);
298
+
299
+ const status = await r.runner.capture(["git", "status", "--porcelain"], { cwd: sourceDir });
300
+ if (status.code !== 0) {
301
+ r.log(`✗ ${target.short}: git status failed in ${sourceDir}`);
302
+ return status.code;
303
+ }
304
+ if (status.stdout.trim().length > 0) {
305
+ r.log(
306
+ `✗ ${target.short}: dirty working tree at ${sourceDir} — commit or stash first, then re-run.`,
307
+ );
308
+ return 1;
309
+ }
310
+
311
+ const before = await readGitHead(sourceDir, r.runner);
312
+ if (before.code !== 0) {
313
+ r.log(`✗ ${target.short}: failed to read HEAD in ${sourceDir}`);
314
+ return before.code;
315
+ }
316
+
317
+ r.log(`${target.short}: git pull --ff-only`);
318
+ const pull = await r.runner.run(["git", "pull", "--ff-only"], { cwd: sourceDir });
319
+ if (pull !== 0) {
320
+ r.log(`✗ ${target.short}: git pull --ff-only failed (exit ${pull}). Resolve and retry.`);
321
+ return pull;
322
+ }
323
+
324
+ const after = await readGitHead(sourceDir, r.runner);
325
+ if (after.code !== 0) {
326
+ r.log(`✗ ${target.short}: failed to read HEAD post-pull`);
327
+ return after.code;
328
+ }
329
+
330
+ if (before.sha === after.sha) {
331
+ r.log(`${target.short}: already up to date (${before.sha.slice(0, 7)}). Skipping restart.`);
332
+ return 0;
333
+ }
334
+
335
+ const changed = await listChangedFiles(sourceDir, before.sha, after.sha, r.runner);
336
+ const depsChanged =
337
+ changed.includes("package.json") ||
338
+ changed.includes("bun.lock") ||
339
+ changed.includes("bun.lockb");
340
+ if (depsChanged) {
341
+ r.log(`${target.short}: package.json/bun.lock changed — bun install --frozen-lockfile`);
342
+ const inst = await r.runner.run(["bun", "install", "--frozen-lockfile"], { cwd: sourceDir });
343
+ if (inst !== 0) {
344
+ r.log(`✗ ${target.short}: bun install failed (exit ${inst})`);
345
+ return inst;
346
+ }
347
+ }
348
+
349
+ if (target.spec?.kind === "frontend") {
350
+ const pkgJsonPath = join(sourceDir, "package.json");
351
+ if (packageHasScript(pkgJsonPath, "build")) {
352
+ r.log(`${target.short}: bun run build`);
353
+ const build = await r.runner.run(["bun", "run", "build"], { cwd: sourceDir });
354
+ if (build !== 0) {
355
+ r.log(`✗ ${target.short}: bun run build failed (exit ${build})`);
356
+ return build;
357
+ }
358
+ }
359
+ }
360
+
361
+ r.log(`${target.short}: ${before.sha.slice(0, 7)} → ${after.sha.slice(0, 7)}; restarting…`);
362
+ return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
363
+ }
364
+
365
+ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved): Promise<number> {
366
+ r.log(`${target.short}: npm-installed (${sourceDir})`);
367
+ const beforeVersion = readPackageVersion(join(sourceDir, "package.json"));
368
+ const spec = `${target.packageName}@${r.tag}`;
369
+ r.log(`${target.short}: bun add -g ${spec}`);
370
+ const code = await r.runner.run(["bun", "add", "-g", spec]);
371
+ if (code !== 0) {
372
+ r.log(`✗ ${target.short}: bun add -g failed (exit ${code})`);
373
+ return code;
374
+ }
375
+
376
+ const afterVersion = readPackageVersion(join(sourceDir, "package.json"));
377
+ if (beforeVersion && afterVersion && beforeVersion === afterVersion) {
378
+ r.log(`${target.short}: already at ${afterVersion}. Skipping restart.`);
379
+ return 0;
380
+ }
381
+
382
+ r.log(`${target.short}: ${beforeVersion ?? "?"} → ${afterVersion ?? "?"}; restarting…`);
383
+ return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
384
+ }
385
+
386
+ async function upgradeOne(target: ResolvedTarget, r: Resolved): Promise<number> {
387
+ const sourceDir = resolveSourceDir(
388
+ target.packageName,
389
+ r.findGlobalInstall,
390
+ target.entry.installDir,
391
+ );
392
+ if (!sourceDir) {
393
+ r.log(
394
+ `✗ ${target.short}: can't locate install dir for ${target.packageName}. Try \`parachute install ${target.short}\` first.`,
395
+ );
396
+ return 1;
397
+ }
398
+ if (!existsSync(sourceDir)) {
399
+ r.log(`✗ ${target.short}: install dir ${sourceDir} does not exist.`);
400
+ return 1;
401
+ }
402
+
403
+ if (await isGitCheckout(sourceDir, r.runner)) {
404
+ return await upgradeLinked(target, sourceDir, r);
405
+ }
406
+ return await upgradeNpm(target, sourceDir, r);
407
+ }
408
+
409
+ /**
410
+ * Sweep one or all installed services through the upgrade flow. Returns 0
411
+ * when every target succeeds; non-zero is the exit code of the first failure
412
+ * (subsequent targets still run — partial success is preferable to halting
413
+ * mid-sweep on a flake).
414
+ */
415
+ export async function upgrade(svc: string | undefined, opts: UpgradeOpts = {}): Promise<number> {
416
+ const r = resolve(opts);
417
+ const picked = await resolveTargets(svc, r.manifestPath);
418
+ if ("error" in picked) {
419
+ r.log(picked.error);
420
+ return 1;
421
+ }
422
+
423
+ let firstFailure = 0;
424
+ for (const target of picked.targets) {
425
+ const code = await upgradeOne(target, r);
426
+ if (code !== 0 && firstFailure === 0) firstFailure = code;
427
+ }
428
+ return firstFailure;
429
+ }
package/src/csrf.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * CSRF protection for state-changing admin POSTs (login, consent, and any
3
+ * future admin form mounted off `/`).
4
+ *
5
+ * Pattern: double-submit cookie. On every GET that renders a form, we ensure
6
+ * a `parachute_hub_csrf` cookie exists (lazily generated, then reused for the
7
+ * cookie's lifetime) and embed the same value as a hidden `__csrf` input in
8
+ * the form. On POST, we compare the form-submitted token to the cookie value
9
+ * via constant-time compare; mismatch = 400 Bad Request. We pick 400 over 403
10
+ * because the failure mode is a malformed/stale form (the operator's tab sat
11
+ * past cookie expiry, two tabs raced, or the form was hand-rolled), not an
12
+ * authorization failure — they're already authenticated; the *form* is what
13
+ * the server can't accept. All callers (admin login, admin config, OAuth
14
+ * authorize) agree on 400.
15
+ *
16
+ * Why this and not session-bound tokens? Login forms are submitted *before*
17
+ * a session exists, so a session-bound CSRF would need a separate "pre-login"
18
+ * track anyway. Double-submit is uniform across both — same helper handles
19
+ * pre-login and post-login forms, and it works no matter how many tabs the
20
+ * operator has open.
21
+ *
22
+ * The cookie is HttpOnly (the form doesn't need JS to read it; the server
23
+ * embeds the value at render time), SameSite=Lax (matches the session
24
+ * cookie), Secure, and Path=/ (covers every admin form, OAuth or otherwise).
25
+ *
26
+ * Token entropy: 32 random bytes, base64url-encoded — same shape as session
27
+ * IDs. No HMAC needed: the value is opaque to the client and only ever
28
+ * compared to itself across the cookie/form boundary.
29
+ */
30
+ import { randomBytes, timingSafeEqual } from "node:crypto";
31
+
32
+ export const CSRF_COOKIE_NAME = "parachute_hub_csrf";
33
+ export const CSRF_FIELD_NAME = "__csrf";
34
+ /** 30 days. Cookie outlives the 24h session by design — closing the OAuth
35
+ * tab and reopening it later shouldn't force a re-mint of the CSRF token. */
36
+ export const CSRF_TTL_SECONDS = 30 * 24 * 60 * 60;
37
+
38
+ export function generateCsrfToken(): string {
39
+ return randomBytes(32).toString("base64url");
40
+ }
41
+
42
+ export function buildCsrfCookie(token: string): string {
43
+ return [
44
+ `${CSRF_COOKIE_NAME}=${token}`,
45
+ "HttpOnly",
46
+ "Secure",
47
+ "SameSite=Lax",
48
+ "Path=/",
49
+ `Max-Age=${CSRF_TTL_SECONDS}`,
50
+ ].join("; ");
51
+ }
52
+
53
+ export function parseCsrfCookie(cookieHeader: string | null): string | null {
54
+ if (!cookieHeader) return null;
55
+ for (const part of cookieHeader.split(";")) {
56
+ const [name, ...rest] = part.trim().split("=");
57
+ if (name === CSRF_COOKIE_NAME) return rest.join("=");
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export interface EnsuredCsrf {
63
+ token: string;
64
+ /** Set when the caller must include this Set-Cookie on the response. */
65
+ setCookie?: string;
66
+ }
67
+
68
+ /**
69
+ * Ensure the request carries a CSRF token cookie; mint and return one if not.
70
+ * Callers embed `result.token` in the rendered form and attach
71
+ * `result.setCookie` (if defined) to the response.
72
+ */
73
+ export function ensureCsrfToken(req: Request): EnsuredCsrf {
74
+ const existing = parseCsrfCookie(req.headers.get("cookie"));
75
+ if (existing && existing.length > 0) return { token: existing };
76
+ const token = generateCsrfToken();
77
+ return { token, setCookie: buildCsrfCookie(token) };
78
+ }
79
+
80
+ /**
81
+ * Verify that a form-submitted CSRF token matches the cookie token via
82
+ * constant-time compare. Both must be present and equal.
83
+ */
84
+ export function verifyCsrfToken(req: Request, formToken: string | null): boolean {
85
+ const cookieToken = parseCsrfCookie(req.headers.get("cookie"));
86
+ if (!cookieToken || !formToken) return false;
87
+ if (cookieToken.length !== formToken.length) return false;
88
+ try {
89
+ return timingSafeEqual(Buffer.from(cookieToken), Buffer.from(formToken));
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ export function renderCsrfHiddenInput(token: string): string {
96
+ return `<input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(token)}" />`;
97
+ }
98
+
99
+ function escapeAttr(s: string): string {
100
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
101
+ }
package/src/grants.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Grants — record of "user U has approved scope-set S for client C".
3
+ *
4
+ * Closes #75. The OAuth consent screen is UX, not protocol — RFC 6749 §3
5
+ * doesn't mandate it. Once a user has approved a set of scopes for a given
6
+ * client, re-running the same flow with the same (or a subset of) scopes
7
+ * shouldn't show consent again. Token-endpoint scope validation still gates
8
+ * actual issuance, so this is purely about not re-asking the human.
9
+ *
10
+ * Storage: one row per (user_id, client_id) in the `grants` table (created
11
+ * in hub-db migration v3). The `scopes` column is a space-separated set of
12
+ * every scope the user has ever approved for this client — recording is
13
+ * UNION semantics, not overwrite. That way, a user who approved [a, b, c]
14
+ * once and later approves only [a, b] for an incremental flow doesn't lose
15
+ * their c grant; the next flow asking [a, c] still skips consent.
16
+ *
17
+ * Skip rule: a flow may skip consent iff every requested scope is already
18
+ * in the grant's set. A strict superset (the client wants something new)
19
+ * shows the consent screen with the full requested set so the user is
20
+ * approving the new addition explicitly. A strict subset skips.
21
+ *
22
+ * Re-registered clients have a fresh client_id, so grants are not carried
23
+ * across — a re-registered app must earn consent again. That's by design:
24
+ * if the client_id changed, the operator can't tell from the URL whether
25
+ * it's the same app or an impostor.
26
+ */
27
+
28
+ import type { Database } from "bun:sqlite";
29
+
30
+ export interface Grant {
31
+ userId: string;
32
+ clientId: string;
33
+ scopes: string[];
34
+ grantedAt: string;
35
+ }
36
+
37
+ interface GrantRow {
38
+ user_id: string;
39
+ client_id: string;
40
+ scopes: string;
41
+ granted_at: string;
42
+ }
43
+
44
+ function rowToGrant(row: GrantRow): Grant {
45
+ return {
46
+ userId: row.user_id,
47
+ clientId: row.client_id,
48
+ scopes: row.scopes.split(" ").filter((s) => s.length > 0),
49
+ grantedAt: row.granted_at,
50
+ };
51
+ }
52
+
53
+ /** Look up the grant for (user, client). Returns null when none exists. */
54
+ export function findGrant(db: Database, userId: string, clientId: string): Grant | null {
55
+ const row = db
56
+ .prepare(
57
+ "SELECT user_id, client_id, scopes, granted_at FROM grants WHERE user_id = ? AND client_id = ?",
58
+ )
59
+ .get(userId, clientId) as GrantRow | undefined;
60
+ return row ? rowToGrant(row) : null;
61
+ }
62
+
63
+ /**
64
+ * Record a consent approval. Merges `newScopes` into any existing grant for
65
+ * (user, client) — UNION semantics — and bumps `granted_at` to `now`. Empty
66
+ * `newScopes` is a no-op (we don't want to insert empty rows).
67
+ */
68
+ export function recordGrant(
69
+ db: Database,
70
+ userId: string,
71
+ clientId: string,
72
+ newScopes: readonly string[],
73
+ now: Date = new Date(),
74
+ ): Grant {
75
+ // Wrapped in a transaction so the read-merge-write is atomic. Without
76
+ // this, two concurrent consents for the same (user, client) could both
77
+ // SELECT the same prior row and then race to INSERT OR REPLACE, with the
78
+ // later writer's UNION missing scopes the earlier writer added.
79
+ return db.transaction(() => {
80
+ const existing = findGrant(db, userId, clientId);
81
+ const merged = new Set<string>(existing?.scopes ?? []);
82
+ for (const s of newScopes) {
83
+ if (s.length > 0) merged.add(s);
84
+ }
85
+ const scopes = Array.from(merged).sort();
86
+ const grantedAt = now.toISOString();
87
+ db.prepare(
88
+ `INSERT OR REPLACE INTO grants (user_id, client_id, scopes, granted_at)
89
+ VALUES (?, ?, ?, ?)`,
90
+ ).run(userId, clientId, scopes.join(" "), grantedAt);
91
+ return { userId, clientId, scopes, grantedAt };
92
+ })();
93
+ }
94
+
95
+ /**
96
+ * Test whether `requestedScopes` is fully covered by the existing grant —
97
+ * the rule for skipping the consent screen. Returns false when:
98
+ * - no grant exists for (user, client), or
99
+ * - any requested scope is missing from the grant's set, or
100
+ * - `requestedScopes` is empty (we don't auto-approve "ask for nothing"
101
+ * flows; they're almost certainly client bugs and showing consent will
102
+ * surface that to the operator).
103
+ */
104
+ export function isCoveredByGrant(
105
+ db: Database,
106
+ userId: string,
107
+ clientId: string,
108
+ requestedScopes: readonly string[],
109
+ ): boolean {
110
+ if (requestedScopes.length === 0) return false;
111
+ const grant = findGrant(db, userId, clientId);
112
+ if (!grant) return false;
113
+ const granted = new Set(grant.scopes);
114
+ for (const s of requestedScopes) {
115
+ if (!granted.has(s)) return false;
116
+ }
117
+ return true;
118
+ }
119
+
120
+ /** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
121
+ export function listGrantsForUser(db: Database, userId: string): Grant[] {
122
+ const rows = db
123
+ .prepare(
124
+ "SELECT user_id, client_id, scopes, granted_at FROM grants WHERE user_id = ? ORDER BY granted_at DESC",
125
+ )
126
+ .all(userId) as GrantRow[];
127
+ return rows.map(rowToGrant);
128
+ }
129
+
130
+ /**
131
+ * Delete a grant. Returns true when a row was removed; false when no grant
132
+ * existed for (user, client). Note this does not revoke existing tokens —
133
+ * an operator who wants to revoke active sessions runs `/oauth/revoke` (or
134
+ * its CLI wrapper) separately. Removing the grant only forces the next
135
+ * /oauth/authorize flow to show consent again.
136
+ */
137
+ export function revokeGrant(db: Database, userId: string, clientId: string): boolean {
138
+ const res = db
139
+ .prepare("DELETE FROM grants WHERE user_id = ? AND client_id = ?")
140
+ .run(userId, clientId);
141
+ return res.changes > 0;
142
+ }