@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.16",
3
+ "version": "0.7.4-rc.18",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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)", () => {
@@ -918,6 +929,262 @@ describe("parachute auth revoke-client", () => {
918
929
  });
919
930
  });
920
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
+
921
1188
  // closes #75 — operator-facing controls for the OAuth consent skip-list.
922
1189
  describe("parachute auth list-grants / revoke-grant", () => {
923
1190
  test("list-grants shows the seeding hint when no users exist", async () => {
@@ -8,9 +8,11 @@ import {
8
8
  approveClient,
9
9
  deleteClient,
10
10
  expandRedirectUrisForHubOrigins,
11
+ findReapableClients,
11
12
  getClient,
12
13
  isValidRedirectUri,
13
14
  listClientsByStatus,
15
+ reapClient,
14
16
  registerClient,
15
17
  requireRegisteredRedirectUri,
16
18
  verifyClientSecret,
@@ -430,6 +432,213 @@ describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
430
432
  });
431
433
  });
432
434
 
435
+ describe("findReapableClients / reapClient — the GC safety gate (hub#640)", () => {
436
+ const DAY_MS = 24 * 60 * 60 * 1000;
437
+ // A fixed "now" so age math is deterministic. All planted clients register
438
+ // 60 days before this unless a test overrides.
439
+ const NOW = new Date("2026-06-27T00:00:00Z");
440
+ const now = () => NOW;
441
+ const OLD = new Date(NOW.getTime() - 60 * DAY_MS); // 60d old → past 30d floor
442
+ const oldNow = () => OLD;
443
+
444
+ /** Plant a `tokens` row with precise expiry/revocation for a client. */
445
+ function plantToken(
446
+ db: ReturnType<typeof openHubDb>,
447
+ opts: {
448
+ clientId: string;
449
+ userId: string;
450
+ jti: string;
451
+ expiresAt: string;
452
+ revokedAt?: string | null;
453
+ },
454
+ ): void {
455
+ db.prepare(
456
+ `INSERT INTO tokens
457
+ (jti, user_id, client_id, scopes, refresh_token_hash, family_id,
458
+ expires_at, revoked_at, created_at, created_via, subject)
459
+ VALUES (?, ?, ?, '', NULL, NULL, ?, ?, ?, 'oauth_refresh', NULL)`,
460
+ ).run(
461
+ opts.jti,
462
+ opts.userId,
463
+ opts.clientId,
464
+ opts.expiresAt,
465
+ opts.revokedAt ?? null,
466
+ OLD.toISOString(),
467
+ );
468
+ }
469
+
470
+ test("a genuinely-dead old client IS reaped (no grants, only expired/revoked tokens, no live codes)", async () => {
471
+ const { db, cleanup } = makeDb();
472
+ try {
473
+ const user = await createUser(db, "owner", "pw");
474
+ const dead = registerClient(db, { redirectUris: ["https://dead.example/cb"], now: oldNow });
475
+ const id = dead.client.clientId;
476
+ // An expired token + a revoked token — both dead.
477
+ plantToken(db, {
478
+ clientId: id,
479
+ userId: user.id,
480
+ jti: "jti-expired",
481
+ expiresAt: new Date(NOW.getTime() - DAY_MS).toISOString(),
482
+ });
483
+ plantToken(db, {
484
+ clientId: id,
485
+ userId: user.id,
486
+ jti: "jti-revoked",
487
+ expiresAt: new Date(NOW.getTime() + DAY_MS).toISOString(), // unexpired...
488
+ revokedAt: new Date(NOW.getTime() - DAY_MS).toISOString(), // ...but revoked → dead
489
+ });
490
+
491
+ const reapable = findReapableClients(db, { now });
492
+ expect(reapable.map((c) => c.clientId)).toEqual([id]);
493
+ expect(reapable[0]?.ageDays).toBe(60);
494
+
495
+ // reapClient removes the client AND its dead token rows.
496
+ expect(reapClient(db, id)).toBe(true);
497
+ expect(getClient(db, id)).toBeNull();
498
+ expect(db.query("SELECT jti FROM tokens WHERE client_id = ?").all(id)).toEqual([]);
499
+ } finally {
500
+ cleanup();
501
+ }
502
+ });
503
+
504
+ test("a client WITH a live grant is NEVER reaped, even when old", async () => {
505
+ const { db, cleanup } = makeDb();
506
+ try {
507
+ const user = await createUser(db, "owner", "pw");
508
+ const c = registerClient(db, { redirectUris: ["https://granted.example/cb"], now: oldNow });
509
+ recordGrant(db, user.id, c.client.clientId, ["vault:work:read"]);
510
+ const reapable = findReapableClients(db, { now });
511
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
512
+ } finally {
513
+ cleanup();
514
+ }
515
+ });
516
+
517
+ test("a client with a live (unexpired, unrevoked) token is NEVER reaped, even when old", async () => {
518
+ const { db, cleanup } = makeDb();
519
+ try {
520
+ const user = await createUser(db, "owner", "pw");
521
+ const c = registerClient(db, { redirectUris: ["https://live.example/cb"], now: oldNow });
522
+ plantToken(db, {
523
+ clientId: c.client.clientId,
524
+ userId: user.id,
525
+ jti: "jti-live",
526
+ expiresAt: new Date(NOW.getTime() + 7 * DAY_MS).toISOString(),
527
+ revokedAt: null,
528
+ });
529
+ const reapable = findReapableClients(db, { now });
530
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
531
+ } finally {
532
+ cleanup();
533
+ }
534
+ });
535
+
536
+ test("a client with an unexpired auth_code is NEVER reaped (in-flight OAuth)", async () => {
537
+ const { db, cleanup } = makeDb();
538
+ try {
539
+ const user = await createUser(db, "owner", "pw");
540
+ const c = registerClient(db, { redirectUris: ["https://inflight.example/cb"], now: oldNow });
541
+ // issueAuthCode mints a code expiring AUTH_CODE_TTL after `now` → unexpired.
542
+ issueAuthCode(db, {
543
+ clientId: c.client.clientId,
544
+ userId: user.id,
545
+ redirectUri: "https://inflight.example/cb",
546
+ scopes: ["vault:work:read"],
547
+ codeChallenge: "x".repeat(43),
548
+ codeChallengeMethod: "S256",
549
+ now,
550
+ });
551
+ const reapable = findReapableClients(db, { now });
552
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
553
+ } finally {
554
+ cleanup();
555
+ }
556
+ });
557
+
558
+ test("a freshly-registered client is NEVER reaped, even with zero grants/tokens/codes", () => {
559
+ const { db, cleanup } = makeDb();
560
+ try {
561
+ // Registered 5 days ago — inside the default 30d floor.
562
+ const fresh = registerClient(db, {
563
+ redirectUris: ["https://fresh.example/cb"],
564
+ now: () => new Date(NOW.getTime() - 5 * DAY_MS),
565
+ });
566
+ const reapable = findReapableClients(db, { now });
567
+ expect(reapable.map((x) => x.clientId)).not.toContain(fresh.client.clientId);
568
+ } finally {
569
+ cleanup();
570
+ }
571
+ });
572
+
573
+ test("an expired auth_code does NOT protect a client (only LIVE codes do)", async () => {
574
+ const { db, cleanup } = makeDb();
575
+ try {
576
+ const user = await createUser(db, "owner", "pw");
577
+ const c = registerClient(db, { redirectUris: ["https://stale.example/cb"], now: oldNow });
578
+ // Code issued 60d ago → long expired.
579
+ issueAuthCode(db, {
580
+ clientId: c.client.clientId,
581
+ userId: user.id,
582
+ redirectUri: "https://stale.example/cb",
583
+ scopes: ["vault:work:read"],
584
+ codeChallenge: "x".repeat(43),
585
+ codeChallengeMethod: "S256",
586
+ now: oldNow,
587
+ });
588
+ const reapable = findReapableClients(db, { now });
589
+ expect(reapable.map((x) => x.clientId)).toContain(c.client.clientId);
590
+ } finally {
591
+ cleanup();
592
+ }
593
+ });
594
+
595
+ test("--older-than threshold is honored (a 20d-old client falls under a 10d floor but not 30d)", () => {
596
+ const { db, cleanup } = makeDb();
597
+ try {
598
+ const c = registerClient(db, {
599
+ redirectUris: ["https://midage.example/cb"],
600
+ now: () => new Date(NOW.getTime() - 20 * DAY_MS),
601
+ });
602
+ // Default 30d floor → not yet reapable.
603
+ expect(findReapableClients(db, { now }).map((x) => x.clientId)).not.toContain(
604
+ c.client.clientId,
605
+ );
606
+ // 10d floor → now reapable.
607
+ expect(
608
+ findReapableClients(db, { now, olderThanMs: 10 * DAY_MS }).map((x) => x.clientId),
609
+ ).toContain(c.client.clientId);
610
+ } finally {
611
+ cleanup();
612
+ }
613
+ });
614
+
615
+ test("reapClient on a still-protected client deletes nothing the gate excluded (callers pass only gated ids)", async () => {
616
+ // Belt-and-suspenders: reapClient itself doesn't re-check the gate (the
617
+ // caller is contractually responsible). This asserts the realistic flow —
618
+ // a live client is simply NOT in the findReapableClients output, so the
619
+ // apply loop never calls reapClient on it.
620
+ const { db, cleanup } = makeDb();
621
+ try {
622
+ const user = await createUser(db, "owner", "pw");
623
+ const live = registerClient(db, { redirectUris: ["https://keep.example/cb"], now: oldNow });
624
+ recordGrant(db, user.id, live.client.clientId, ["vault:work:read"]);
625
+ const dead = registerClient(db, { redirectUris: ["https://gone.example/cb"], now: oldNow });
626
+
627
+ const reapable = findReapableClients(db, { now });
628
+ const ids = reapable.map((x) => x.clientId);
629
+ expect(ids).toEqual([dead.client.clientId]);
630
+ for (const c of reapable) reapClient(db, c.clientId);
631
+
632
+ // Live client + its grant survive; only the dead one is gone.
633
+ expect(getClient(db, live.client.clientId)).not.toBeNull();
634
+ expect(findGrant(db, user.id, live.client.clientId)).not.toBeNull();
635
+ expect(getClient(db, dead.client.clientId)).toBeNull();
636
+ } finally {
637
+ cleanup();
638
+ }
639
+ });
640
+ });
641
+
433
642
  describe("isValidRedirectUri", () => {
434
643
  test("accepts http and https", () => {
435
644
  expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);