@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
package/src/cli.ts CHANGED
@@ -22,6 +22,7 @@ import type { setup } from "./commands/setup.ts";
22
22
  import type { upgrade } from "./commands/upgrade.ts";
23
23
  import { ExposeStateError } from "./expose-state.ts";
24
24
  import {
25
+ doctorHelp,
25
26
  exposeHelp,
26
27
  initHelp,
27
28
  installHelp,
@@ -574,6 +575,34 @@ async function main(argv: string[]): Promise<number> {
574
575
  return await mod.status({ supervisor: {} });
575
576
  }
576
577
 
578
+ case "doctor": {
579
+ if (isHelpFlag(rest[0])) {
580
+ console.log(doctorHelp());
581
+ return 0;
582
+ }
583
+ const json = rest.includes("--json");
584
+ const fix = rest.includes("--fix");
585
+ const yes = rest.includes("--yes") || rest.includes("-y");
586
+ const known = new Set(["--json", "--fix", "--yes", "-y"]);
587
+ const unknown = rest.find((a) => !known.has(a));
588
+ if (unknown !== undefined) {
589
+ console.error(`parachute doctor: unknown argument "${unknown}"`);
590
+ console.error("usage: parachute doctor [--json] [--fix [--yes]]");
591
+ return 1;
592
+ }
593
+ if (json && fix) {
594
+ console.error("parachute doctor: --json and --fix are mutually exclusive");
595
+ return 1;
596
+ }
597
+ if (yes && !fix) {
598
+ console.error("parachute doctor: --yes has no effect without --fix");
599
+ return 1;
600
+ }
601
+ const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
602
+ if (!mod) return 1;
603
+ return await mod.doctor({ json, fix, yes });
604
+ }
605
+
577
606
  case "expose": {
578
607
  const hubExtract = extractHubOrigin(rest);
579
608
  if (hubExtract.error) {
package/src/clients.ts CHANGED
@@ -203,6 +203,170 @@ export function getClient(db: Database, clientId: string): OAuthClient | null {
203
203
  return row ? rowToClient(row) : null;
204
204
  }
205
205
 
206
+ /**
207
+ * Delete an OAuth client and everything that references it. Returns true when
208
+ * a client row was removed, false when no such client existed.
209
+ *
210
+ * Backs the RFC 7592 `DELETE /oauth/clients/<id>` deregistration endpoint
211
+ * (closes hub#640): parachute-surface fires a best-effort DELETE on every
212
+ * Notes/Claude remove-flow, so without this helper + route every reconnect
213
+ * orphaned a `clients` row in the operator's hub DB.
214
+ *
215
+ * CASCADE-BY-HAND. `auth_codes.client_id` and `grants.client_id` both
216
+ * `REFERENCES clients(client_id)` with NO `ON DELETE CASCADE`, and the hub
217
+ * opens its DB with `PRAGMA foreign_keys = ON` — so a bare
218
+ * `DELETE FROM clients` while a dependent auth_code or grant row exists
219
+ * throws a FOREIGN KEY constraint violation. We delete the dependents FIRST,
220
+ * then the client row, all inside a single transaction so a mid-cascade
221
+ * failure rolls the whole thing back (no half-deleted client). Modelled on
222
+ * `grants.revokeGrant` + the vault-delete cascade in `admin-vaults`.
223
+ *
224
+ * Note on tokens: access tokens already minted for this client are NOT
225
+ * touched here (the `tokens` registry is keyed by jti, not client_id, and
226
+ * carries no FK to `clients`). They expire on their own; an operator who
227
+ * wants to kill live sessions runs `/oauth/revoke` separately. Same posture
228
+ * `revokeGrant` documents.
229
+ */
230
+ export function deleteClient(db: Database, clientId: string): boolean {
231
+ return db.transaction(() => {
232
+ // Delete dependents first — FK ON, no cascade, so the client row can't
233
+ // go while an auth_code or grant still points at it.
234
+ db.prepare("DELETE FROM auth_codes WHERE client_id = ?").run(clientId);
235
+ db.prepare("DELETE FROM grants WHERE client_id = ?").run(clientId);
236
+ const res = db.prepare("DELETE FROM clients WHERE client_id = ?").run(clientId);
237
+ return res.changes > 0;
238
+ })();
239
+ }
240
+
241
+ /**
242
+ * Default reaper age threshold: 30 days. A client registered more recently
243
+ * than this is NEVER reaped — it may be mid-first-OAuth-flow (registered →
244
+ * user is on the consent screen → no grant/token/code written yet). The
245
+ * threshold buys generous headroom over the worst-case human consent latency.
246
+ */
247
+ export const DEFAULT_REAP_AGE_MS = 30 * 24 * 60 * 60 * 1000;
248
+
249
+ /** A client the reaper has proven dead. Carries enough to display a dry-run row. */
250
+ export interface ReapableClient {
251
+ clientId: string;
252
+ clientName: string | null;
253
+ registeredAt: string;
254
+ status: ClientStatus;
255
+ /** Whole days since registration, floored. For the dry-run/`--json` display. */
256
+ ageDays: number;
257
+ }
258
+
259
+ export interface FindReapableOpts {
260
+ /** Reap only clients older than this many ms. Defaults to 30 days. */
261
+ olderThanMs?: number;
262
+ /** Injectable clock — all age + liveness comparisons use this. */
263
+ now?: () => Date;
264
+ }
265
+
266
+ /**
267
+ * Find OAuth clients that are PROVABLY DEAD and safe to garbage-collect.
268
+ *
269
+ * THE SAFETY GATE (maximally conservative — see hub#640). DCR churn (Notes /
270
+ * Claude mint a fresh `client_id` per reconnect) accumulates dead `clients`
271
+ * rows; this reaps them. But a reaper that deletes a LIVE client breaks a
272
+ * user's active connection, and we've been bitten by over-eager pruning
273
+ * before (a derived-key divergence wrongly pruned still-wanted grants). So a
274
+ * client is reapable IFF **ALL** of:
275
+ *
276
+ * 1. ZERO rows in `grants` — no standing consent. A grant means the user
277
+ * approved this client; never touch it.
278
+ * 2. ZERO live tokens — no row in `tokens` with `expires_at > now AND
279
+ * revoked_at IS NULL`. A live token means an active session.
280
+ * 3. ZERO live auth_codes — no row in `auth_codes` with `expires_at > now`.
281
+ * A live code means an OAuth exchange is in flight RIGHT NOW.
282
+ * 4. `registered_at` older than `olderThanMs` (default 30d) — a freshly-
283
+ * registered client may be mid-first-flow before any of the above rows
284
+ * land.
285
+ *
286
+ * This reaps abandoned-never-consented `pending` DCR registrations and fully-
287
+ * dead (revoked/expired) clients, and NEVER a client with a live grant, live
288
+ * token, or in-flight code. Pure read — touches nothing. `reapClient` does the
289
+ * deletion.
290
+ *
291
+ * Implemented as a single `NOT EXISTS` SELECT so the gate is evaluated
292
+ * atomically per row against one consistent DB snapshot (no read-modify-write
293
+ * window where a token could land between the check and a later delete — the
294
+ * delete re-derives nothing; callers re-run the gate, see below).
295
+ */
296
+ export function findReapableClients(db: Database, opts: FindReapableOpts = {}): ReapableClient[] {
297
+ const now = opts.now?.() ?? new Date();
298
+ const olderThanMs = opts.olderThanMs ?? DEFAULT_REAP_AGE_MS;
299
+ const nowIso = now.toISOString();
300
+ // Registered strictly before this cutoff to qualify on age (condition 4).
301
+ const cutoffIso = new Date(now.getTime() - olderThanMs).toISOString();
302
+
303
+ const rows = db
304
+ .query<Row, [string, string, string]>(
305
+ `SELECT c.* FROM clients c
306
+ WHERE c.registered_at < ?1
307
+ AND NOT EXISTS (SELECT 1 FROM grants g WHERE g.client_id = c.client_id)
308
+ AND NOT EXISTS (
309
+ SELECT 1 FROM tokens t
310
+ WHERE t.client_id = c.client_id
311
+ AND t.revoked_at IS NULL
312
+ AND t.expires_at > ?2
313
+ )
314
+ AND NOT EXISTS (
315
+ SELECT 1 FROM auth_codes a
316
+ WHERE a.client_id = c.client_id
317
+ AND a.expires_at > ?3
318
+ )
319
+ ORDER BY c.registered_at`,
320
+ )
321
+ .all(cutoffIso, nowIso, nowIso);
322
+
323
+ return rows.map((r) => {
324
+ const client = rowToClient(r);
325
+ const ageMs = now.getTime() - new Date(client.registeredAt).getTime();
326
+ return {
327
+ clientId: client.clientId,
328
+ clientName: client.clientName,
329
+ registeredAt: client.registeredAt,
330
+ status: client.status,
331
+ ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
332
+ };
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Garbage-collect a single PROVABLY-DEAD client. Wraps the `deleteClient`
338
+ * cascade (grants + auth_codes + client row) AND a `DELETE FROM tokens` for
339
+ * the same client_id, all in ONE transaction.
340
+ *
341
+ * Why also delete tokens here when `deleteClient` deliberately leaves them?
342
+ * `deleteClient` is the general deregistration path — it can run against a
343
+ * client that still has LIVE tokens (an operator force-removing an app mid-
344
+ * session), so it leaves the `tokens` rows to expire on their own (they carry
345
+ * no FK to `clients`). The reaper, by contrast, only ever runs on a client the
346
+ * gate has PROVEN has no live tokens — every remaining `tokens` row for it is
347
+ * already expired or revoked (dead). Deleting them completes the GC instead of
348
+ * leaving dead registry rows behind forever. Callers MUST pass only client_ids
349
+ * returned by `findReapableClients` (the safety gate); see the CLI's reap loop.
350
+ *
351
+ * TOCTOU note: `reapClient` does NOT re-check the gate — it trusts the caller's
352
+ * list. There is a theoretical window where a grant/token lands between the
353
+ * `findReapableClients` snapshot and this delete, reaping a client that just
354
+ * went live. On the single-operator hub this is cosmetically impossible: the
355
+ * OAuth flow that would create a grant runs synchronously on the same SQLite
356
+ * connection and never races a CLI invocation. Re-running `findReapableClients`
357
+ * immediately before each call would close the window at the cost of N extra
358
+ * round-trips; not worth it under the current trust model.
359
+ */
360
+ export function reapClient(db: Database, clientId: string): boolean {
361
+ return db.transaction(() => {
362
+ // Dead token rows first — the gate proved none are live; this completes
363
+ // the GC (deleteClient leaves tokens alone for the general delete path).
364
+ db.prepare("DELETE FROM tokens WHERE client_id = ?").run(clientId);
365
+ // The cascade: auth_codes + grants (both dead per the gate) + the client.
366
+ return deleteClient(db, clientId);
367
+ })();
368
+ }
369
+
206
370
  /**
207
371
  * Returns the registered redirect URI matching `candidate` exactly, or throws.
208
372
  * RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
@@ -25,7 +25,16 @@
25
25
 
26
26
  import { join } from "node:path";
27
27
  import { createInterface } from "node:readline/promises";
28
- import { approveClient, getClient, listClientsByStatus } from "../clients.ts";
28
+ import {
29
+ DEFAULT_REAP_AGE_MS,
30
+ type ReapableClient,
31
+ approveClient,
32
+ deleteClient,
33
+ findReapableClients,
34
+ getClient,
35
+ listClientsByStatus,
36
+ reapClient,
37
+ } from "../clients.ts";
29
38
  import { CONFIG_DIR } from "../config.ts";
30
39
  import { readExposeState } from "../expose-state.ts";
31
40
  import { listGrantsForUser, revokeGrant } from "../grants.ts";
@@ -96,6 +105,8 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
96
105
  "revoke-token",
97
106
  "pending-clients",
98
107
  "approve-client",
108
+ "revoke-client",
109
+ "reap-clients",
99
110
  "list-grants",
100
111
  "revoke-grant",
101
112
  ]);
@@ -135,6 +146,15 @@ Usage:
135
146
  exits 0.
136
147
  parachute auth pending-clients List OAuth clients awaiting approval
137
148
  parachute auth approve-client <id> Approve a pending OAuth client
149
+ parachute auth revoke-client <id> Deregister (delete) an OAuth client,
150
+ cascading its grants + auth codes
151
+ (RFC 7592 deregistration)
152
+ parachute auth reap-clients [--older-than <days>] [--apply] [--json]
153
+ Garbage-collect abandoned/dead OAuth
154
+ clients (DCR reconnect churn). Dry-run
155
+ by default — lists what WOULD be reaped
156
+ and deletes nothing; pass --apply to
157
+ actually delete.
138
158
  parachute auth list-grants [--username <name>]
139
159
  Show OAuth scope grants on record
140
160
  parachute auth revoke-grant <client_id> [--username <name>]
@@ -238,6 +258,19 @@ and cannot OAuth until you run \`parachute auth approve-client <id>\`.
238
258
  First-party install flows that present \`Authorization: Bearer
239
259
  <operator-token>\` with \`hub:admin\` scope land as 'approved' immediately.
240
260
 
261
+ reap-clients garbage-collects abandoned/dead OAuth clients (closes #640).
262
+ DCR churn — Notes/Claude mint a FRESH client_id per reconnect — accumulates
263
+ dead \`clients\` rows in the operator's hub.db. reap-clients deletes only the
264
+ PROVABLY-DEAD ones: a client is reapable iff it has ZERO grants (no standing
265
+ consent), ZERO live tokens (none unexpired+unrevoked), ZERO in-flight auth
266
+ codes, AND was registered more than --older-than days ago (default 30). A
267
+ client with a live grant, a live token, or an in-flight code is NEVER touched.
268
+
269
+ Dry-run by DEFAULT: prints the clients that WOULD be reaped and deletes
270
+ nothing — review before deleting. Pass \`--apply\` to actually delete them
271
+ (grants + auth codes + dead tokens cascade). \`--older-than <days>\` tunes the
272
+ age floor; \`--json\` emits machine-readable output.
273
+
241
274
  list-grants + revoke-grant manage the OAuth consent skip-list (closes
242
275
  #75). When you approve a scope-set on the consent screen, the hub
243
276
  records it so re-running the same flow goes straight to the auth-code
@@ -619,6 +652,217 @@ function runApproveClient(args: readonly string[], deps: AuthDeps): number {
619
652
  }
620
653
  }
621
654
 
655
+ /**
656
+ * `parachute auth revoke-client <id>` — RFC 7592 deregistration from the
657
+ * terminal. The CLI complement to the `DELETE /oauth/clients/<id>` route
658
+ * parachute-surface fires automatically; an operator runs this to clean up
659
+ * an orphaned / stale client by hand (closes hub#640). Calls the
660
+ * `deleteClient` cascade helper directly (same db, no round-trip through the
661
+ * HTTP route) and emits the same `client deleted:` audit line the route does
662
+ * so browser-driven and terminal-driven deletions are uniformly greppable in
663
+ * hub.log. `remover_sub=cli` distinguishes the terminal path (which has no
664
+ * authenticated JWT subject) from the route's `remover_sub=<jwt sub>`.
665
+ */
666
+ function runRevokeClient(args: readonly string[], deps: AuthDeps): number {
667
+ const clientId = args[0];
668
+ if (!clientId) {
669
+ console.error("parachute auth revoke-client: missing client_id argument");
670
+ console.error("usage: parachute auth revoke-client <client_id>");
671
+ return 1;
672
+ }
673
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
674
+ try {
675
+ // Read the name before deleting so the audit line + confirmation can
676
+ // carry it. getClient also gives us the "exists?" answer for the 404 path.
677
+ const before = getClient(db, clientId);
678
+ const removed = deleteClient(db, clientId);
679
+ if (!removed) {
680
+ console.error(`no OAuth client registered with client_id "${clientId}"`);
681
+ return 1;
682
+ }
683
+ console.log(
684
+ `client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=cli`,
685
+ );
686
+ console.log(`Deregistered OAuth client "${clientId}" (grants + auth codes cascaded).`);
687
+ return 0;
688
+ } finally {
689
+ db.close();
690
+ }
691
+ }
692
+
693
+ interface ReapClientsFlags {
694
+ /** Age floor in days. Defaults to DEFAULT_REAP_AGE_MS / day. */
695
+ olderThanDays?: number;
696
+ /** --apply: actually delete. Without it, dry-run (default). */
697
+ apply: boolean;
698
+ /** --json: machine-readable output. */
699
+ json: boolean;
700
+ error?: string;
701
+ }
702
+
703
+ function parseReapClientsFlags(args: readonly string[]): ReapClientsFlags {
704
+ let olderThanDays: number | undefined;
705
+ let apply = false;
706
+ let json = false;
707
+ const parseDays = (raw: string | undefined): number | undefined | "error" => {
708
+ if (!raw) return "error";
709
+ // Whole, positive day count. Reject 0 / negatives / non-integers — an
710
+ // --older-than 0 would reap freshly-registered clients (the exact thing
711
+ // condition 4 of the gate exists to prevent).
712
+ if (!/^\d+$/.test(raw)) return "error";
713
+ const n = Number.parseInt(raw, 10);
714
+ if (!Number.isFinite(n) || n <= 0) return "error";
715
+ return n;
716
+ };
717
+ for (let i = 0; i < args.length; i++) {
718
+ const a = args[i];
719
+ if (a === "--older-than") {
720
+ const parsed = parseDays(args[++i]);
721
+ if (parsed === "error")
722
+ return { apply, json, error: "--older-than requires a positive integer (days)" };
723
+ olderThanDays = parsed;
724
+ } else if (a?.startsWith("--older-than=")) {
725
+ const parsed = parseDays(a.slice("--older-than=".length));
726
+ if (parsed === "error")
727
+ return { apply, json, error: "--older-than requires a positive integer (days)" };
728
+ olderThanDays = parsed;
729
+ } else if (a === "--apply") {
730
+ apply = true;
731
+ } else if (a === "--json") {
732
+ json = true;
733
+ } else {
734
+ return { apply, json, error: `unknown flag "${a}"` };
735
+ }
736
+ }
737
+ return olderThanDays !== undefined ? { olderThanDays, apply, json } : { apply, json };
738
+ }
739
+
740
+ /**
741
+ * `parachute auth reap-clients` — garbage-collect abandoned/dead OAuth clients
742
+ * (closes hub#640). DCR churn (Notes/Claude mint a fresh client_id per
743
+ * reconnect) accumulates dead `clients` rows; this reaps the PROVABLY-DEAD
744
+ * ones via the conservative `findReapableClients` gate (zero grants, zero live
745
+ * tokens, zero in-flight auth_codes, older than the age floor).
746
+ *
747
+ * DRY-RUN BY DEFAULT — the load-bearing safety choice. A reaper that deletes a
748
+ * live client breaks a user's active connection, so the default run only
749
+ * REPORTS what it would delete and touches nothing; `--apply` performs the
750
+ * deletion. The same `findReapableClients` snapshot drives both the dry-run
751
+ * listing and the --apply loop, so what's printed is exactly what gets reaped.
752
+ */
753
+ function runReapClients(args: readonly string[], deps: AuthDeps): number {
754
+ const flags = parseReapClientsFlags(args);
755
+ if (flags.error) {
756
+ console.error(`parachute auth reap-clients: ${flags.error}`);
757
+ console.error("usage: parachute auth reap-clients [--older-than <days>] [--apply] [--json]");
758
+ return 1;
759
+ }
760
+ const olderThanMs =
761
+ flags.olderThanDays !== undefined
762
+ ? flags.olderThanDays * 24 * 60 * 60 * 1000
763
+ : DEFAULT_REAP_AGE_MS;
764
+ const olderThanDays = olderThanMs / (24 * 60 * 60 * 1000);
765
+
766
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
767
+ try {
768
+ const reapable = findReapableClients(db, { olderThanMs });
769
+
770
+ if (flags.json) {
771
+ // Machine-readable: the candidate set + whether we actually deleted.
772
+ // In --apply mode each row carries `reaped: true` after deletion. Audit
773
+ // lines route to stderr so stdout stays pure JSON for piping into `jq`.
774
+ const deleted = flags.apply ? applyReap(db, reapable, console.error) : [];
775
+ console.log(
776
+ JSON.stringify(
777
+ {
778
+ applied: flags.apply,
779
+ olderThanDays,
780
+ count: reapable.length,
781
+ clients: reapable.map((c) => ({
782
+ clientId: c.clientId,
783
+ clientName: c.clientName,
784
+ registeredAt: c.registeredAt,
785
+ status: c.status,
786
+ ageDays: c.ageDays,
787
+ ...(flags.apply ? { reaped: deleted.includes(c.clientId) } : {}),
788
+ })),
789
+ },
790
+ null,
791
+ 2,
792
+ ),
793
+ );
794
+ return 0;
795
+ }
796
+
797
+ if (reapable.length === 0) {
798
+ console.log("No abandoned clients to reap.");
799
+ return 0;
800
+ }
801
+
802
+ printReapTable(reapable);
803
+
804
+ if (!flags.apply) {
805
+ const plural = reapable.length === 1 ? "client" : "clients";
806
+ console.log("");
807
+ console.log(
808
+ `Dry run — nothing deleted. Run with --apply to delete ${reapable.length} ${plural}.`,
809
+ );
810
+ return 0;
811
+ }
812
+
813
+ const deleted = applyReap(db, reapable);
814
+ console.log("");
815
+ const plural = deleted.length === 1 ? "client" : "clients";
816
+ console.log(
817
+ `Reaped ${deleted.length} abandoned OAuth ${plural} (grants + auth codes + dead tokens cascaded).`,
818
+ );
819
+ return 0;
820
+ } finally {
821
+ db.close();
822
+ }
823
+ }
824
+
825
+ /** Print the dry-run / apply table of reapable clients. */
826
+ function printReapTable(reapable: readonly ReapableClient[]): void {
827
+ console.log(
828
+ "CLIENT_ID NAME STATUS AGE_DAYS REGISTERED",
829
+ );
830
+ for (const c of reapable) {
831
+ const id = c.clientId.padEnd(36).slice(0, 36);
832
+ const name = (c.clientName ?? "").padEnd(20).slice(0, 20);
833
+ const status = c.status.padEnd(8).slice(0, 8);
834
+ const age = String(c.ageDays).padStart(8);
835
+ console.log(`${id} ${name} ${status} ${age} ${c.registeredAt}`);
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Delete each reapable client via `reapClient`, emitting one audit line per
841
+ * deletion (`client reaped:` — greppable in hub.log alongside `client
842
+ * deleted:` from the manual revoke path). Returns the client_ids actually
843
+ * removed. Callers pass ONLY the `findReapableClients` output, so every id
844
+ * here has already cleared the safety gate.
845
+ *
846
+ * `audit` is the sink for the per-deletion lines — `console.log` for the human
847
+ * table path, `console.error` for `--json` (keeps stdout pure JSON for `jq`).
848
+ */
849
+ function applyReap(
850
+ db: ReturnType<typeof openHubDb>,
851
+ reapable: readonly ReapableClient[],
852
+ audit: (line: string) => void = console.log,
853
+ ): string[] {
854
+ const deleted: string[] = [];
855
+ for (const c of reapable) {
856
+ if (reapClient(db, c.clientId)) {
857
+ deleted.push(c.clientId);
858
+ audit(
859
+ `client reaped: client_id=${c.clientId} client_name=${c.clientName ?? ""} age_days=${c.ageDays} status=${c.status}`,
860
+ );
861
+ }
862
+ }
863
+ return deleted;
864
+ }
865
+
622
866
  interface UsernameFlag {
623
867
  username?: string;
624
868
  rest: string[];
@@ -1405,6 +1649,24 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
1405
1649
  return 1;
1406
1650
  }
1407
1651
  }
1652
+ if (sub === "revoke-client") {
1653
+ try {
1654
+ return runRevokeClient(args.slice(1), normalized);
1655
+ } catch (err) {
1656
+ const msg = err instanceof Error ? err.message : String(err);
1657
+ console.error(`parachute auth revoke-client: ${msg}`);
1658
+ return 1;
1659
+ }
1660
+ }
1661
+ if (sub === "reap-clients") {
1662
+ try {
1663
+ return runReapClients(args.slice(1), normalized);
1664
+ } catch (err) {
1665
+ const msg = err instanceof Error ? err.message : String(err);
1666
+ console.error(`parachute auth reap-clients: ${msg}`);
1667
+ return 1;
1668
+ }
1669
+ }
1408
1670
  if (sub === "list-grants") {
1409
1671
  try {
1410
1672
  return runListGrants(args.slice(1), normalized);