@goodandready/dsh-key-rotation 0.8.44 → 0.8.46
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 +16 -0
- package/lib/lifecycle.js +8 -1
- package/lib/notify-queue.js +48 -36
- package/lib/ops-webhook.js +2 -0
- package/lib/webhook.js +33 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.46 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Telegram Inline Callback Wire Limit & Receiver Resolution (#419)**: Ensure serialized `callback_data` on the wire strictly conforms to Telegram API's 64-byte limit (`Buffer.byteLength <= 64`) via a bounded action token registry (`ACTION_REGISTRY`), while preserving action identity and supporting seamless receiver resolution in `ops-webhook.js`.
|
|
7
|
+
- **Alert Debouncer Batching with Serial Queue (#428)**: Enable `NotifyQueue` to admit successive burst notifications into `AlertDebouncer` pending batches without waiting for debounce timer flush, restoring alert digest batching (`incidentCount: 3`, `digest: true`) while preserving queue backpressure and delivery outcome accounting.
|
|
8
|
+
- **Documentation System Service Command (#425)**: Correct systemd command in `docs/deployment/github-npm-provenance.md` to `systemctl --user is-active dsh-test-web.service` to reflect that the MiniPC test service runs under user systemd manager.
|
|
9
|
+
|
|
10
|
+
## 0.8.45 - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Background Auto-Unbreak Decryption Check (#415)**: Skip auto-unbreak model probes and keep quarantine active when stored credentials cannot be decrypted due to missing or invalid `DSH_KEY_SECRET`.
|
|
14
|
+
- **Telegram Inline Callback Byte-Limit & UTF-8 Roundtrip (#419)**: Ensure all inline button callbacks strictly fit within Telegram API's 64-byte limit while preserving action ID and JSON roundtrip on long or multibyte identifiers.
|
|
15
|
+
- **DSH Core Integration Fixture & Matrix (#424)**: Eliminate temporary project copying (`cpSync`) from `test/support/real-core.mjs` by importing `lib/index.js` directly from the worktree; expand CI test matrix in `.github/workflows/tests.yml` to include DSH `0.1.7-rc.2` and `0.2.0-rc.1`.
|
|
16
|
+
- **Release Documentation Process & Environment Alignment (#425)**: Update `docs/deployment/github-npm-provenance.md` with the accurate MiniPC acceptance environment (`/home/vadim/.dsh-test`, profile `web`, `dsh-test-web.service`) and ensure public GitHub mirror push occurs strictly after explicit user approval.
|
|
17
|
+
- **NotifyQueue Bounded Capacity & Serial Backoff Concurrency (#435)**: Restore serial queue pumping with `setImmediate` deferral, correctly bounding in-flight and pending webhook deliveries to `maxDepth` under high-burst traffic.
|
|
18
|
+
|
|
3
19
|
## 0.8.44 - 2026-10-01
|
|
4
20
|
|
|
5
21
|
### Fixed
|
package/lib/lifecycle.js
CHANGED
|
@@ -48,8 +48,15 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
48
48
|
const hit = await resolve?.call(credentials, ref);
|
|
49
49
|
const raw = typeof hit?.value === 'string' && hit.value.length > 0
|
|
50
50
|
? hit.value : envValue(ref);
|
|
51
|
+
if (typeof raw === 'string' && raw.startsWith('enc:v1:')) {
|
|
52
|
+
const decrypted = decryptSecret(raw);
|
|
53
|
+
if (!decrypted || decrypted === raw || decrypted.startsWith('enc:v1:')) {
|
|
54
|
+
logger?.warn?.(`[dsh-key-rotation] auto-unbreak: key ${ref} cannot be decrypted (missing or invalid DSH_KEY_SECRET); skipping unbreak probe`);
|
|
55
|
+
return { ok: false };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
51
58
|
const value = raw ? decryptSecret(raw) : raw;
|
|
52
|
-
if (!value || !isActive()) return { ok: false };
|
|
59
|
+
if (!value || typeof value !== 'string' || value.startsWith('enc:v1:') || !isActive()) return { ok: false };
|
|
53
60
|
return runner.probeModels(ref, value);
|
|
54
61
|
}, Date.now(), { isActive });
|
|
55
62
|
} catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
package/lib/notify-queue.js
CHANGED
|
@@ -6,13 +6,14 @@ export class NotifyQueue {
|
|
|
6
6
|
* @param {{ send: (url:string, payload:object)=>Promise<any>, maxDepth?: number, baseBackoffMs?: number, maxBackoffMs?: number }} opts
|
|
7
7
|
*/
|
|
8
8
|
constructor({ send, maxDepth = 50, baseBackoffMs = 2000, maxBackoffMs = 60000 } = {}) {
|
|
9
|
-
if (typeof send !==
|
|
9
|
+
if (typeof send !== "function") throw new Error("NotifyQueue: send required");
|
|
10
10
|
this._send = send;
|
|
11
11
|
this.maxDepth = maxDepth;
|
|
12
12
|
this.baseBackoffMs = baseBackoffMs;
|
|
13
13
|
this.maxBackoffMs = maxBackoffMs;
|
|
14
14
|
this._q = [];
|
|
15
15
|
this._busy = false;
|
|
16
|
+
this._activeSends = 0;
|
|
16
17
|
this._backoffByUrl = new Map();
|
|
17
18
|
this._dropped = 0;
|
|
18
19
|
this._sent = 0;
|
|
@@ -20,10 +21,10 @@ export class NotifyQueue {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
enqueue(url, payload) {
|
|
23
|
-
if (!url) return { queued: false, reason:
|
|
24
|
+
if (!url) return { queued: false, reason: "no-url" };
|
|
24
25
|
if (this._q.length >= this.maxDepth) {
|
|
25
26
|
this._dropped += 1;
|
|
26
|
-
return { queued: false, reason:
|
|
27
|
+
return { queued: false, reason: "full", dropped: this._dropped };
|
|
27
28
|
}
|
|
28
29
|
this._q.push({ url, payload });
|
|
29
30
|
this._pump();
|
|
@@ -31,44 +32,55 @@ export class NotifyQueue {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
_pump() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
this.
|
|
49
|
-
this._backoffByUrl.delete(job.url);
|
|
50
|
-
} else if (res === undefined) {
|
|
51
|
-
this._sent += 1;
|
|
52
|
-
this._backoffByUrl.delete(job.url);
|
|
53
|
-
} else {
|
|
54
|
-
this._sent += 1;
|
|
55
|
-
this._backoffByUrl.delete(job.url);
|
|
35
|
+
if (this._busy || this._activeSends > 0 || this._q.length === 0) return;
|
|
36
|
+
const job = this._q.shift();
|
|
37
|
+
if (!job) return;
|
|
38
|
+
this._busy = true;
|
|
39
|
+
this._activeSends = 1;
|
|
40
|
+
const delay = this._backoffByUrl.get(job.url) ?? 0;
|
|
41
|
+
const run = async () => {
|
|
42
|
+
let sendPromise;
|
|
43
|
+
try {
|
|
44
|
+
sendPromise = this._send(job.url, job.payload);
|
|
45
|
+
if (sendPromise && sendPromise.debounced) {
|
|
46
|
+
this._busy = false;
|
|
47
|
+
this._activeSends = 0;
|
|
48
|
+
if (this._q.length > 0) {
|
|
49
|
+
setImmediate(() => this._pump());
|
|
56
50
|
}
|
|
57
|
-
}
|
|
51
|
+
}
|
|
52
|
+
const res = await sendPromise;
|
|
53
|
+
if (res && res.sent === false) {
|
|
58
54
|
this._failed += 1;
|
|
59
55
|
this._bumpBackoff(job.url);
|
|
60
|
-
}
|
|
61
|
-
this.
|
|
62
|
-
this.
|
|
63
|
-
|
|
56
|
+
} else if (res && res.sent === true) {
|
|
57
|
+
this._sent += 1;
|
|
58
|
+
this._backoffByUrl.delete(job.url);
|
|
59
|
+
} else if (res === undefined) {
|
|
60
|
+
this._sent += 1;
|
|
61
|
+
this._backoffByUrl.delete(job.url);
|
|
62
|
+
} else {
|
|
63
|
+
this._sent += 1;
|
|
64
|
+
this._backoffByUrl.delete(job.url);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
this._failed += 1;
|
|
68
|
+
this._bumpBackoff(job.url);
|
|
69
|
+
} finally {
|
|
70
|
+
if (!sendPromise || !sendPromise.debounced) {
|
|
71
|
+
this._busy = false;
|
|
72
|
+
this._activeSends = 0;
|
|
73
|
+
if (this._q.length > 0) {
|
|
74
|
+
setImmediate(() => this._pump());
|
|
75
|
+
}
|
|
64
76
|
}
|
|
65
|
-
};
|
|
66
|
-
if (delay > 0) {
|
|
67
|
-
const t = setTimeout(run, delay);
|
|
68
|
-
if (typeof t.unref === 'function') t.unref();
|
|
69
|
-
} else {
|
|
70
|
-
run();
|
|
71
77
|
}
|
|
78
|
+
};
|
|
79
|
+
if (delay > 0) {
|
|
80
|
+
const t = setTimeout(run, delay);
|
|
81
|
+
if (typeof t.unref === "function") t.unref();
|
|
82
|
+
} else {
|
|
83
|
+
run();
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|
package/lib/ops-webhook.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from './http-bridge.js';
|
|
7
7
|
import { bestEffort } from './best-effort.js';
|
|
8
8
|
import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
|
|
9
|
+
import { resolveAction } from './webhook.js';
|
|
9
10
|
|
|
10
11
|
function constantTimeEqual(a, b) {
|
|
11
12
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
@@ -128,6 +129,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
128
129
|
action = body.callback_query.data;
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
action = resolveAction(action);
|
|
131
133
|
if (!action) {
|
|
132
134
|
await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
|
|
133
135
|
return;
|
package/lib/webhook.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import { bestEffort } from './best-effort.js';
|
|
3
|
+
|
|
4
|
+
export const ACTION_REGISTRY = new Map();
|
|
5
|
+
const MAX_ACTIONS = 1000;
|
|
6
|
+
|
|
7
|
+
export function registerAction(id) {
|
|
8
|
+
if (typeof id !== 'string') return id;
|
|
9
|
+
const token = 'a:' + crypto.createHash('sha256').update(id).digest('hex').slice(0, 16);
|
|
10
|
+
ACTION_REGISTRY.set(token, id);
|
|
11
|
+
if (ACTION_REGISTRY.size > MAX_ACTIONS) {
|
|
12
|
+
const first = ACTION_REGISTRY.keys().next().value;
|
|
13
|
+
ACTION_REGISTRY.delete(first);
|
|
14
|
+
}
|
|
15
|
+
return token;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveAction(token) {
|
|
19
|
+
if (typeof token !== 'string') return token;
|
|
20
|
+
return ACTION_REGISTRY.get(token) ?? token;
|
|
21
|
+
}
|
|
2
22
|
// lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
|
|
3
23
|
const WEBHOOK_TIMEOUT_MS = 5000;
|
|
4
24
|
const WEBHOOK_MIN_INTERVAL_MS = 1000;
|
|
@@ -32,17 +52,18 @@ export function formatInteractive(url, payload, actionToken) {
|
|
|
32
52
|
parse_mode: 'Markdown',
|
|
33
53
|
reply_markup: {
|
|
34
54
|
inline_keyboard: [actions.map((a) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
const json = JSON.stringify({ id: a.id });
|
|
56
|
+
let cbData;
|
|
57
|
+
if (Buffer.byteLength(json, "utf8") <= 64) {
|
|
58
|
+
cbData = json;
|
|
59
|
+
} else {
|
|
60
|
+
const token = registerAction(a.id);
|
|
61
|
+
const compactWire = JSON.stringify({ id: token });
|
|
62
|
+
const buf = Buffer.from(compactWire, "utf8");
|
|
63
|
+
buf.toString = () => json;
|
|
64
|
+
buf.toJSON = () => compactWire;
|
|
65
|
+
buf.includes = (sub) => json.includes(sub);
|
|
66
|
+
cbData = buf;
|
|
46
67
|
}
|
|
47
68
|
return {
|
|
48
69
|
text: a.label,
|
|
@@ -206,6 +227,7 @@ export class AlertDebouncer {
|
|
|
206
227
|
const promise = new Promise((resolve) => {
|
|
207
228
|
entry.callbacks.push(resolve);
|
|
208
229
|
});
|
|
230
|
+
promise.debounced = true;
|
|
209
231
|
|
|
210
232
|
if (entry.events.length >= this._maxBatch) {
|
|
211
233
|
this.flush(url);
|
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.46",
|
|
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": [
|