@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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 +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- 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 +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- 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-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -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/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- 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
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { issueAuthCode } from "../auth-codes.ts";
|
|
5
6
|
import { registerClient } from "../clients.ts";
|
|
6
7
|
import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
|
|
7
8
|
import { findGrant, recordGrant } from "../grants.ts";
|
|
@@ -269,6 +270,16 @@ describe("authHelp", () => {
|
|
|
269
270
|
expect(h).toContain("parachute auth list-users");
|
|
270
271
|
expect(h).toContain("parachute auth 2fa");
|
|
271
272
|
expect(h).toContain("parachute auth rotate-key");
|
|
273
|
+
expect(h).toContain("parachute auth reap-clients");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("reap-clients help documents dry-run-by-default + the conservative gate (#640)", () => {
|
|
277
|
+
expect(h).toContain("reap-clients");
|
|
278
|
+
expect(h).toContain("Dry-run by DEFAULT");
|
|
279
|
+
expect(h).toContain("--apply");
|
|
280
|
+
expect(h).toContain("--older-than");
|
|
281
|
+
expect(h).toContain("PROVABLY-DEAD");
|
|
282
|
+
expect(h).toContain("#640");
|
|
272
283
|
});
|
|
273
284
|
|
|
274
285
|
test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
|
|
@@ -849,6 +860,331 @@ describe("parachute auth pending-clients / approve-client", () => {
|
|
|
849
860
|
});
|
|
850
861
|
});
|
|
851
862
|
|
|
863
|
+
// hub#640 — RFC 7592 deregistration from the terminal.
|
|
864
|
+
describe("parachute auth revoke-client", () => {
|
|
865
|
+
test("revoke-client without an arg is a usage error", async () => {
|
|
866
|
+
const tmp = makeTmp();
|
|
867
|
+
try {
|
|
868
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
869
|
+
const { code, stderr } = await captureOutput(() => auth(["revoke-client"], deps));
|
|
870
|
+
expect(code).toBe(1);
|
|
871
|
+
expect(stderr).toContain("missing client_id");
|
|
872
|
+
} finally {
|
|
873
|
+
tmp.cleanup();
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("revoke-client <unknown> exits 1 with a friendly message", async () => {
|
|
878
|
+
const tmp = makeTmp();
|
|
879
|
+
try {
|
|
880
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
881
|
+
const { code, stderr } = await captureOutput(() => auth(["revoke-client", "no-such"], deps));
|
|
882
|
+
expect(code).toBe(1);
|
|
883
|
+
expect(stderr).toContain("no OAuth client");
|
|
884
|
+
} finally {
|
|
885
|
+
tmp.cleanup();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("revoke-client deletes the client + cascades its grant + emits audit line", async () => {
|
|
890
|
+
const tmp = makeTmp();
|
|
891
|
+
try {
|
|
892
|
+
const db = openHubDb(tmp.dbPath);
|
|
893
|
+
let userId: string;
|
|
894
|
+
let clientId: string;
|
|
895
|
+
try {
|
|
896
|
+
const user = await createUser(db, "owner", "pw");
|
|
897
|
+
userId = user.id;
|
|
898
|
+
clientId = registerClient(db, {
|
|
899
|
+
redirectUris: ["https://app.example/cb"],
|
|
900
|
+
clientName: "MyApp",
|
|
901
|
+
}).client.clientId;
|
|
902
|
+
recordGrant(db, userId, clientId, ["vault:work:read"]);
|
|
903
|
+
expect(findGrant(db, userId, clientId)).not.toBeNull();
|
|
904
|
+
} finally {
|
|
905
|
+
db.close();
|
|
906
|
+
}
|
|
907
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
908
|
+
const { code, stdout } = await captureOutput(() => auth(["revoke-client", clientId], deps));
|
|
909
|
+
expect(code).toBe(0);
|
|
910
|
+
expect(stdout).toContain("Deregistered OAuth client");
|
|
911
|
+
// Audit line for greppability (matches the route's shape, remover_sub=cli).
|
|
912
|
+
expect(stdout).toContain(`client deleted: client_id=${clientId}`);
|
|
913
|
+
expect(stdout).toContain("client_name=MyApp");
|
|
914
|
+
expect(stdout).toContain("remover_sub=cli");
|
|
915
|
+
|
|
916
|
+
// Verify the cascade actually landed in the db.
|
|
917
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
918
|
+
try {
|
|
919
|
+
expect(
|
|
920
|
+
db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(clientId),
|
|
921
|
+
).toBeNull();
|
|
922
|
+
expect(findGrant(db2, userId, clientId)).toBeNull();
|
|
923
|
+
} finally {
|
|
924
|
+
db2.close();
|
|
925
|
+
}
|
|
926
|
+
} finally {
|
|
927
|
+
tmp.cleanup();
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// closes #640 — OAuth client GC reaper. Dry-run by default; only provably-dead
|
|
933
|
+
// clients are reapable. The deep gate coverage lives in clients.test.ts; these
|
|
934
|
+
// exercise the CLI surface (dry-run safety, --apply, --json, empty case).
|
|
935
|
+
describe("parachute auth reap-clients", () => {
|
|
936
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
937
|
+
|
|
938
|
+
/** Register a client `daysAgo` days before now (relative to wall clock). */
|
|
939
|
+
function oldClient(db: ReturnType<typeof openHubDb>, daysAgo: number, name?: string): string {
|
|
940
|
+
const when = new Date(Date.now() - daysAgo * DAY_MS);
|
|
941
|
+
return registerClient(db, {
|
|
942
|
+
redirectUris: ["https://app.example/cb"],
|
|
943
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
944
|
+
now: () => when,
|
|
945
|
+
}).client.clientId;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
test("empty case: clean message, exit 0, no false alarm", async () => {
|
|
949
|
+
const tmp = makeTmp();
|
|
950
|
+
try {
|
|
951
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
952
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
953
|
+
expect(code).toBe(0);
|
|
954
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
955
|
+
} finally {
|
|
956
|
+
tmp.cleanup();
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("dry-run by DEFAULT lists candidates but deletes NOTHING", async () => {
|
|
961
|
+
const tmp = makeTmp();
|
|
962
|
+
let deadId: string;
|
|
963
|
+
try {
|
|
964
|
+
const db = openHubDb(tmp.dbPath);
|
|
965
|
+
try {
|
|
966
|
+
deadId = oldClient(db, 60, "DeadApp");
|
|
967
|
+
} finally {
|
|
968
|
+
db.close();
|
|
969
|
+
}
|
|
970
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
971
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
972
|
+
expect(code).toBe(0);
|
|
973
|
+
expect(stdout).toContain(deadId);
|
|
974
|
+
expect(stdout).toContain("DeadApp");
|
|
975
|
+
expect(stdout).toContain("--apply");
|
|
976
|
+
expect(stdout).toContain("nothing deleted");
|
|
977
|
+
|
|
978
|
+
// Count unchanged: the client is still there.
|
|
979
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
980
|
+
try {
|
|
981
|
+
const n = db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n;
|
|
982
|
+
expect(n).toBe(1);
|
|
983
|
+
} finally {
|
|
984
|
+
db2.close();
|
|
985
|
+
}
|
|
986
|
+
} finally {
|
|
987
|
+
tmp.cleanup();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("--apply actually reaps + emits an audit line, dry-run is a no-op before it", async () => {
|
|
992
|
+
const tmp = makeTmp();
|
|
993
|
+
let deadId: string;
|
|
994
|
+
try {
|
|
995
|
+
const db = openHubDb(tmp.dbPath);
|
|
996
|
+
try {
|
|
997
|
+
deadId = oldClient(db, 60, "DeadApp");
|
|
998
|
+
} finally {
|
|
999
|
+
db.close();
|
|
1000
|
+
}
|
|
1001
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1002
|
+
|
|
1003
|
+
// Dry-run first: count before == count after.
|
|
1004
|
+
await captureOutput(() => auth(["reap-clients"], deps));
|
|
1005
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1006
|
+
try {
|
|
1007
|
+
expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
|
|
1008
|
+
} finally {
|
|
1009
|
+
db2.close();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// --apply deletes.
|
|
1013
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
|
|
1014
|
+
expect(code).toBe(0);
|
|
1015
|
+
expect(stdout).toContain("Reaped 1 abandoned OAuth client");
|
|
1016
|
+
expect(stdout).toContain(`client reaped: client_id=${deadId}`);
|
|
1017
|
+
expect(stdout).toContain("client_name=DeadApp");
|
|
1018
|
+
|
|
1019
|
+
const db3 = openHubDb(tmp.dbPath);
|
|
1020
|
+
try {
|
|
1021
|
+
expect(db3.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(0);
|
|
1022
|
+
} finally {
|
|
1023
|
+
db3.close();
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
tmp.cleanup();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("NEVER reaps a client with a live grant (--apply leaves it intact)", async () => {
|
|
1031
|
+
const tmp = makeTmp();
|
|
1032
|
+
let liveId: string;
|
|
1033
|
+
let userId: string;
|
|
1034
|
+
try {
|
|
1035
|
+
const db = openHubDb(tmp.dbPath);
|
|
1036
|
+
try {
|
|
1037
|
+
const user = await createUser(db, "owner", "pw");
|
|
1038
|
+
userId = user.id;
|
|
1039
|
+
liveId = oldClient(db, 60, "GrantedApp");
|
|
1040
|
+
recordGrant(db, userId, liveId, ["vault:work:read"]);
|
|
1041
|
+
} finally {
|
|
1042
|
+
db.close();
|
|
1043
|
+
}
|
|
1044
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1045
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
|
|
1046
|
+
expect(code).toBe(0);
|
|
1047
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1048
|
+
|
|
1049
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1050
|
+
try {
|
|
1051
|
+
expect(
|
|
1052
|
+
db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(liveId),
|
|
1053
|
+
).not.toBeNull();
|
|
1054
|
+
expect(findGrant(db2, userId, liveId)).not.toBeNull();
|
|
1055
|
+
} finally {
|
|
1056
|
+
db2.close();
|
|
1057
|
+
}
|
|
1058
|
+
} finally {
|
|
1059
|
+
tmp.cleanup();
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test("NEVER reaps a freshly-registered client (inside the 30d floor)", async () => {
|
|
1064
|
+
const tmp = makeTmp();
|
|
1065
|
+
try {
|
|
1066
|
+
const db = openHubDb(tmp.dbPath);
|
|
1067
|
+
try {
|
|
1068
|
+
oldClient(db, 5); // 5 days old
|
|
1069
|
+
} finally {
|
|
1070
|
+
db.close();
|
|
1071
|
+
}
|
|
1072
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1073
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1074
|
+
expect(code).toBe(0);
|
|
1075
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1076
|
+
} finally {
|
|
1077
|
+
tmp.cleanup();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("--older-than tunes the age floor", async () => {
|
|
1082
|
+
const tmp = makeTmp();
|
|
1083
|
+
let id: string;
|
|
1084
|
+
try {
|
|
1085
|
+
const db = openHubDb(tmp.dbPath);
|
|
1086
|
+
try {
|
|
1087
|
+
id = oldClient(db, 15); // 15 days old
|
|
1088
|
+
} finally {
|
|
1089
|
+
db.close();
|
|
1090
|
+
}
|
|
1091
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1092
|
+
// Default 30d → not reapable.
|
|
1093
|
+
const def = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1094
|
+
expect(def.stdout).toContain("No abandoned clients to reap.");
|
|
1095
|
+
// 10d floor → reapable.
|
|
1096
|
+
const tuned = await captureOutput(() => auth(["reap-clients", "--older-than", "10"], deps));
|
|
1097
|
+
expect(tuned.stdout).toContain(id);
|
|
1098
|
+
} finally {
|
|
1099
|
+
tmp.cleanup();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("--json emits machine output; applied=false in dry-run, true with --apply", async () => {
|
|
1104
|
+
const tmp = makeTmp();
|
|
1105
|
+
let deadId: string;
|
|
1106
|
+
try {
|
|
1107
|
+
const db = openHubDb(tmp.dbPath);
|
|
1108
|
+
try {
|
|
1109
|
+
deadId = oldClient(db, 60, "JsonApp");
|
|
1110
|
+
} finally {
|
|
1111
|
+
db.close();
|
|
1112
|
+
}
|
|
1113
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1114
|
+
const dry = await captureOutput(() => auth(["reap-clients", "--json"], deps));
|
|
1115
|
+
expect(dry.code).toBe(0);
|
|
1116
|
+
const parsed = JSON.parse(dry.stdout) as {
|
|
1117
|
+
applied: boolean;
|
|
1118
|
+
count: number;
|
|
1119
|
+
clients: Array<{ clientId: string }>;
|
|
1120
|
+
};
|
|
1121
|
+
expect(parsed.applied).toBe(false);
|
|
1122
|
+
expect(parsed.count).toBe(1);
|
|
1123
|
+
expect(parsed.clients[0]?.clientId).toBe(deadId);
|
|
1124
|
+
// dry-run JSON deleted nothing.
|
|
1125
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1126
|
+
try {
|
|
1127
|
+
expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
|
|
1128
|
+
} finally {
|
|
1129
|
+
db2.close();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const wet = await captureOutput(() => auth(["reap-clients", "--json", "--apply"], deps));
|
|
1133
|
+
const wetParsed = JSON.parse(wet.stdout) as {
|
|
1134
|
+
applied: boolean;
|
|
1135
|
+
clients: Array<{ reaped?: boolean }>;
|
|
1136
|
+
};
|
|
1137
|
+
expect(wetParsed.applied).toBe(true);
|
|
1138
|
+
expect(wetParsed.clients[0]?.reaped).toBe(true);
|
|
1139
|
+
} finally {
|
|
1140
|
+
tmp.cleanup();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test("a client with only an in-flight auth_code is NEVER reaped", async () => {
|
|
1145
|
+
const tmp = makeTmp();
|
|
1146
|
+
let id: string;
|
|
1147
|
+
try {
|
|
1148
|
+
const db = openHubDb(tmp.dbPath);
|
|
1149
|
+
try {
|
|
1150
|
+
const user = await createUser(db, "owner", "pw");
|
|
1151
|
+
id = oldClient(db, 60);
|
|
1152
|
+
issueAuthCode(db, {
|
|
1153
|
+
clientId: id,
|
|
1154
|
+
userId: user.id,
|
|
1155
|
+
redirectUri: "https://app.example/cb",
|
|
1156
|
+
scopes: ["vault:work:read"],
|
|
1157
|
+
codeChallenge: "x".repeat(43),
|
|
1158
|
+
codeChallengeMethod: "S256",
|
|
1159
|
+
});
|
|
1160
|
+
} finally {
|
|
1161
|
+
db.close();
|
|
1162
|
+
}
|
|
1163
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1164
|
+
const { stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1165
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1166
|
+
} finally {
|
|
1167
|
+
tmp.cleanup();
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("rejects --older-than 0 / negative / non-integer", async () => {
|
|
1172
|
+
const tmp = makeTmp();
|
|
1173
|
+
try {
|
|
1174
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1175
|
+
for (const bad of ["0", "-5", "abc"]) {
|
|
1176
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1177
|
+
auth(["reap-clients", "--older-than", bad], deps),
|
|
1178
|
+
);
|
|
1179
|
+
expect(code).toBe(1);
|
|
1180
|
+
expect(stderr).toContain("--older-than");
|
|
1181
|
+
}
|
|
1182
|
+
} finally {
|
|
1183
|
+
tmp.cleanup();
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
852
1188
|
// closes #75 — operator-facing controls for the OAuth consent skip-list.
|
|
853
1189
|
describe("parachute auth list-grants / revoke-grant", () => {
|
|
854
1190
|
test("list-grants shows the seeding hint when no users exist", async () => {
|