@openparachute/hub 0.7.4-rc.8 → 0.7.4

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 (71) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +276 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Tests for `/api/settings/root-redirect`.
3
+ *
4
+ * Covers:
5
+ * - `validateRootRedirect` pure validator (null/empty clear, safe path,
6
+ * open-redirect rejection).
7
+ * - GET response shape (root_redirect + resolved + source).
8
+ * - PUT happy path + open-redirect rejection (the highest-stakes part).
9
+ * - PUT clear (null) reverts to env/default precedence.
10
+ * - Auth gating: 401 missing/empty bearer, 403 wrong scope.
11
+ * - "Change takes effect on the next request" — the GET resolved value
12
+ * reflects the value just written, without restarting.
13
+ */
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
20
+ handleApiSettingsRootRedirect,
21
+ validateRootRedirect,
22
+ } from "../api-settings-root-redirect.ts";
23
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
24
+ import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
25
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
26
+ import { rotateSigningKey } from "../signing-keys.ts";
27
+ import { createUser } from "../users.ts";
28
+
29
+ const ISSUER = "http://127.0.0.1:1939";
30
+
31
+ interface Harness {
32
+ dir: string;
33
+ db: ReturnType<typeof openHubDb>;
34
+ userId: string;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ async function makeHarness(): Promise<Harness> {
39
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-settings-root-redirect-"));
40
+ const db = openHubDb(hubDbPath(dir));
41
+ rotateSigningKey(db);
42
+ const user = await createUser(db, "owner", "pw");
43
+ return {
44
+ dir,
45
+ db,
46
+ userId: user.id,
47
+ cleanup: () => {
48
+ db.close();
49
+ rmSync(dir, { recursive: true, force: true });
50
+ },
51
+ };
52
+ }
53
+
54
+ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
55
+ const signed = await signAccessToken(h.db, {
56
+ sub: h.userId,
57
+ scopes,
58
+ audience: "parachute-hub",
59
+ clientId: "parachute-hub",
60
+ issuer: ISSUER,
61
+ ttlSeconds: 3600,
62
+ });
63
+ recordTokenMint(h.db, {
64
+ jti: signed.jti,
65
+ createdVia: "operator_mint",
66
+ subject: h.userId,
67
+ clientId: "parachute-hub",
68
+ scopes,
69
+ expiresAt: signed.expiresAt,
70
+ });
71
+ return signed.token;
72
+ }
73
+
74
+ function getReq(headers: Record<string, string> = {}): Request {
75
+ return new Request("http://localhost/api/settings/root-redirect", { method: "GET", headers });
76
+ }
77
+
78
+ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
79
+ return new Request("http://localhost/api/settings/root-redirect", {
80
+ method: "PUT",
81
+ headers: { "content-type": "application/json", ...headers },
82
+ body: typeof body === "string" ? body : JSON.stringify(body),
83
+ });
84
+ }
85
+
86
+ // Empty env so the resolver's env layer is deterministic (the host's real
87
+ // PARACHUTE_HUB_ROOT_REDIRECT must not leak into GET's resolved/source).
88
+ const noEnv: NodeJS.ProcessEnv = {};
89
+
90
+ function deps(
91
+ h: Harness,
92
+ overrides: Partial<Parameters<typeof handleApiSettingsRootRedirect>[1]> = {},
93
+ ) {
94
+ return {
95
+ db: h.db,
96
+ issuer: ISSUER,
97
+ env: noEnv,
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ describe("validateRootRedirect — pure validator", () => {
103
+ test("null → normalized null (clear)", () => {
104
+ expect(validateRootRedirect(null)).toEqual({ ok: true, normalized: null });
105
+ });
106
+
107
+ test("empty string → normalized null (clear footgun guard)", () => {
108
+ expect(validateRootRedirect("")).toEqual({ ok: true, normalized: null });
109
+ });
110
+
111
+ test("safe same-origin path → normalized verbatim", () => {
112
+ expect(validateRootRedirect("/surface/reading-room")).toEqual({
113
+ ok: true,
114
+ normalized: "/surface/reading-room",
115
+ });
116
+ });
117
+
118
+ test("rejects off-origin + scheme shapes", () => {
119
+ for (const bad of [
120
+ "//evil.com",
121
+ "/\\evil.com",
122
+ "https://evil.com",
123
+ "javascript:alert(1)",
124
+ "admin",
125
+ "/",
126
+ ]) {
127
+ const r = validateRootRedirect(bad);
128
+ expect(r.ok).toBe(false);
129
+ }
130
+ });
131
+
132
+ test("rejects non-string non-null", () => {
133
+ expect(validateRootRedirect(42).ok).toBe(false);
134
+ expect(validateRootRedirect({}).ok).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("auth gating", () => {
139
+ let h: Harness;
140
+ beforeEach(async () => {
141
+ h = await makeHarness();
142
+ });
143
+ afterEach(() => h.cleanup());
144
+
145
+ test("405 on non-GET/PUT", async () => {
146
+ const res = await handleApiSettingsRootRedirect(
147
+ new Request("http://localhost/api/settings/root-redirect", { method: "POST" }),
148
+ deps(h),
149
+ );
150
+ expect(res.status).toBe(405);
151
+ });
152
+
153
+ test("401 when Authorization header is missing", async () => {
154
+ const res = await handleApiSettingsRootRedirect(getReq(), deps(h));
155
+ expect(res.status).toBe(401);
156
+ });
157
+
158
+ test("401 on empty bearer", async () => {
159
+ const res = await handleApiSettingsRootRedirect(getReq({ authorization: "Bearer " }), deps(h));
160
+ expect(res.status).toBe(401);
161
+ });
162
+
163
+ test("403 when the bearer lacks the required scope", async () => {
164
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
165
+ const resGet = await handleApiSettingsRootRedirect(
166
+ getReq({ authorization: `Bearer ${bearer}` }),
167
+ deps(h),
168
+ );
169
+ expect(resGet.status).toBe(403);
170
+ const resPut = await handleApiSettingsRootRedirect(
171
+ putReq({ root_redirect: "/surface/x" }, { authorization: `Bearer ${bearer}` }),
172
+ deps(h),
173
+ );
174
+ expect(resPut.status).toBe(403);
175
+ // Nothing was written.
176
+ expect(getRootRedirect(h.db)).toBeNull();
177
+ });
178
+ });
179
+
180
+ describe("GET /api/settings/root-redirect", () => {
181
+ let h: Harness;
182
+ beforeEach(async () => {
183
+ h = await makeHarness();
184
+ });
185
+ afterEach(() => h.cleanup());
186
+
187
+ test("default shape when unset: /admin from the default layer", async () => {
188
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
189
+ const res = await handleApiSettingsRootRedirect(
190
+ getReq({ authorization: `Bearer ${bearer}` }),
191
+ deps(h),
192
+ );
193
+ expect(res.status).toBe(200);
194
+ const body = (await res.json()) as Record<string, unknown>;
195
+ expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
196
+ });
197
+
198
+ test("reflects a stored value with source=db", async () => {
199
+ setRootRedirect(h.db, "/surface/reading-room");
200
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
201
+ const res = await handleApiSettingsRootRedirect(
202
+ getReq({ authorization: `Bearer ${bearer}` }),
203
+ deps(h),
204
+ );
205
+ const body = (await res.json()) as Record<string, unknown>;
206
+ expect(body).toEqual({
207
+ root_redirect: "/surface/reading-room",
208
+ resolved: "/surface/reading-room",
209
+ source: "db",
210
+ });
211
+ });
212
+
213
+ test("surfaces an env-sourced resolved value while the stored row is null", async () => {
214
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
215
+ const res = await handleApiSettingsRootRedirect(
216
+ getReq({ authorization: `Bearer ${bearer}` }),
217
+ deps(h, { env: { PARACHUTE_HUB_ROOT_REDIRECT: "/surface/from-env" } }),
218
+ );
219
+ const body = (await res.json()) as Record<string, unknown>;
220
+ expect(body).toEqual({
221
+ root_redirect: null,
222
+ resolved: "/surface/from-env",
223
+ source: "env",
224
+ });
225
+ });
226
+ });
227
+
228
+ describe("PUT /api/settings/root-redirect", () => {
229
+ let h: Harness;
230
+ beforeEach(async () => {
231
+ h = await makeHarness();
232
+ });
233
+ afterEach(() => h.cleanup());
234
+
235
+ test("stores a safe path + GET reflects it on the next request (no restart)", async () => {
236
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
237
+ const put = await handleApiSettingsRootRedirect(
238
+ putReq({ root_redirect: "/surface/reading-room" }, { authorization: `Bearer ${bearer}` }),
239
+ deps(h),
240
+ );
241
+ expect(put.status).toBe(200);
242
+ expect((await put.json()) as unknown).toEqual({ root_redirect: "/surface/reading-room" });
243
+ expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
244
+
245
+ const get = await handleApiSettingsRootRedirect(
246
+ getReq({ authorization: `Bearer ${bearer}` }),
247
+ deps(h),
248
+ );
249
+ const body = (await get.json()) as Record<string, unknown>;
250
+ expect(body.resolved).toBe("/surface/reading-room");
251
+ expect(body.source).toBe("db");
252
+ });
253
+
254
+ test("null clears the row", async () => {
255
+ setRootRedirect(h.db, "/surface/x");
256
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
257
+ const res = await handleApiSettingsRootRedirect(
258
+ putReq({ root_redirect: null }, { authorization: `Bearer ${bearer}` }),
259
+ deps(h),
260
+ );
261
+ expect(res.status).toBe(200);
262
+ expect(getRootRedirect(h.db)).toBeNull();
263
+ });
264
+
265
+ test("rejects open-redirect payloads with 400 and writes nothing", async () => {
266
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
267
+ for (const bad of [
268
+ "//evil.com",
269
+ "https://evil.com",
270
+ "javascript:alert(1)",
271
+ "/\\evil.com",
272
+ "/",
273
+ ]) {
274
+ const res = await handleApiSettingsRootRedirect(
275
+ putReq({ root_redirect: bad }, { authorization: `Bearer ${bearer}` }),
276
+ deps(h),
277
+ );
278
+ expect(res.status).toBe(400);
279
+ const body = (await res.json()) as Record<string, unknown>;
280
+ expect(body.error).toBe("invalid_root_redirect");
281
+ expect(getRootRedirect(h.db)).toBeNull();
282
+ }
283
+ });
284
+
285
+ test("400 on a body without a root_redirect field", async () => {
286
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
287
+ const res = await handleApiSettingsRootRedirect(
288
+ putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
289
+ deps(h),
290
+ );
291
+ expect(res.status).toBe(400);
292
+ });
293
+
294
+ test("400 on non-JSON body", async () => {
295
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
296
+ const res = await handleApiSettingsRootRedirect(
297
+ putReq("not json{", { authorization: `Bearer ${bearer}` }),
298
+ deps(h),
299
+ );
300
+ expect(res.status).toBe(400);
301
+ });
302
+ });
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { issueAuthCode } from "../auth-codes.ts";
5
6
  import { registerClient } from "../clients.ts";
6
7
  import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
7
8
  import { findGrant, recordGrant } from "../grants.ts";
@@ -269,6 +270,16 @@ describe("authHelp", () => {
269
270
  expect(h).toContain("parachute auth list-users");
270
271
  expect(h).toContain("parachute auth 2fa");
271
272
  expect(h).toContain("parachute auth rotate-key");
273
+ expect(h).toContain("parachute auth reap-clients");
274
+ });
275
+
276
+ test("reap-clients help documents dry-run-by-default + the conservative gate (#640)", () => {
277
+ expect(h).toContain("reap-clients");
278
+ expect(h).toContain("Dry-run by DEFAULT");
279
+ expect(h).toContain("--apply");
280
+ expect(h).toContain("--older-than");
281
+ expect(h).toContain("PROVABLY-DEAD");
282
+ expect(h).toContain("#640");
272
283
  });
273
284
 
274
285
  test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
@@ -849,6 +860,331 @@ describe("parachute auth pending-clients / approve-client", () => {
849
860
  });
850
861
  });
851
862
 
863
+ // hub#640 — RFC 7592 deregistration from the terminal.
864
+ describe("parachute auth revoke-client", () => {
865
+ test("revoke-client without an arg is a usage error", async () => {
866
+ const tmp = makeTmp();
867
+ try {
868
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
869
+ const { code, stderr } = await captureOutput(() => auth(["revoke-client"], deps));
870
+ expect(code).toBe(1);
871
+ expect(stderr).toContain("missing client_id");
872
+ } finally {
873
+ tmp.cleanup();
874
+ }
875
+ });
876
+
877
+ test("revoke-client <unknown> exits 1 with a friendly message", async () => {
878
+ const tmp = makeTmp();
879
+ try {
880
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
881
+ const { code, stderr } = await captureOutput(() => auth(["revoke-client", "no-such"], deps));
882
+ expect(code).toBe(1);
883
+ expect(stderr).toContain("no OAuth client");
884
+ } finally {
885
+ tmp.cleanup();
886
+ }
887
+ });
888
+
889
+ test("revoke-client deletes the client + cascades its grant + emits audit line", async () => {
890
+ const tmp = makeTmp();
891
+ try {
892
+ const db = openHubDb(tmp.dbPath);
893
+ let userId: string;
894
+ let clientId: string;
895
+ try {
896
+ const user = await createUser(db, "owner", "pw");
897
+ userId = user.id;
898
+ clientId = registerClient(db, {
899
+ redirectUris: ["https://app.example/cb"],
900
+ clientName: "MyApp",
901
+ }).client.clientId;
902
+ recordGrant(db, userId, clientId, ["vault:work:read"]);
903
+ expect(findGrant(db, userId, clientId)).not.toBeNull();
904
+ } finally {
905
+ db.close();
906
+ }
907
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
908
+ const { code, stdout } = await captureOutput(() => auth(["revoke-client", clientId], deps));
909
+ expect(code).toBe(0);
910
+ expect(stdout).toContain("Deregistered OAuth client");
911
+ // Audit line for greppability (matches the route's shape, remover_sub=cli).
912
+ expect(stdout).toContain(`client deleted: client_id=${clientId}`);
913
+ expect(stdout).toContain("client_name=MyApp");
914
+ expect(stdout).toContain("remover_sub=cli");
915
+
916
+ // Verify the cascade actually landed in the db.
917
+ const db2 = openHubDb(tmp.dbPath);
918
+ try {
919
+ expect(
920
+ db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(clientId),
921
+ ).toBeNull();
922
+ expect(findGrant(db2, userId, clientId)).toBeNull();
923
+ } finally {
924
+ db2.close();
925
+ }
926
+ } finally {
927
+ tmp.cleanup();
928
+ }
929
+ });
930
+ });
931
+
932
+ // closes #640 — OAuth client GC reaper. Dry-run by default; only provably-dead
933
+ // clients are reapable. The deep gate coverage lives in clients.test.ts; these
934
+ // exercise the CLI surface (dry-run safety, --apply, --json, empty case).
935
+ describe("parachute auth reap-clients", () => {
936
+ const DAY_MS = 24 * 60 * 60 * 1000;
937
+
938
+ /** Register a client `daysAgo` days before now (relative to wall clock). */
939
+ function oldClient(db: ReturnType<typeof openHubDb>, daysAgo: number, name?: string): string {
940
+ const when = new Date(Date.now() - daysAgo * DAY_MS);
941
+ return registerClient(db, {
942
+ redirectUris: ["https://app.example/cb"],
943
+ ...(name !== undefined ? { clientName: name } : {}),
944
+ now: () => when,
945
+ }).client.clientId;
946
+ }
947
+
948
+ test("empty case: clean message, exit 0, no false alarm", async () => {
949
+ const tmp = makeTmp();
950
+ try {
951
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
952
+ const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
953
+ expect(code).toBe(0);
954
+ expect(stdout).toContain("No abandoned clients to reap.");
955
+ } finally {
956
+ tmp.cleanup();
957
+ }
958
+ });
959
+
960
+ test("dry-run by DEFAULT lists candidates but deletes NOTHING", async () => {
961
+ const tmp = makeTmp();
962
+ let deadId: string;
963
+ try {
964
+ const db = openHubDb(tmp.dbPath);
965
+ try {
966
+ deadId = oldClient(db, 60, "DeadApp");
967
+ } finally {
968
+ db.close();
969
+ }
970
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
971
+ const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
972
+ expect(code).toBe(0);
973
+ expect(stdout).toContain(deadId);
974
+ expect(stdout).toContain("DeadApp");
975
+ expect(stdout).toContain("--apply");
976
+ expect(stdout).toContain("nothing deleted");
977
+
978
+ // Count unchanged: the client is still there.
979
+ const db2 = openHubDb(tmp.dbPath);
980
+ try {
981
+ const n = db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n;
982
+ expect(n).toBe(1);
983
+ } finally {
984
+ db2.close();
985
+ }
986
+ } finally {
987
+ tmp.cleanup();
988
+ }
989
+ });
990
+
991
+ test("--apply actually reaps + emits an audit line, dry-run is a no-op before it", async () => {
992
+ const tmp = makeTmp();
993
+ let deadId: string;
994
+ try {
995
+ const db = openHubDb(tmp.dbPath);
996
+ try {
997
+ deadId = oldClient(db, 60, "DeadApp");
998
+ } finally {
999
+ db.close();
1000
+ }
1001
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1002
+
1003
+ // Dry-run first: count before == count after.
1004
+ await captureOutput(() => auth(["reap-clients"], deps));
1005
+ const db2 = openHubDb(tmp.dbPath);
1006
+ try {
1007
+ expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
1008
+ } finally {
1009
+ db2.close();
1010
+ }
1011
+
1012
+ // --apply deletes.
1013
+ const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
1014
+ expect(code).toBe(0);
1015
+ expect(stdout).toContain("Reaped 1 abandoned OAuth client");
1016
+ expect(stdout).toContain(`client reaped: client_id=${deadId}`);
1017
+ expect(stdout).toContain("client_name=DeadApp");
1018
+
1019
+ const db3 = openHubDb(tmp.dbPath);
1020
+ try {
1021
+ expect(db3.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(0);
1022
+ } finally {
1023
+ db3.close();
1024
+ }
1025
+ } finally {
1026
+ tmp.cleanup();
1027
+ }
1028
+ });
1029
+
1030
+ test("NEVER reaps a client with a live grant (--apply leaves it intact)", async () => {
1031
+ const tmp = makeTmp();
1032
+ let liveId: string;
1033
+ let userId: string;
1034
+ try {
1035
+ const db = openHubDb(tmp.dbPath);
1036
+ try {
1037
+ const user = await createUser(db, "owner", "pw");
1038
+ userId = user.id;
1039
+ liveId = oldClient(db, 60, "GrantedApp");
1040
+ recordGrant(db, userId, liveId, ["vault:work:read"]);
1041
+ } finally {
1042
+ db.close();
1043
+ }
1044
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1045
+ const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
1046
+ expect(code).toBe(0);
1047
+ expect(stdout).toContain("No abandoned clients to reap.");
1048
+
1049
+ const db2 = openHubDb(tmp.dbPath);
1050
+ try {
1051
+ expect(
1052
+ db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(liveId),
1053
+ ).not.toBeNull();
1054
+ expect(findGrant(db2, userId, liveId)).not.toBeNull();
1055
+ } finally {
1056
+ db2.close();
1057
+ }
1058
+ } finally {
1059
+ tmp.cleanup();
1060
+ }
1061
+ });
1062
+
1063
+ test("NEVER reaps a freshly-registered client (inside the 30d floor)", async () => {
1064
+ const tmp = makeTmp();
1065
+ try {
1066
+ const db = openHubDb(tmp.dbPath);
1067
+ try {
1068
+ oldClient(db, 5); // 5 days old
1069
+ } finally {
1070
+ db.close();
1071
+ }
1072
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1073
+ const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
1074
+ expect(code).toBe(0);
1075
+ expect(stdout).toContain("No abandoned clients to reap.");
1076
+ } finally {
1077
+ tmp.cleanup();
1078
+ }
1079
+ });
1080
+
1081
+ test("--older-than tunes the age floor", async () => {
1082
+ const tmp = makeTmp();
1083
+ let id: string;
1084
+ try {
1085
+ const db = openHubDb(tmp.dbPath);
1086
+ try {
1087
+ id = oldClient(db, 15); // 15 days old
1088
+ } finally {
1089
+ db.close();
1090
+ }
1091
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1092
+ // Default 30d → not reapable.
1093
+ const def = await captureOutput(() => auth(["reap-clients"], deps));
1094
+ expect(def.stdout).toContain("No abandoned clients to reap.");
1095
+ // 10d floor → reapable.
1096
+ const tuned = await captureOutput(() => auth(["reap-clients", "--older-than", "10"], deps));
1097
+ expect(tuned.stdout).toContain(id);
1098
+ } finally {
1099
+ tmp.cleanup();
1100
+ }
1101
+ });
1102
+
1103
+ test("--json emits machine output; applied=false in dry-run, true with --apply", async () => {
1104
+ const tmp = makeTmp();
1105
+ let deadId: string;
1106
+ try {
1107
+ const db = openHubDb(tmp.dbPath);
1108
+ try {
1109
+ deadId = oldClient(db, 60, "JsonApp");
1110
+ } finally {
1111
+ db.close();
1112
+ }
1113
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1114
+ const dry = await captureOutput(() => auth(["reap-clients", "--json"], deps));
1115
+ expect(dry.code).toBe(0);
1116
+ const parsed = JSON.parse(dry.stdout) as {
1117
+ applied: boolean;
1118
+ count: number;
1119
+ clients: Array<{ clientId: string }>;
1120
+ };
1121
+ expect(parsed.applied).toBe(false);
1122
+ expect(parsed.count).toBe(1);
1123
+ expect(parsed.clients[0]?.clientId).toBe(deadId);
1124
+ // dry-run JSON deleted nothing.
1125
+ const db2 = openHubDb(tmp.dbPath);
1126
+ try {
1127
+ expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
1128
+ } finally {
1129
+ db2.close();
1130
+ }
1131
+
1132
+ const wet = await captureOutput(() => auth(["reap-clients", "--json", "--apply"], deps));
1133
+ const wetParsed = JSON.parse(wet.stdout) as {
1134
+ applied: boolean;
1135
+ clients: Array<{ reaped?: boolean }>;
1136
+ };
1137
+ expect(wetParsed.applied).toBe(true);
1138
+ expect(wetParsed.clients[0]?.reaped).toBe(true);
1139
+ } finally {
1140
+ tmp.cleanup();
1141
+ }
1142
+ });
1143
+
1144
+ test("a client with only an in-flight auth_code is NEVER reaped", async () => {
1145
+ const tmp = makeTmp();
1146
+ let id: string;
1147
+ try {
1148
+ const db = openHubDb(tmp.dbPath);
1149
+ try {
1150
+ const user = await createUser(db, "owner", "pw");
1151
+ id = oldClient(db, 60);
1152
+ issueAuthCode(db, {
1153
+ clientId: id,
1154
+ userId: user.id,
1155
+ redirectUri: "https://app.example/cb",
1156
+ scopes: ["vault:work:read"],
1157
+ codeChallenge: "x".repeat(43),
1158
+ codeChallengeMethod: "S256",
1159
+ });
1160
+ } finally {
1161
+ db.close();
1162
+ }
1163
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1164
+ const { stdout } = await captureOutput(() => auth(["reap-clients"], deps));
1165
+ expect(stdout).toContain("No abandoned clients to reap.");
1166
+ } finally {
1167
+ tmp.cleanup();
1168
+ }
1169
+ });
1170
+
1171
+ test("rejects --older-than 0 / negative / non-integer", async () => {
1172
+ const tmp = makeTmp();
1173
+ try {
1174
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
1175
+ for (const bad of ["0", "-5", "abc"]) {
1176
+ const { code, stderr } = await captureOutput(() =>
1177
+ auth(["reap-clients", "--older-than", bad], deps),
1178
+ );
1179
+ expect(code).toBe(1);
1180
+ expect(stderr).toContain("--older-than");
1181
+ }
1182
+ } finally {
1183
+ tmp.cleanup();
1184
+ }
1185
+ });
1186
+ });
1187
+
852
1188
  // closes #75 — operator-facing controls for the OAuth consent skip-list.
853
1189
  describe("parachute auth list-grants / revoke-grant", () => {
854
1190
  test("list-grants shows the seeding hint when no users exist", async () => {