@openparachute/hub 0.7.4-rc.9 → 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.
- package/package.json +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +207 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +14 -1
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/api-users.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface ApiUsersDeps {
|
|
|
69
69
|
db: Database;
|
|
70
70
|
/** Hub origin — JWT `iss` validation. */
|
|
71
71
|
issuer: string;
|
|
72
|
+
/**
|
|
73
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
74
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
75
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
76
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
77
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
78
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
79
|
+
*/
|
|
80
|
+
knownIssuers?: readonly string[];
|
|
72
81
|
/** Override services.json path. Defaults to `~/.parachute/services.json`. */
|
|
73
82
|
manifestPath?: string;
|
|
74
83
|
}
|
|
@@ -118,7 +127,7 @@ export async function handleListUsers(req: Request, deps: ApiUsersDeps): Promise
|
|
|
118
127
|
return jsonError(405, "method_not_allowed", "use GET");
|
|
119
128
|
}
|
|
120
129
|
try {
|
|
121
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
130
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
122
131
|
} catch (err) {
|
|
123
132
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
124
133
|
}
|
|
@@ -262,7 +271,7 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
|
|
|
262
271
|
return jsonError(405, "method_not_allowed", "use POST");
|
|
263
272
|
}
|
|
264
273
|
try {
|
|
265
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
274
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
266
275
|
} catch (err) {
|
|
267
276
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
268
277
|
}
|
|
@@ -358,7 +367,7 @@ export async function handleDeleteUser(
|
|
|
358
367
|
return jsonError(405, "method_not_allowed", "use DELETE");
|
|
359
368
|
}
|
|
360
369
|
try {
|
|
361
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
370
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
362
371
|
} catch (err) {
|
|
363
372
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
364
373
|
}
|
|
@@ -441,7 +450,7 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
|
|
|
441
450
|
return jsonError(405, "method_not_allowed", "use GET");
|
|
442
451
|
}
|
|
443
452
|
try {
|
|
444
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
453
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
445
454
|
} catch (err) {
|
|
446
455
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
447
456
|
}
|
|
@@ -571,7 +580,7 @@ export async function handleUpdateUserVaults(
|
|
|
571
580
|
return jsonError(405, "method_not_allowed", "use PATCH");
|
|
572
581
|
}
|
|
573
582
|
try {
|
|
574
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
583
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
575
584
|
} catch (err) {
|
|
576
585
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
577
586
|
}
|
|
@@ -760,7 +769,7 @@ export async function handleResetUserPassword(
|
|
|
760
769
|
return jsonError(405, "method_not_allowed", "use POST");
|
|
761
770
|
}
|
|
762
771
|
try {
|
|
763
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
772
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
764
773
|
} catch (err) {
|
|
765
774
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
766
775
|
}
|
package/src/api-vault-caps.ts
CHANGED
|
@@ -39,6 +39,15 @@ export interface ApiVaultCapsDeps {
|
|
|
39
39
|
db: Database;
|
|
40
40
|
/** Hub origin — JWT `iss` validation. */
|
|
41
41
|
issuer: string;
|
|
42
|
+
/**
|
|
43
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
44
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
45
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
46
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
47
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
48
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
49
|
+
*/
|
|
50
|
+
knownIssuers?: readonly string[];
|
|
42
51
|
/** Override services.json path. Defaults to `~/.parachute/services.json`. */
|
|
43
52
|
manifestPath?: string;
|
|
44
53
|
}
|
|
@@ -71,7 +80,7 @@ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps):
|
|
|
71
80
|
return jsonError(405, "method_not_allowed", "use GET");
|
|
72
81
|
}
|
|
73
82
|
try {
|
|
74
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
83
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
75
84
|
} catch (err) {
|
|
76
85
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
77
86
|
}
|
|
@@ -174,7 +183,7 @@ export async function handleSetVaultCap(
|
|
|
174
183
|
return jsonError(405, "method_not_allowed", "use PUT");
|
|
175
184
|
}
|
|
176
185
|
try {
|
|
177
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
186
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
178
187
|
} catch (err) {
|
|
179
188
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
180
189
|
}
|
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
|
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";
|
|
@@ -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);
|