@openparachute/hub 0.7.4-rc.16 → 0.7.4-rc.18
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.
- package/package.json +1 -1
- package/src/__tests__/auth.test.ts +267 -0
- package/src/__tests__/clients.test.ts +209 -0
- package/src/__tests__/doctor.test.ts +292 -1
- package/src/cli.ts +14 -3
- package/src/clients.ts +129 -0
- package/src/commands/auth.ts +212 -1
- package/src/commands/doctor.ts +358 -2
- package/src/help.ts +17 -2
- package/src/hub-db.ts +14 -0
package/src/commands/auth.ts
CHANGED
|
@@ -25,7 +25,16 @@
|
|
|
25
25
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
|
-
import {
|
|
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";
|
|
@@ -97,6 +106,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
|
97
106
|
"pending-clients",
|
|
98
107
|
"approve-client",
|
|
99
108
|
"revoke-client",
|
|
109
|
+
"reap-clients",
|
|
100
110
|
"list-grants",
|
|
101
111
|
"revoke-grant",
|
|
102
112
|
]);
|
|
@@ -139,6 +149,12 @@ Usage:
|
|
|
139
149
|
parachute auth revoke-client <id> Deregister (delete) an OAuth client,
|
|
140
150
|
cascading its grants + auth codes
|
|
141
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.
|
|
142
158
|
parachute auth list-grants [--username <name>]
|
|
143
159
|
Show OAuth scope grants on record
|
|
144
160
|
parachute auth revoke-grant <client_id> [--username <name>]
|
|
@@ -242,6 +258,19 @@ and cannot OAuth until you run \`parachute auth approve-client <id>\`.
|
|
|
242
258
|
First-party install flows that present \`Authorization: Bearer
|
|
243
259
|
<operator-token>\` with \`hub:admin\` scope land as 'approved' immediately.
|
|
244
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
|
+
|
|
245
274
|
list-grants + revoke-grant manage the OAuth consent skip-list (closes
|
|
246
275
|
#75). When you approve a scope-set on the consent screen, the hub
|
|
247
276
|
records it so re-running the same flow goes straight to the auth-code
|
|
@@ -661,6 +690,179 @@ function runRevokeClient(args: readonly string[], deps: AuthDeps): number {
|
|
|
661
690
|
}
|
|
662
691
|
}
|
|
663
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
|
+
|
|
664
866
|
interface UsernameFlag {
|
|
665
867
|
username?: string;
|
|
666
868
|
rest: string[];
|
|
@@ -1456,6 +1658,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1456
1658
|
return 1;
|
|
1457
1659
|
}
|
|
1458
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
|
+
}
|
|
1459
1670
|
if (sub === "list-grants") {
|
|
1460
1671
|
try {
|
|
1461
1672
|
return runListGrants(args.slice(1), normalized);
|