@openparachute/hub 0.3.0-rc.1 → 0.5.0

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 (90) 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 +712 -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 +519 -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 +652 -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 +242 -37
  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-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -1,5 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { type Runner, auth, authHelp } from "../commands/auth.ts";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { registerClient } from "../clients.ts";
6
+ import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
7
+ import { findGrant, recordGrant } from "../grants.ts";
8
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
9
+ import { validateAccessToken } from "../jwt-sign.ts";
10
+ import {
11
+ OPERATOR_TOKEN_AUDIENCE,
12
+ OPERATOR_TOKEN_SCOPES,
13
+ readOperatorTokenFile,
14
+ } from "../operator-token.ts";
15
+ import { createUser, listUsers, verifyPassword } from "../users.ts";
3
16
 
4
17
  function makeRunner(result: number | (() => Promise<number>) = 0): {
5
18
  runner: Runner;
@@ -15,21 +28,41 @@ function makeRunner(result: number | (() => Promise<number>) = 0): {
15
28
  return { runner, calls };
16
29
  }
17
30
 
18
- describe("parachute auth", () => {
19
- test("set-password forwards to parachute-vault set-password", async () => {
20
- const { runner, calls } = makeRunner(0);
21
- const code = await auth(["set-password"], runner);
22
- expect(code).toBe(0);
23
- expect(calls).toEqual([["parachute-vault", "set-password"]]);
24
- });
31
+ function makeTmp(): { dir: string; dbPath: string; cleanup: () => void } {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-auth-"));
33
+ return {
34
+ dir,
35
+ dbPath: hubDbPath(dir),
36
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
37
+ };
38
+ }
25
39
 
26
- test("set-password --clear forwards the flag", async () => {
27
- const { runner, calls } = makeRunner(0);
28
- const code = await auth(["set-password", "--clear"], runner);
29
- expect(code).toBe(0);
30
- expect(calls).toEqual([["parachute-vault", "set-password", "--clear"]]);
31
- });
40
+ /** Capture console.log + console.error output for the duration of `fn`. */
41
+ async function captureOutput(fn: () => Promise<number> | number): Promise<{
42
+ code: number;
43
+ stdout: string;
44
+ stderr: string;
45
+ }> {
46
+ const origLog = console.log;
47
+ const origErr = console.error;
48
+ let stdout = "";
49
+ let stderr = "";
50
+ console.log = (...a: unknown[]) => {
51
+ stdout += `${a.map(String).join(" ")}\n`;
52
+ };
53
+ console.error = (...a: unknown[]) => {
54
+ stderr += `${a.map(String).join(" ")}\n`;
55
+ };
56
+ try {
57
+ const code = await fn();
58
+ return { code, stdout, stderr };
59
+ } finally {
60
+ console.log = origLog;
61
+ console.error = origErr;
62
+ }
63
+ }
32
64
 
65
+ describe("parachute auth", () => {
33
66
  test("2fa enroll forwards to parachute-vault 2fa enroll", async () => {
34
67
  const { runner, calls } = makeRunner(0);
35
68
  const code = await auth(["2fa", "enroll"], runner);
@@ -50,16 +83,34 @@ describe("parachute auth", () => {
50
83
  expect(code).toBe(3);
51
84
  });
52
85
 
53
- test("ENOENT surfaces install hint and exit 127", async () => {
86
+ test("ENOENT on a vault-forwarded subcommand surfaces install hint and exit 127", async () => {
54
87
  const runner: Runner = {
55
88
  async run() {
56
89
  throw new Error("ENOENT: spawn parachute-vault");
57
90
  },
58
91
  };
59
- const code = await auth(["set-password"], runner);
92
+ const code = await auth(["2fa", "status"], runner);
60
93
  expect(code).toBe(127);
61
94
  });
62
95
 
96
+ test("set-password no longer forwards to vault", async () => {
97
+ const tmp = makeTmp();
98
+ try {
99
+ const { runner, calls } = makeRunner(0);
100
+ const code = await auth(["set-password", "--password", "pw"], {
101
+ runner,
102
+ dbPath: tmp.dbPath,
103
+ configDir: tmp.dir,
104
+ isInteractive: () => false,
105
+ });
106
+ expect(code).toBe(0);
107
+ // Did NOT spawn parachute-vault.
108
+ expect(calls).toEqual([]);
109
+ } finally {
110
+ tmp.cleanup();
111
+ }
112
+ });
113
+
63
114
  test("bogus subcommand exits 1 without spawning vault", async () => {
64
115
  const { runner, calls } = makeRunner(0);
65
116
  const code = await auth(["whoami"], runner);
@@ -88,14 +139,658 @@ describe("authHelp", () => {
88
139
 
89
140
  test("lists every blessed subcommand", () => {
90
141
  expect(h).toContain("parachute auth set-password");
91
- expect(h).toContain("--clear");
142
+ expect(h).toContain("parachute auth list-users");
92
143
  expect(h).toContain("parachute auth 2fa status");
93
144
  expect(h).toContain("parachute auth 2fa enroll");
94
145
  expect(h).toContain("parachute auth 2fa disable");
95
146
  expect(h).toContain("parachute auth 2fa backup-codes");
147
+ expect(h).toContain("parachute auth rotate-key");
148
+ });
149
+
150
+ test("set-password help mentions the new flags + hub-local home", () => {
151
+ expect(h).toContain("--username");
152
+ expect(h).toContain("--allow-multi");
153
+ expect(h).toContain("hub.db");
96
154
  });
97
155
 
98
156
  test("mentions the vault-install hint", () => {
99
157
  expect(h).toContain("parachute install vault");
100
158
  });
159
+
160
+ test("rotate-key explains the 24h JWKS retention", () => {
161
+ expect(h).toContain("jwks.json");
162
+ // "24" + "hours" may be split by line wrap; check both pieces.
163
+ expect(h).toContain("24");
164
+ expect(h).toContain("hours");
165
+ });
166
+ });
167
+
168
+ describe("parachute auth rotate-key", () => {
169
+ test("invokes the rotate hook and exits 0; does not spawn vault", async () => {
170
+ const { runner, calls } = makeRunner(0);
171
+ let hookCalls = 0;
172
+ const code = await auth(["rotate-key"], {
173
+ runner,
174
+ rotateKey: () => {
175
+ hookCalls++;
176
+ return { kid: "test-kid-abc", createdAt: "2026-04-26T00:00:00.000Z" };
177
+ },
178
+ });
179
+ expect(code).toBe(0);
180
+ expect(hookCalls).toBe(1);
181
+ expect(calls).toEqual([]);
182
+ });
183
+
184
+ test("propagates rotate errors as exit 1", async () => {
185
+ const code = await auth(["rotate-key"], {
186
+ rotateKey: () => {
187
+ throw new Error("disk full");
188
+ },
189
+ });
190
+ expect(code).toBe(1);
191
+ });
192
+ });
193
+
194
+ describe("parachute auth set-password", () => {
195
+ test("creates the first user with --password (non-interactive)", async () => {
196
+ const tmp = makeTmp();
197
+ try {
198
+ const deps: AuthDeps = {
199
+ dbPath: tmp.dbPath,
200
+ configDir: tmp.dir,
201
+ isInteractive: () => false,
202
+ };
203
+ const { code, stdout } = await captureOutput(() =>
204
+ auth(["set-password", "--password", "hunter2"], deps),
205
+ );
206
+ expect(code).toBe(0);
207
+ expect(stdout).toContain("Created hub user");
208
+ expect(stdout).toContain("owner");
209
+ const db = openHubDb(tmp.dbPath);
210
+ try {
211
+ const users = listUsers(db);
212
+ expect(users).toHaveLength(1);
213
+ expect(users[0]?.username).toBe("owner");
214
+ expect(await verifyPassword(users[0]!, "hunter2")).toBe(true);
215
+ } finally {
216
+ db.close();
217
+ }
218
+ } finally {
219
+ tmp.cleanup();
220
+ }
221
+ });
222
+
223
+ test("creates with a custom --username", async () => {
224
+ const tmp = makeTmp();
225
+ try {
226
+ const { code } = await captureOutput(() =>
227
+ auth(["set-password", "--username", "aaron", "--password", "pw"], {
228
+ dbPath: tmp.dbPath,
229
+ configDir: tmp.dir,
230
+ isInteractive: () => false,
231
+ }),
232
+ );
233
+ expect(code).toBe(0);
234
+ const db = openHubDb(tmp.dbPath);
235
+ try {
236
+ expect(listUsers(db).map((u) => u.username)).toEqual(["aaron"]);
237
+ } finally {
238
+ db.close();
239
+ }
240
+ } finally {
241
+ tmp.cleanup();
242
+ }
243
+ });
244
+
245
+ test("updates the existing user's password (single-user mode)", async () => {
246
+ const tmp = makeTmp();
247
+ try {
248
+ const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
249
+ // First-run create.
250
+ await captureOutput(() => auth(["set-password", "--password", "old"], deps));
251
+ // Update.
252
+ const { code, stdout } = await captureOutput(() =>
253
+ auth(["set-password", "--password", "new"], deps),
254
+ );
255
+ expect(code).toBe(0);
256
+ expect(stdout).toContain("Updated password");
257
+ const db = openHubDb(tmp.dbPath);
258
+ try {
259
+ const u = listUsers(db)[0]!;
260
+ expect(await verifyPassword(u, "new")).toBe(true);
261
+ expect(await verifyPassword(u, "old")).toBe(false);
262
+ } finally {
263
+ db.close();
264
+ }
265
+ } finally {
266
+ tmp.cleanup();
267
+ }
268
+ });
269
+
270
+ test("rejects --username mismatch without --allow-multi", async () => {
271
+ const tmp = makeTmp();
272
+ try {
273
+ const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
274
+ await captureOutput(() => auth(["set-password", "--password", "p"], deps));
275
+ const { code, stderr } = await captureOutput(() =>
276
+ auth(["set-password", "--username", "second", "--password", "p"], deps),
277
+ );
278
+ expect(code).toBe(1);
279
+ expect(stderr).toContain("already exists");
280
+ } finally {
281
+ tmp.cleanup();
282
+ }
283
+ });
284
+
285
+ test("creates a second user with --allow-multi", async () => {
286
+ const tmp = makeTmp();
287
+ try {
288
+ const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
289
+ await captureOutput(() => auth(["set-password", "--password", "p"], deps));
290
+ const { code } = await captureOutput(() =>
291
+ auth(["set-password", "--username", "second", "--password", "p", "--allow-multi"], deps),
292
+ );
293
+ expect(code).toBe(0);
294
+ const db = openHubDb(tmp.dbPath);
295
+ try {
296
+ expect(
297
+ listUsers(db)
298
+ .map((u) => u.username)
299
+ .sort(),
300
+ ).toEqual(["owner", "second"]);
301
+ } finally {
302
+ db.close();
303
+ }
304
+ } finally {
305
+ tmp.cleanup();
306
+ }
307
+ });
308
+
309
+ test("non-interactive without --password is an error", async () => {
310
+ const tmp = makeTmp();
311
+ try {
312
+ const { code, stderr } = await captureOutput(() =>
313
+ auth(["set-password"], {
314
+ dbPath: tmp.dbPath,
315
+ configDir: tmp.dir,
316
+ isInteractive: () => false,
317
+ }),
318
+ );
319
+ expect(code).toBe(1);
320
+ expect(stderr).toContain("--password is required");
321
+ } finally {
322
+ tmp.cleanup();
323
+ }
324
+ });
325
+
326
+ test("interactive: prompts twice and creates the user when they match", async () => {
327
+ const tmp = makeTmp();
328
+ try {
329
+ const prompts: string[] = [];
330
+ const deps: AuthDeps = {
331
+ dbPath: tmp.dbPath,
332
+ configDir: tmp.dir,
333
+ isInteractive: () => true,
334
+ readPassword: async (p) => {
335
+ prompts.push(p);
336
+ return "matched";
337
+ },
338
+ readLine: async () => "y",
339
+ };
340
+ const { code } = await captureOutput(() => auth(["set-password"], deps));
341
+ expect(code).toBe(0);
342
+ expect(prompts.length).toBe(2);
343
+ const db = openHubDb(tmp.dbPath);
344
+ try {
345
+ const u = listUsers(db)[0]!;
346
+ expect(await verifyPassword(u, "matched")).toBe(true);
347
+ } finally {
348
+ db.close();
349
+ }
350
+ } finally {
351
+ tmp.cleanup();
352
+ }
353
+ });
354
+
355
+ test("interactive: mismatched confirmation aborts with exit 1", async () => {
356
+ const tmp = makeTmp();
357
+ try {
358
+ const answers = ["one", "two"];
359
+ const deps: AuthDeps = {
360
+ dbPath: tmp.dbPath,
361
+ configDir: tmp.dir,
362
+ isInteractive: () => true,
363
+ readPassword: async () => answers.shift() ?? "",
364
+ readLine: async () => "y",
365
+ };
366
+ const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
367
+ expect(code).toBe(1);
368
+ expect(stderr).toContain("did not match");
369
+ } finally {
370
+ tmp.cleanup();
371
+ }
372
+ });
373
+
374
+ test("interactive: empty password aborts with exit 1", async () => {
375
+ const tmp = makeTmp();
376
+ try {
377
+ const deps: AuthDeps = {
378
+ dbPath: tmp.dbPath,
379
+ configDir: tmp.dir,
380
+ isInteractive: () => true,
381
+ readPassword: async () => "",
382
+ readLine: async () => "y",
383
+ };
384
+ const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
385
+ expect(code).toBe(1);
386
+ expect(stderr).toContain("empty");
387
+ } finally {
388
+ tmp.cleanup();
389
+ }
390
+ });
391
+
392
+ test("first-run interactive: declining the default-username confirmation aborts", async () => {
393
+ const tmp = makeTmp();
394
+ try {
395
+ const deps: AuthDeps = {
396
+ dbPath: tmp.dbPath,
397
+ configDir: tmp.dir,
398
+ isInteractive: () => true,
399
+ readPassword: async () => "pw",
400
+ readLine: async () => "n",
401
+ };
402
+ const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
403
+ expect(code).toBe(1);
404
+ expect(stderr).toContain("aborted");
405
+ } finally {
406
+ tmp.cleanup();
407
+ }
408
+ });
409
+
410
+ test("unknown flag exits 1", async () => {
411
+ const tmp = makeTmp();
412
+ try {
413
+ const { code, stderr } = await captureOutput(() =>
414
+ auth(["set-password", "--lol"], {
415
+ dbPath: tmp.dbPath,
416
+ configDir: tmp.dir,
417
+ isInteractive: () => false,
418
+ }),
419
+ );
420
+ expect(code).toBe(1);
421
+ expect(stderr).toContain("unknown flag");
422
+ } finally {
423
+ tmp.cleanup();
424
+ }
425
+ });
426
+ });
427
+
428
+ describe("parachute auth list-users", () => {
429
+ test("empty state prints the seeding hint", async () => {
430
+ const tmp = makeTmp();
431
+ try {
432
+ const { code, stdout } = await captureOutput(() =>
433
+ auth(["list-users"], { dbPath: tmp.dbPath }),
434
+ );
435
+ expect(code).toBe(0);
436
+ expect(stdout).toContain("no hub users yet");
437
+ } finally {
438
+ tmp.cleanup();
439
+ }
440
+ });
441
+
442
+ test("lists usernames after a set-password", async () => {
443
+ const tmp = makeTmp();
444
+ try {
445
+ const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
446
+ await captureOutput(() =>
447
+ auth(["set-password", "--username", "alice", "--password", "p"], deps),
448
+ );
449
+ const { code, stdout } = await captureOutput(() => auth(["list-users"], deps));
450
+ expect(code).toBe(0);
451
+ expect(stdout).toContain("USERNAME");
452
+ expect(stdout).toContain("alice");
453
+ } finally {
454
+ tmp.cleanup();
455
+ }
456
+ });
457
+ });
458
+
459
+ describe("set-password operator-token side-effect", () => {
460
+ // First-run set-password must seed ~/.parachute/operator.token. Without
461
+ // this, on-box CLI callers have nothing to present as a bearer when the
462
+ // hub starts requiring auth on every request (no loopback bypass).
463
+ test("creates operator.token on first-run, signed against active key, audience=operator", async () => {
464
+ const tmp = makeTmp();
465
+ try {
466
+ const deps: AuthDeps = {
467
+ dbPath: tmp.dbPath,
468
+ configDir: tmp.dir,
469
+ isInteractive: () => false,
470
+ };
471
+ const { code, stdout } = await captureOutput(() =>
472
+ auth(["set-password", "--password", "pw"], deps),
473
+ );
474
+ expect(code).toBe(0);
475
+ expect(stdout).toContain("operator token");
476
+ const tokenOnDisk = await readOperatorTokenFile(tmp.dir);
477
+ expect(tokenOnDisk).not.toBeNull();
478
+ const db = openHubDb(tmp.dbPath);
479
+ try {
480
+ const validated = await validateAccessToken(db, tokenOnDisk ?? "");
481
+ expect(validated.payload.aud).toBe(OPERATOR_TOKEN_AUDIENCE);
482
+ expect(validated.payload.scope).toBe(OPERATOR_TOKEN_SCOPES.join(" "));
483
+ const users = listUsers(db);
484
+ expect(validated.payload.sub).toBe(users[0]?.id);
485
+ } finally {
486
+ db.close();
487
+ }
488
+ } finally {
489
+ tmp.cleanup();
490
+ }
491
+ });
492
+
493
+ // Password reset rotates the file too — old token stays valid until its
494
+ // 1y TTL expires (the hub doesn't track operator-token jtis), but the
495
+ // file always carries the freshest one.
496
+ test("password update overwrites operator.token with a fresh JWT", async () => {
497
+ const tmp = makeTmp();
498
+ try {
499
+ const deps: AuthDeps = {
500
+ dbPath: tmp.dbPath,
501
+ configDir: tmp.dir,
502
+ isInteractive: () => false,
503
+ };
504
+ await captureOutput(() => auth(["set-password", "--password", "old"], deps));
505
+ const first = await readOperatorTokenFile(tmp.dir);
506
+ // Sleep a beat to make sure the new JWT has a different iat — JWT
507
+ // claims are second-precision.
508
+ await new Promise((r) => setTimeout(r, 1100));
509
+ await captureOutput(() => auth(["set-password", "--password", "new"], deps));
510
+ const second = await readOperatorTokenFile(tmp.dir);
511
+ expect(second).not.toBeNull();
512
+ expect(second).not.toBe(first);
513
+ } finally {
514
+ tmp.cleanup();
515
+ }
516
+ });
517
+ });
518
+
519
+ describe("parachute auth rotate-operator", () => {
520
+ test("mints a fresh token, overwrites the file, exits 0", async () => {
521
+ const tmp = makeTmp();
522
+ try {
523
+ const deps: AuthDeps = {
524
+ dbPath: tmp.dbPath,
525
+ configDir: tmp.dir,
526
+ isInteractive: () => false,
527
+ };
528
+ await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
529
+ const before = await readOperatorTokenFile(tmp.dir);
530
+ await new Promise((r) => setTimeout(r, 1100));
531
+ const { code, stdout } = await captureOutput(() => auth(["rotate-operator"], deps));
532
+ expect(code).toBe(0);
533
+ expect(stdout).toContain("Rotated operator token");
534
+ const after = await readOperatorTokenFile(tmp.dir);
535
+ expect(after).not.toBeNull();
536
+ expect(after).not.toBe(before);
537
+ } finally {
538
+ tmp.cleanup();
539
+ }
540
+ });
541
+
542
+ test("with no users yet, exits 1 with a hint to run set-password", async () => {
543
+ const tmp = makeTmp();
544
+ try {
545
+ const deps: AuthDeps = {
546
+ dbPath: tmp.dbPath,
547
+ configDir: tmp.dir,
548
+ isInteractive: () => false,
549
+ };
550
+ const { code, stderr } = await captureOutput(() => auth(["rotate-operator"], deps));
551
+ expect(code).toBe(1);
552
+ expect(stderr).toContain("set-password");
553
+ } finally {
554
+ tmp.cleanup();
555
+ }
556
+ });
557
+ });
558
+
559
+ // closes #74 — the operator's surface for the DCR approval gate. The CLI
560
+ // is the only approval path at launch (no admin UI yet); these tests pin
561
+ // the round-trip so an operator can promote a pending registration.
562
+ describe("parachute auth pending-clients / approve-client", () => {
563
+ test("pending-clients on an empty db says '(no pending OAuth clients)'", async () => {
564
+ const tmp = makeTmp();
565
+ try {
566
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
567
+ const { code, stdout } = await captureOutput(() => auth(["pending-clients"], deps));
568
+ expect(code).toBe(0);
569
+ expect(stdout).toContain("no pending OAuth clients");
570
+ } finally {
571
+ tmp.cleanup();
572
+ }
573
+ });
574
+
575
+ test("pending-clients lists pending rows; approve-client promotes them", async () => {
576
+ const tmp = makeTmp();
577
+ try {
578
+ const { registerClient } = await import("../clients.ts");
579
+ const db = openHubDb(tmp.dbPath);
580
+ let pendingId: string;
581
+ try {
582
+ pendingId = registerClient(db, {
583
+ redirectUris: ["https://app.example/cb"],
584
+ status: "pending",
585
+ clientName: "MyApp",
586
+ }).client.clientId;
587
+ registerClient(db, {
588
+ redirectUris: ["https://approved.example/cb"],
589
+ status: "approved",
590
+ clientName: "Already",
591
+ });
592
+ } finally {
593
+ db.close();
594
+ }
595
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
596
+
597
+ // pending-clients shows only the pending row.
598
+ const list = await captureOutput(() => auth(["pending-clients"], deps));
599
+ expect(list.code).toBe(0);
600
+ expect(list.stdout).toContain(pendingId);
601
+ expect(list.stdout).toContain("MyApp");
602
+ expect(list.stdout).not.toContain("approved.example");
603
+
604
+ // approve-client without an arg is a usage error.
605
+ const noArg = await captureOutput(() => auth(["approve-client"], deps));
606
+ expect(noArg.code).toBe(1);
607
+ expect(noArg.stderr).toContain("missing client_id");
608
+
609
+ // approve-client <unknown> is a 1.
610
+ const unknown = await captureOutput(() => auth(["approve-client", "no-such"], deps));
611
+ expect(unknown.code).toBe(1);
612
+ expect(unknown.stderr).toContain("no OAuth client");
613
+
614
+ // approve-client <pending> succeeds and the row drops off pending-clients.
615
+ const ok = await captureOutput(() => auth(["approve-client", pendingId], deps));
616
+ expect(ok.code).toBe(0);
617
+ expect(ok.stdout).toContain("Approved");
618
+ const after = await captureOutput(() => auth(["pending-clients"], deps));
619
+ expect(after.stdout).toContain("no pending OAuth clients");
620
+ } finally {
621
+ tmp.cleanup();
622
+ }
623
+ });
624
+ });
625
+
626
+ // closes #75 — operator-facing controls for the OAuth consent skip-list.
627
+ describe("parachute auth list-grants / revoke-grant", () => {
628
+ test("list-grants shows the seeding hint when no users exist", async () => {
629
+ const tmp = makeTmp();
630
+ try {
631
+ const { code, stderr } = await captureOutput(() =>
632
+ auth(["list-grants"], { dbPath: tmp.dbPath }),
633
+ );
634
+ expect(code).toBe(1);
635
+ expect(stderr).toContain("no hub users yet");
636
+ } finally {
637
+ tmp.cleanup();
638
+ }
639
+ });
640
+
641
+ test("list-grants shows '(no OAuth grants)' when the user has none", async () => {
642
+ const tmp = makeTmp();
643
+ try {
644
+ const db = openHubDb(tmp.dbPath);
645
+ try {
646
+ await createUser(db, "owner", "pw");
647
+ } finally {
648
+ db.close();
649
+ }
650
+ const { code, stdout } = await captureOutput(() =>
651
+ auth(["list-grants"], { dbPath: tmp.dbPath }),
652
+ );
653
+ expect(code).toBe(0);
654
+ expect(stdout).toContain("no OAuth grants on record");
655
+ expect(stdout).toContain("owner");
656
+ } finally {
657
+ tmp.cleanup();
658
+ }
659
+ });
660
+
661
+ test("list-grants prints rows with client_id + client_name + scopes", async () => {
662
+ const tmp = makeTmp();
663
+ try {
664
+ const db = openHubDb(tmp.dbPath);
665
+ let userId: string;
666
+ let clientId: string;
667
+ try {
668
+ const user = await createUser(db, "owner", "pw");
669
+ userId = user.id;
670
+ const reg = registerClient(db, {
671
+ redirectUris: ["https://app.example/cb"],
672
+ clientName: "MyApp",
673
+ });
674
+ clientId = reg.client.clientId;
675
+ recordGrant(db, userId, clientId, ["vault:default:read", "scribe:transcribe"]);
676
+ } finally {
677
+ db.close();
678
+ }
679
+ const { code, stdout } = await captureOutput(() =>
680
+ auth(["list-grants"], { dbPath: tmp.dbPath }),
681
+ );
682
+ expect(code).toBe(0);
683
+ expect(stdout).toContain(clientId);
684
+ expect(stdout).toContain("MyApp");
685
+ expect(stdout).toContain("vault:default:read");
686
+ expect(stdout).toContain("scribe:transcribe");
687
+ } finally {
688
+ tmp.cleanup();
689
+ }
690
+ });
691
+
692
+ test("revoke-grant without args prints usage", async () => {
693
+ const tmp = makeTmp();
694
+ try {
695
+ const { code, stderr } = await captureOutput(() =>
696
+ auth(["revoke-grant"], { dbPath: tmp.dbPath }),
697
+ );
698
+ expect(code).toBe(1);
699
+ expect(stderr).toContain("missing client_id");
700
+ } finally {
701
+ tmp.cleanup();
702
+ }
703
+ });
704
+
705
+ test("revoke-grant for an unknown client errors", async () => {
706
+ const tmp = makeTmp();
707
+ try {
708
+ const db = openHubDb(tmp.dbPath);
709
+ try {
710
+ await createUser(db, "owner", "pw");
711
+ } finally {
712
+ db.close();
713
+ }
714
+ const { code, stderr } = await captureOutput(() =>
715
+ auth(["revoke-grant", "no-such"], { dbPath: tmp.dbPath }),
716
+ );
717
+ expect(code).toBe(1);
718
+ expect(stderr).toContain("no grant on record");
719
+ } finally {
720
+ tmp.cleanup();
721
+ }
722
+ });
723
+
724
+ test("revoke-grant deletes the row and surfaces a friendly message", async () => {
725
+ const tmp = makeTmp();
726
+ try {
727
+ const db = openHubDb(tmp.dbPath);
728
+ let userId: string;
729
+ let clientId: string;
730
+ try {
731
+ const user = await createUser(db, "owner", "pw");
732
+ userId = user.id;
733
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
734
+ clientId = reg.client.clientId;
735
+ recordGrant(db, userId, clientId, ["vault:default:read"]);
736
+ expect(findGrant(db, userId, clientId)).not.toBeNull();
737
+ } finally {
738
+ db.close();
739
+ }
740
+ const { code, stdout } = await captureOutput(() =>
741
+ auth(["revoke-grant", clientId], { dbPath: tmp.dbPath }),
742
+ );
743
+ expect(code).toBe(0);
744
+ expect(stdout).toContain("Revoked OAuth grant");
745
+ expect(stdout).toContain("re-prompt for consent");
746
+
747
+ // Row gone.
748
+ const verifyDb = openHubDb(tmp.dbPath);
749
+ try {
750
+ expect(findGrant(verifyDb, userId, clientId)).toBeNull();
751
+ } finally {
752
+ verifyDb.close();
753
+ }
754
+ } finally {
755
+ tmp.cleanup();
756
+ }
757
+ });
758
+
759
+ test("multi-user mode requires --username on revoke-grant", async () => {
760
+ const tmp = makeTmp();
761
+ try {
762
+ const db = openHubDb(tmp.dbPath);
763
+ let aliceId: string;
764
+ let clientId: string;
765
+ try {
766
+ const alice = await createUser(db, "alice", "pw");
767
+ aliceId = alice.id;
768
+ await createUser(db, "bob", "pw", { allowMulti: true });
769
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
770
+ clientId = reg.client.clientId;
771
+ recordGrant(db, aliceId, clientId, ["vault:default:read"]);
772
+ } finally {
773
+ db.close();
774
+ }
775
+ const ambig = await captureOutput(() =>
776
+ auth(["revoke-grant", clientId], { dbPath: tmp.dbPath }),
777
+ );
778
+ expect(ambig.code).toBe(1);
779
+ expect(ambig.stderr).toContain("multiple hub users exist");
780
+
781
+ const targeted = await captureOutput(() =>
782
+ auth(["revoke-grant", clientId, "--username", "alice"], { dbPath: tmp.dbPath }),
783
+ );
784
+ expect(targeted.code).toBe(0);
785
+ expect(targeted.stdout).toContain("alice");
786
+
787
+ // Bob never had this grant, so revoking his side is a 1.
788
+ const bobMiss = await captureOutput(() =>
789
+ auth(["revoke-grant", clientId, "--username", "bob"], { dbPath: tmp.dbPath }),
790
+ );
791
+ expect(bobMiss.code).toBe(1);
792
+ } finally {
793
+ tmp.cleanup();
794
+ }
795
+ });
101
796
  });