@goodandready/dsh-key-rotation 0.7.29 → 0.7.31

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/lib/heal.js CHANGED
@@ -1,35 +1,35 @@
1
- // heal.js — self-healing idle cooldowns.
2
- // ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
3
-
4
- // Returns array of { ref, poolBase } entries that were healed in this tick.
5
- // Mutates `pools` (removes from failedUntil, pushes heal event into events).
6
- // `now` parameter is injectable for tests.
7
- export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
8
- if (!Array.isArray(pools) || pools.length === 0) return [];
9
- if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
10
- const healed = [];
11
- for (const pool of pools) {
12
- if (!pool || !pool.state || !pool.base) continue;
13
- const fu = pool.state.failedUntil;
14
- const lu = pool.state.lastUsed;
15
- if (!fu || fu.size === 0) continue;
16
- const expiredRefs = [];
17
- for (const [ref, until] of fu.entries()) {
18
- if (!Number.isFinite(until)) continue;
19
- if (until > now) continue; // cooldown still active
20
- const last = lu ? lu.get(ref) : undefined;
21
- if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
- if (now - last < idleMs) continue; // used recently → don't heal
23
- expiredRefs.push(ref);
24
- }
25
- for (const ref of expiredRefs) {
26
- fu.delete(ref);
27
- if (Array.isArray(pool.state.events)) {
28
- pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
29
- if (pool.state.events.length > 50) pool.state.events.shift();
30
- }
31
- healed.push({ ref, poolBase: pool.base });
32
- }
33
- }
34
- return healed;
35
- }
1
+ // heal.js — self-healing idle cooldowns.
2
+ // ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
3
+
4
+ // Returns array of { ref, poolBase } entries that were healed in this tick.
5
+ // Mutates `pools` (removes from failedUntil, pushes heal event into events).
6
+ // `now` parameter is injectable for tests.
7
+ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
8
+ if (!Array.isArray(pools) || pools.length === 0) return [];
9
+ if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
10
+ const healed = [];
11
+ for (const pool of pools) {
12
+ if (!pool || !pool.state || !pool.base) continue;
13
+ const fu = pool.state.failedUntil;
14
+ const lu = pool.state.lastUsed;
15
+ if (!fu || fu.size === 0) continue;
16
+ const expiredRefs = [];
17
+ for (const [ref, until] of fu.entries()) {
18
+ if (!Number.isFinite(until)) continue;
19
+ if (until > now) continue; // cooldown still active
20
+ const last = lu ? lu.get(ref) : undefined;
21
+ if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
+ if (now - last < idleMs) continue; // used recently → don't heal
23
+ expiredRefs.push(ref);
24
+ }
25
+ for (const ref of expiredRefs) {
26
+ fu.delete(ref);
27
+ if (Array.isArray(pool.state.events)) {
28
+ pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
29
+ if (pool.state.events.length > 50) pool.state.events.shift();
30
+ }
31
+ healed.push({ ref, poolBase: pool.base });
32
+ }
33
+ }
34
+ return healed;
35
+ }
package/lib/histogram.js CHANGED
@@ -1,66 +1,66 @@
1
- // histogram.js — per-ref latency ring buffer + percentile.
2
- // ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
3
-
4
- export const LATENCY_DEFAULT_WINDOW = 200;
5
-
6
- export class LatencyHistogram {
7
- constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
8
- const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
9
- this._window = w;
10
- this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
11
- this._lastAt = new Map(); // ref -> epochMs of last sample
12
- }
13
-
14
- record(ref, ms) {
15
- if (!ref || !Number.isFinite(ms) || ms < 0) return;
16
- let entry = this._buffers.get(ref);
17
- if (!entry) {
18
- entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
19
- this._buffers.set(ref, entry);
20
- }
21
- entry.buf[entry.head] = ms;
22
- entry.head = (entry.head + 1) % this._window;
23
- if (entry.count < this._window) entry.count += 1;
24
- this._lastAt.set(ref, Date.now());
25
- }
26
-
27
- // Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
28
- snapshot(ref) {
29
- const entry = this._buffers.get(ref);
30
- const lastAt = this._lastAt.get(ref);
31
- if (!entry || entry.count === 0) {
32
- return { count: 0, lastAt: lastAt || null };
33
- }
34
- const arr = entry.buf.subarray(0, entry.count);
35
- const sorted = Array.from(arr).sort((a, b) => a - b);
36
- const n = sorted.length;
37
- return {
38
- count: n,
39
- lastAt: lastAt || null,
40
- p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
41
- p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
42
- p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
43
- };
44
- }
45
-
46
- // Returns { [ref]: snapshot }
47
- snapshotAll() {
48
- const out = {};
49
- for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
50
- return out;
51
- }
52
-
53
- clear(ref) {
54
- if (ref) {
55
- this._buffers.delete(ref);
56
- this._lastAt.delete(ref);
57
- } else {
58
- this._buffers.clear();
59
- this._lastAt.clear();
60
- }
61
- }
62
-
63
- get size() {
64
- return this._buffers.size;
65
- }
66
- }
1
+ // histogram.js — per-ref latency ring buffer + percentile.
2
+ // ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
3
+
4
+ export const LATENCY_DEFAULT_WINDOW = 200;
5
+
6
+ export class LatencyHistogram {
7
+ constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
8
+ const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
9
+ this._window = w;
10
+ this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
11
+ this._lastAt = new Map(); // ref -> epochMs of last sample
12
+ }
13
+
14
+ record(ref, ms) {
15
+ if (!ref || !Number.isFinite(ms) || ms < 0) return;
16
+ let entry = this._buffers.get(ref);
17
+ if (!entry) {
18
+ entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
19
+ this._buffers.set(ref, entry);
20
+ }
21
+ entry.buf[entry.head] = ms;
22
+ entry.head = (entry.head + 1) % this._window;
23
+ if (entry.count < this._window) entry.count += 1;
24
+ this._lastAt.set(ref, Date.now());
25
+ }
26
+
27
+ // Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
28
+ snapshot(ref) {
29
+ const entry = this._buffers.get(ref);
30
+ const lastAt = this._lastAt.get(ref);
31
+ if (!entry || entry.count === 0) {
32
+ return { count: 0, lastAt: lastAt || null };
33
+ }
34
+ const arr = entry.buf.subarray(0, entry.count);
35
+ const sorted = Array.from(arr).sort((a, b) => a - b);
36
+ const n = sorted.length;
37
+ return {
38
+ count: n,
39
+ lastAt: lastAt || null,
40
+ p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
41
+ p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
42
+ p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
43
+ };
44
+ }
45
+
46
+ // Returns { [ref]: snapshot }
47
+ snapshotAll() {
48
+ const out = {};
49
+ for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
50
+ return out;
51
+ }
52
+
53
+ clear(ref) {
54
+ if (ref) {
55
+ this._buffers.delete(ref);
56
+ this._lastAt.delete(ref);
57
+ } else {
58
+ this._buffers.clear();
59
+ this._lastAt.clear();
60
+ }
61
+ }
62
+
63
+ get size() {
64
+ return this._buffers.size;
65
+ }
66
+ }
package/lib/incident.js CHANGED
@@ -1,76 +1,76 @@
1
- // lib/incident.js — auto-create Gitea issue when pool exhausted > threshold.
2
- // ponytail: minimal — caller provides a token + base URL. No retries on rate-limit.
3
-
4
- export const INCIDENT_DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
5
- export const INCIDENT_DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 min between incidents per provider
6
- export const INCIDENT_TIMEOUT_MS = 5000;
7
-
8
- export class IncidentReporter {
9
- constructor({ token, baseUrl, repo, thresholdMs = INCIDENT_DEFAULT_THRESHOLD_MS, cooldownMs = INCIDENT_DEFAULT_COOLDOWN_MS, fetchImpl } = {}) {
10
- if (!token) throw new Error('incident: token required');
11
- if (!baseUrl) throw new Error('incident: baseUrl required');
12
- if (!repo || !repo.includes('/')) throw new Error('incident: repo (owner/name) required');
13
- this._token = token;
14
- this._baseUrl = baseUrl.replace(/\/+$/, '');
15
- this._repo = repo;
16
- this._thresholdMs = thresholdMs;
17
- this._cooldownMs = cooldownMs;
18
- this._lastIncidentAt = new Map(); // provider -> epochMs
19
- this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : () => { throw new Error('incident: no fetch available'); });
20
- }
21
-
22
- // Should we report now? Pure; does not perform I/O.
23
- shouldReport(provider, exhaustedSince, now = Date.now()) {
24
- if (!provider) return false;
25
- if (!Number.isFinite(exhaustedSince)) return false;
26
- if (now - exhaustedSince < this._thresholdMs) return false;
27
- const last = this._lastIncidentAt.get(provider);
28
- if (Number.isFinite(last) && now - last < this._cooldownMs) return false;
29
- return true;
30
- }
31
-
32
- markReported(provider, at = Date.now()) {
33
- this._lastIncidentAt.set(provider, at);
34
- }
35
-
36
- resetCooldown(provider) {
37
- if (provider) this._lastIncidentAt.delete(provider);
38
- else this._lastIncidentAt.clear();
39
- }
40
-
41
- // Open a Gitea issue. ponytail: minimal payload, ignore failures.
42
- async open(provider, exhaustedSince, now = Date.now()) {
43
- if (!this.shouldReport(provider, exhaustedSince, now)) return { reported: false };
44
- const url = `${this._baseUrl}/api/v1/repos/${this._repo}/issues`;
45
- const body = {
46
- title: `prod-incident: pool ${provider} exhausted since ${new Date(exhaustedSince).toISOString()}`,
47
- body: [
48
- 'Auto-generated by `dsh-key-rotation`.',
49
- '',
50
- `- provider: \`${provider}\``,
51
- `- exhaustedSince: \`${new Date(exhaustedSince).toISOString()}\``,
52
- '',
53
- 'All keys in the pool are in cooldown or missing. Check OpenCode provider status and rotate keys.',
54
- ].join('\n'),
55
- labels: ['prod-incident'],
56
- };
57
- const ctrl = new AbortController();
58
- const timer = setTimeout(() => ctrl.abort(), INCIDENT_TIMEOUT_MS);
59
- try {
60
- const res = await this._fetch(url, {
61
- method: 'POST',
62
- headers: { authorization: `token ${this._token}`, 'content-type': 'application/json' },
63
- body: JSON.stringify(body),
64
- signal: ctrl.signal,
65
- });
66
- if (!res.ok) return { reported: false, status: res.status };
67
- const data = await res.json();
68
- this.markReported(provider, now);
69
- return { reported: true, number: data.number, url: data.html_url };
70
- } catch (_) {
71
- return { reported: false };
72
- } finally {
73
- clearTimeout(timer);
74
- }
75
- }
76
- }
1
+ // lib/incident.js — auto-create Gitea issue when pool exhausted > threshold.
2
+ // ponytail: minimal — caller provides a token + base URL. No retries on rate-limit.
3
+
4
+ export const INCIDENT_DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
5
+ export const INCIDENT_DEFAULT_COOLDOWN_MS = 30 * 60 * 1000; // 30 min between incidents per provider
6
+ export const INCIDENT_TIMEOUT_MS = 5000;
7
+
8
+ export class IncidentReporter {
9
+ constructor({ token, baseUrl, repo, thresholdMs = INCIDENT_DEFAULT_THRESHOLD_MS, cooldownMs = INCIDENT_DEFAULT_COOLDOWN_MS, fetchImpl } = {}) {
10
+ if (!token) throw new Error('incident: token required');
11
+ if (!baseUrl) throw new Error('incident: baseUrl required');
12
+ if (!repo || !repo.includes('/')) throw new Error('incident: repo (owner/name) required');
13
+ this._token = token;
14
+ this._baseUrl = baseUrl.replace(/\/+$/, '');
15
+ this._repo = repo;
16
+ this._thresholdMs = thresholdMs;
17
+ this._cooldownMs = cooldownMs;
18
+ this._lastIncidentAt = new Map(); // provider -> epochMs
19
+ this._fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : () => { throw new Error('incident: no fetch available'); });
20
+ }
21
+
22
+ // Should we report now? Pure; does not perform I/O.
23
+ shouldReport(provider, exhaustedSince, now = Date.now()) {
24
+ if (!provider) return false;
25
+ if (!Number.isFinite(exhaustedSince)) return false;
26
+ if (now - exhaustedSince < this._thresholdMs) return false;
27
+ const last = this._lastIncidentAt.get(provider);
28
+ if (Number.isFinite(last) && now - last < this._cooldownMs) return false;
29
+ return true;
30
+ }
31
+
32
+ markReported(provider, at = Date.now()) {
33
+ this._lastIncidentAt.set(provider, at);
34
+ }
35
+
36
+ resetCooldown(provider) {
37
+ if (provider) this._lastIncidentAt.delete(provider);
38
+ else this._lastIncidentAt.clear();
39
+ }
40
+
41
+ // Open a Gitea issue. ponytail: minimal payload, ignore failures.
42
+ async open(provider, exhaustedSince, now = Date.now()) {
43
+ if (!this.shouldReport(provider, exhaustedSince, now)) return { reported: false };
44
+ const url = `${this._baseUrl}/api/v1/repos/${this._repo}/issues`;
45
+ const body = {
46
+ title: `prod-incident: pool ${provider} exhausted since ${new Date(exhaustedSince).toISOString()}`,
47
+ body: [
48
+ 'Auto-generated by `dsh-key-rotation`.',
49
+ '',
50
+ `- provider: \`${provider}\``,
51
+ `- exhaustedSince: \`${new Date(exhaustedSince).toISOString()}\``,
52
+ '',
53
+ 'All keys in the pool are in cooldown or missing. Check OpenCode provider status and rotate keys.',
54
+ ].join('\n'),
55
+ labels: ['prod-incident'],
56
+ };
57
+ const ctrl = new AbortController();
58
+ const timer = setTimeout(() => ctrl.abort(), INCIDENT_TIMEOUT_MS);
59
+ try {
60
+ const res = await this._fetch(url, {
61
+ method: 'POST',
62
+ headers: { authorization: `token ${this._token}`, 'content-type': 'application/json' },
63
+ body: JSON.stringify(body),
64
+ signal: ctrl.signal,
65
+ });
66
+ if (!res.ok) return { reported: false, status: res.status };
67
+ const data = await res.json();
68
+ this.markReported(provider, now);
69
+ return { reported: true, number: data.number, url: data.html_url };
70
+ } catch (_) {
71
+ return { reported: false };
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+ }
package/lib/index.js CHANGED
@@ -86,6 +86,8 @@ let rotationDisabled = false;
86
86
  const expiryNotifiedAt = new Map();
87
87
  const budgetNotifiedAt = new Map();
88
88
  const switchNotifiedAt = new Map();
89
+ const lowHealthNotifiedAt = new Map();
90
+ const sloNotifiedAt = new Map();
89
91
  const DAY_MS = 86400000;
90
92
 
91
93
  // #216: one webhook per switch, deduped to at most one message per provider
@@ -215,6 +217,8 @@ export const Config = Schema.object({
215
217
  expiryWarnDays: Schema.number().default(7),
216
218
  switchNotify: Schema.boolean().default(false),
217
219
  switchNotifyThrottleMs: Schema.number().default(60000),
220
+ warnBelowHealthy: Schema.number().default(0),
221
+ latencySloMs: Schema.number().default(0),
218
222
  providers: Schema.array(Schema.object({
219
223
  provider: Schema.string().required(),
220
224
  keys: Schema.array(Schema.string()).default([]),
@@ -633,6 +637,55 @@ export function apply(ctx, config = {}) {
633
637
  if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
634
638
  }
635
639
  }
640
+ // #221: pool running low - webhook while healthy < warnBelowHealthy
641
+ const warnBelow = runtime.warnBelowHealthy ?? 0;
642
+ if (warnBelow > 0) {
643
+ let healthy = 0;
644
+ for (const ref of pool.refs) {
645
+ const fu = pool.state.failedUntil.get(ref);
646
+ if (fu !== undefined && fu > now) continue;
647
+ const exp = pool.expiresAt?.[ref];
648
+ if (exp !== undefined && now >= exp) continue;
649
+ healthy++;
650
+ }
651
+ if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
652
+ console.warn(`[dsh-key-rotation] ${pool.base}: pool running low - ${healthy}/${pool.refs.length} healthy`);
653
+ if (runtime.notifyWebhook) {
654
+ const token = runtime.webhookActionToken ?? '';
655
+ webhookSender.send(runtime.notifyWebhook, {
656
+ title: `Pool running low: ${pool.base}`,
657
+ text: `${healthy}/${pool.refs.length} keys healthy (alert below ${warnBelow})`,
658
+ provider: pool.base,
659
+ kind: 'low-health',
660
+ healthy,
661
+ total: pool.refs.length,
662
+ actionToken: token || undefined,
663
+ actions: token ? [{ id: `reset-${pool.base}`, label: 'Reset cooldown' }] : undefined,
664
+ });
665
+ }
666
+ }
667
+ }
668
+ // #225: latency SLO - webhook when a key's p95 exceeds the threshold
669
+ const slo = runtime.latencySloMs ?? 0;
670
+ if (slo > 0) {
671
+ for (const ref of pool.refs) {
672
+ const snap = latencyHistogram.snapshot(ref);
673
+ if (!snap.p95 || snap.p95 <= slo) continue;
674
+ if (!shouldNotifyDaily(sloNotifiedAt, pool.base + ':' + ref + ':slo', now)) continue;
675
+ console.warn(`[dsh-key-rotation] ${pool.base}: ${ref} p95 ${Math.round(snap.p95)}ms > SLO ${slo}ms`);
676
+ if (runtime.notifyWebhook) {
677
+ webhookSender.send(runtime.notifyWebhook, {
678
+ title: `Latency SLO exceeded: ${pool.base}`,
679
+ text: `${ref} p95 ${Math.round(snap.p95)}ms > ${slo}ms (${snap.count} samples)`,
680
+ provider: pool.base,
681
+ kind: 'latency-slo',
682
+ ref,
683
+ p95: Math.round(snap.p95),
684
+ slo,
685
+ });
686
+ }
687
+ }
688
+ }
636
689
  }
637
690
  } catch (_) { /* maintenance must never crash the sweep */ }
638
691
  }, 30000);
@@ -776,7 +829,7 @@ export function apply(ctx, config = {}) {
776
829
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
777
830
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
778
831
  }
779
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
832
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
780
833
  }
781
834
 
782
835
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -1104,6 +1157,7 @@ export function apply(ctx, config = {}) {
1104
1157
  const { poolByRef, providerTags, providerBudgets } = buildRuntime();
1105
1158
  const base = ctx.get('credentials');
1106
1159
  const now = Date.now();
1160
+ const latencySloMs = buildRuntime().latencySloMs;
1107
1161
  const seen = new Set();
1108
1162
  const providers = [];
1109
1163
  for (const pool of poolByRef.values()) {
@@ -1171,6 +1225,12 @@ export function apply(ctx, config = {}) {
1171
1225
  lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
1172
1226
  exhaustionCount: pool.state.exhaustionCount ?? 0,
1173
1227
  totalUsage: [...(pool.state.usageCounts?.values() ?? [])].reduce((a, b) => a + b, 0),
1228
+ // #225: aggregate p95 across the pool's keys
1229
+ p95: (() => {
1230
+ const vals = (pool.refs ?? []).map((r) => latencyHistogram.snapshot(r)).filter((s) => s && s.p95 != null).map((s) => s.p95);
1231
+ return vals.length ? Math.round(Math.max(...vals)) : null;
1232
+ })(),
1233
+ latencySloMs,
1174
1234
  events: (pool.state.events ?? []).slice(-50),
1175
1235
  healthScore: computeHealthScore(pool.state),
1176
1236
  // #208: today/week spend + configured budget for the card
@@ -1592,6 +1652,28 @@ export function apply(ctx, config = {}) {
1592
1652
  if (!action && typeof body?.callback_data === 'string') {
1593
1653
  try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
1594
1654
  }
1655
+ // #222: Telegram update envelope {update_id, callback_query:{data}}
1656
+ if (!action && typeof body?.callback_query?.data === 'string') {
1657
+ try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
1658
+ }
1659
+ // #222: Telegram setWebhook registration helper
1660
+ if (typeof body?.setWebhook === 'object' && body.setWebhook) {
1661
+ const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
1662
+ if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
1663
+ // derive the public URL from request headers; explicit URL wins
1664
+ const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
1665
+ try {
1666
+ const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
1667
+ method: 'POST', headers: { 'content-type': 'application/json' },
1668
+ body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
1669
+ });
1670
+ const hookData = await hookRes.json().catch(() => ({}));
1671
+ json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
1672
+ } catch (e) {
1673
+ json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
1674
+ }
1675
+ return;
1676
+ }
1595
1677
  if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
1596
1678
  const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
1597
1679
  try {