@goodandready/dsh-key-rotation 0.8.28 → 0.8.29
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/CHANGELOG.md +21 -0
- package/lib/budget-monitor.js +2 -1
- package/lib/cascade.js +3 -9
- package/lib/index.js +21 -6
- package/lib/ops-keys.js +4 -0
- package/lib/ops-metrics.js +6 -1
- package/lib/ops-status.js +5 -0
- package/lib/pool.js +8 -5
- package/lib/webhook.js +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
+
## 0.8.29 - 2026-09-27
|
|
7
|
+
|
|
8
|
+
### Fixed & Hardened (#376)
|
|
9
|
+
- **Decrypted secret resolution at rest**:
|
|
10
|
+
- `lib/index.js`: patched `credentials.resolve` now decrypts ciphertext (`enc:v1:...`) via `decryptSecret` for pooled hits, fallback environment variables, and unpooled standalone key queries, preventing raw encrypted strings from being passed to upstream LLM providers.
|
|
11
|
+
- **Fail-safe candidate selection in rotation pool**:
|
|
12
|
+
- `lib/index.js`: `credentials.resolve` candidate check loop now explicitly enforces `!isKeyRevoked(pool, candidate)` and `!isKeyPaused(pool, candidate)` before selecting candidates.
|
|
13
|
+
- **Predicate logic & dynamic state resolution**:
|
|
14
|
+
- `lib/pool.js`: corrected `isKeyPaused` predicate to ensure `pool.state.pausedRefs` is inspected when `pool.pausedRefs` Set or Array does not contain the ref, allowing runtime key pause mutations to take effect.
|
|
15
|
+
- `lib/cascade.js`: replaced raw `.has()` call with `isKeyRevoked(pool, ref)` and unified `isKeyPaused`, preventing `TypeError` crashes when `revokedRefs` is represented as an Array.
|
|
16
|
+
- **Config & schema synchronization**:
|
|
17
|
+
- `lib/index.js`: propagated `p.revoked` into `buildPool` / `buildPoolItem` and added missing `paused` and `revoked` definitions to `SettingsSchema.providers`.
|
|
18
|
+
- **Status & key administrative routes**:
|
|
19
|
+
- `lib/ops-status.js`: exposed `paused` and `revoked` booleans on key entries in `/dsh-key-rotation/status`.
|
|
20
|
+
- `lib/pool.js`: updated `keyTail` to transparently decrypt ciphertext before extracting the tail, displaying the actual credential mask instead of random ciphertext characters while preserving test safety contracts.
|
|
21
|
+
- `lib/ops-keys.js`: `/dsh-key-rotation/reset` now clears `st.revokedRefs` for provider resets and single ref resets, enabling operators to unblock fixed keys.
|
|
22
|
+
- **Prometheus metrics & monitoring**:
|
|
23
|
+
- `lib/ops-metrics.js`: separated expired keys from active counts and added `dsh_key_rotation_expired_keys` gauge.
|
|
24
|
+
- `lib/budget-monitor.js`: excluded revoked and paused keys from healthy count in `warnBelowHealthy` threshold evaluation.
|
|
25
|
+
- `lib/webhook.js`: hardened Ntfy topic extraction against URLs with trailing slashes and query strings.
|
|
26
|
+
|
|
6
27
|
## 0.8.28 - 2026-09-27
|
|
7
28
|
|
|
8
29
|
### Security & Key Lifecycle (Block 3)
|
package/lib/budget-monitor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
|
|
2
|
-
import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
|
|
2
|
+
import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
|
|
3
3
|
|
|
4
4
|
const DAY_MS = 86400000;
|
|
5
5
|
|
|
@@ -72,6 +72,7 @@ export function checkBudgetAndHealthAlerts({
|
|
|
72
72
|
if (warnBelow > 0) {
|
|
73
73
|
let healthy = 0;
|
|
74
74
|
for (const ref of pool.refs) {
|
|
75
|
+
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
75
76
|
const fu = pool.state.failedUntil.get(ref);
|
|
76
77
|
if (fu !== undefined && fu > now) continue;
|
|
77
78
|
const exp = pool.expiresAt?.[ref];
|
package/lib/cascade.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
// cascade.js — cross-provider failover cascade (issues #194, #364).
|
|
1
|
+
// cascade.js — cross-provider failover cascade (issues #194, #364, #376).
|
|
2
2
|
// ponytail: minimal — pick fallback provider with model mapping from config.
|
|
3
|
+
import { isKeyPaused, isKeyRevoked } from './pool.js';
|
|
3
4
|
|
|
4
5
|
export const CASCADE_MAX_DEPTH = 3;
|
|
5
6
|
|
|
6
|
-
function isKeyPaused(pool, ref) {
|
|
7
|
-
if (!pool || !ref) return false;
|
|
8
|
-
if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
|
|
9
|
-
if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
export function pickCascadeFallback(provider, cfg, pools, currentModel) {
|
|
14
8
|
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
15
9
|
for (const entry of list) {
|
|
@@ -20,7 +14,7 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
|
|
|
20
14
|
const now = Date.now();
|
|
21
15
|
let healthy = 0;
|
|
22
16
|
for (const ref of pool.refs) {
|
|
23
|
-
if (isKeyPaused(pool, ref) || (pool
|
|
17
|
+
if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
|
|
24
18
|
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
25
19
|
if (failedUntil > now) continue;
|
|
26
20
|
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
2
3
|
//
|
|
3
4
|
// Transparent key pool rotation: credentials.resolve patch + llm/stream
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
|
|
14
15
|
computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
|
|
15
16
|
formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek,
|
|
16
|
-
budgetVerdict, sortAttemptList,
|
|
17
|
+
budgetVerdict, sortAttemptList, isKeyPaused, isKeyRevoked,
|
|
17
18
|
} from './pool.js';
|
|
18
19
|
import { LatencyHistogram } from './histogram.js';
|
|
19
20
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
@@ -115,6 +116,8 @@ const SettingsSchema = Schema.object({
|
|
|
115
116
|
provider: Schema.string().required(),
|
|
116
117
|
keys: Schema.array(Schema.string()).default([]),
|
|
117
118
|
weights: Schema.array(Schema.number()).default([]),
|
|
119
|
+
paused: Schema.array(Schema.boolean()).default([]),
|
|
120
|
+
revoked: Schema.array(Schema.boolean()).default([]),
|
|
118
121
|
expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
|
|
119
122
|
tags: Schema.array(Schema.string()).default([]),
|
|
120
123
|
costBudgetDaily: Schema.number(),
|
|
@@ -263,13 +266,13 @@ export function apply(ctx, config = {}) {
|
|
|
263
266
|
return initializePoolState(st);
|
|
264
267
|
};
|
|
265
268
|
|
|
266
|
-
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
267
|
-
buildPoolItem({ base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
269
|
+
const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, revoked) =>
|
|
270
|
+
buildPoolItem({ base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
|
|
268
271
|
|
|
269
272
|
for (const p of cfg.providers ?? []) {
|
|
270
273
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
271
274
|
const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
|
|
272
|
-
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
275
|
+
const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, p.revoked);
|
|
273
276
|
if (pool) {
|
|
274
277
|
for (const ref of pool.refs) poolByRef.set(ref, pool);
|
|
275
278
|
for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
@@ -277,7 +280,7 @@ export function apply(ctx, config = {}) {
|
|
|
277
280
|
const models = p.models ?? {};
|
|
278
281
|
const byModel = new Map();
|
|
279
282
|
for (const [model, mp] of Object.entries(models)) {
|
|
280
|
-
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
|
|
283
|
+
const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, mp.revoked);
|
|
281
284
|
if (mpool) {
|
|
282
285
|
byModel.set(model, mpool);
|
|
283
286
|
for (const ref of mpool.refs) poolByRef.set(ref, mpool);
|
|
@@ -353,7 +356,15 @@ export function apply(ctx, config = {}) {
|
|
|
353
356
|
credentials.resolve = async (ref) => {
|
|
354
357
|
const { poolByRef } = buildRuntime();
|
|
355
358
|
const pool = poolByRef.get(ref);
|
|
356
|
-
if (!pool)
|
|
359
|
+
if (!pool) {
|
|
360
|
+
const res = await original(ref);
|
|
361
|
+
if (res && typeof res.value === 'string') {
|
|
362
|
+
return { ...res, value: decryptSecret(res.value) };
|
|
363
|
+
}
|
|
364
|
+
const ev = envValue(ref);
|
|
365
|
+
if (ev !== undefined) return { value: decryptSecret(ev) };
|
|
366
|
+
return res;
|
|
367
|
+
}
|
|
357
368
|
const now = Date.now();
|
|
358
369
|
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
359
370
|
let list = pool.weightedRefs ?? pool.refs;
|
|
@@ -364,6 +375,8 @@ export function apply(ctx, config = {}) {
|
|
|
364
375
|
for (let i = 0; i < list.length; i++) {
|
|
365
376
|
const index = (start + i) % list.length;
|
|
366
377
|
const candidate = list[index];
|
|
378
|
+
if (isKeyRevoked(pool, candidate)) continue;
|
|
379
|
+
if (isKeyPaused(pool, candidate)) continue;
|
|
367
380
|
const until = pool.state.failedUntil.get(candidate);
|
|
368
381
|
if (until !== undefined && until > now) continue;
|
|
369
382
|
if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
|
|
@@ -391,6 +404,7 @@ export function apply(ctx, config = {}) {
|
|
|
391
404
|
pool.state.pointer = (index + 1) % list.length;
|
|
392
405
|
let hit = await original(candidate);
|
|
393
406
|
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
407
|
+
hit = { ...hit, value: decryptSecret(hit.value) };
|
|
394
408
|
pool.state.lastUsed = candidate;
|
|
395
409
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
396
410
|
pool.state.lastUsedAt.set(candidate, now);
|
|
@@ -413,6 +427,7 @@ export function apply(ctx, config = {}) {
|
|
|
413
427
|
}
|
|
414
428
|
const envVal = envValue(candidate);
|
|
415
429
|
if (envVal !== undefined) {
|
|
430
|
+
const decEnv = decryptSecret(envVal);
|
|
416
431
|
pool.state.lastUsed = candidate;
|
|
417
432
|
if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
|
|
418
433
|
pool.state.lastUsedAt.set(candidate, now);
|
package/lib/ops-keys.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
|
|
2
3
|
import {
|
|
3
4
|
json,
|
|
@@ -75,6 +76,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
75
76
|
st.failCounts?.delete(ref);
|
|
76
77
|
st.authFailCounts?.delete(ref);
|
|
77
78
|
st.brokenUntil?.delete(ref);
|
|
79
|
+
st.revokedRefs?.delete(ref);
|
|
78
80
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
79
81
|
}
|
|
80
82
|
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
@@ -127,6 +129,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
127
129
|
st.failCounts?.clear();
|
|
128
130
|
st.authFailCounts?.clear();
|
|
129
131
|
st.brokenUntil?.clear();
|
|
132
|
+
st.revokedRefs?.clear();
|
|
130
133
|
st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
|
|
131
134
|
let circuitReset = false;
|
|
132
135
|
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
@@ -145,6 +148,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
145
148
|
st.failCounts?.delete(ref);
|
|
146
149
|
st.authFailCounts?.delete(ref);
|
|
147
150
|
st.brokenUntil?.delete(ref);
|
|
151
|
+
st.revokedRefs?.delete(ref);
|
|
148
152
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
149
153
|
found = true; break;
|
|
150
154
|
}
|
package/lib/ops-metrics.js
CHANGED
|
@@ -47,6 +47,9 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
47
47
|
lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
|
|
48
48
|
lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
|
|
49
49
|
|
|
50
|
+
lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
|
|
51
|
+
lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
|
|
52
|
+
|
|
50
53
|
lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
|
|
51
54
|
lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
|
|
52
55
|
|
|
@@ -56,10 +59,11 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
56
59
|
for (const pool of pools) {
|
|
57
60
|
const p = pool.base;
|
|
58
61
|
const st = poolState.get(p);
|
|
59
|
-
let active = 0, cooling = 0, paused = 0, revoked = 0;
|
|
62
|
+
let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
|
|
60
63
|
for (const r of (pool.refs ?? [])) {
|
|
61
64
|
if (isKeyRevoked(pool, r)) { revoked++; continue; }
|
|
62
65
|
if (isKeyPaused(pool, r)) { paused++; continue; }
|
|
66
|
+
if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
|
|
63
67
|
const fu = st?.failedUntil?.get(r) ?? 0;
|
|
64
68
|
if (fu > now) cooling++;
|
|
65
69
|
else active++;
|
|
@@ -68,6 +72,7 @@ export function registerMetricsRoutes(ctx, deps) {
|
|
|
68
72
|
lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
|
|
69
73
|
lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
|
|
70
74
|
lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
|
|
75
|
+
lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
|
|
71
76
|
lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
|
|
72
77
|
lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
|
|
73
78
|
}
|
package/lib/ops-status.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
// lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
|
|
2
3
|
import { json } from './http-bridge.js';
|
|
3
4
|
import {
|
|
4
5
|
isTrustedBridgeRequest,
|
|
6
|
+
isKeyPaused,
|
|
7
|
+
isKeyRevoked,
|
|
5
8
|
keyTail,
|
|
6
9
|
envValue,
|
|
7
10
|
computeHealthScore,
|
|
@@ -106,6 +109,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
106
109
|
expiresAt: pool.expiresAt?.[ref] ?? null,
|
|
107
110
|
expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
|
|
108
111
|
broken: pool.state.brokenUntil?.has(ref) ?? false,
|
|
112
|
+
paused: isKeyPaused(pool, ref),
|
|
113
|
+
revoked: isKeyRevoked(pool, ref),
|
|
109
114
|
});
|
|
110
115
|
}
|
|
111
116
|
providers.push({
|
package/lib/pool.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decryptSecret } from './crypto-storage.js';
|
|
1
2
|
import { bestEffort } from './best-effort.js';
|
|
2
3
|
// lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
|
|
3
4
|
// Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
|
|
@@ -11,8 +12,9 @@ export const KEY_TAIL_PLACEHOLDER = '***';
|
|
|
11
12
|
/** Mask key to show only the last KEY_TAIL_CHARS; returns placeholder for short keys (#355). */
|
|
12
13
|
export function keyTail(key) {
|
|
13
14
|
if (typeof key !== 'string' || key.length === 0) return '';
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const plain = decryptSecret(key);
|
|
16
|
+
if (plain.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
|
|
17
|
+
return plain.slice(-KEY_TAIL_CHARS);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Check if an IP address is a loopback address. */
|
|
@@ -290,9 +292,10 @@ export function parseDurationToSeconds(v) {
|
|
|
290
292
|
/** Check if a key reference is in maintenance/paused mode. */
|
|
291
293
|
export function isKeyPaused(pool, ref) {
|
|
292
294
|
if (!pool || !ref) return false;
|
|
293
|
-
if (pool.pausedRefs instanceof Set
|
|
294
|
-
if (Array.isArray(pool.pausedRefs)
|
|
295
|
-
if (pool.state && pool.state.pausedRefs instanceof Set
|
|
295
|
+
if (pool.pausedRefs instanceof Set && pool.pausedRefs.has(ref)) return true;
|
|
296
|
+
if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
|
|
297
|
+
if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
|
|
298
|
+
if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
|
|
296
299
|
return false;
|
|
297
300
|
}
|
|
298
301
|
|
package/lib/webhook.js
CHANGED
|
@@ -81,8 +81,16 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
if (platform === 'ntfy') {
|
|
84
|
+
let topic = 'alerts';
|
|
85
|
+
try {
|
|
86
|
+
const u = new URL(url);
|
|
87
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
88
|
+
if (parts.length > 0) topic = parts[parts.length - 1];
|
|
89
|
+
} catch (_) {
|
|
90
|
+
topic = String(url).split('?')[0].split('/').filter(Boolean).pop() || 'alerts';
|
|
91
|
+
}
|
|
84
92
|
return {
|
|
85
|
-
topic
|
|
93
|
+
topic,
|
|
86
94
|
title,
|
|
87
95
|
message: text,
|
|
88
96
|
priority: 4,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.29",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|