aiden-runtime 4.1.5 → 4.5.0
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/README.md +250 -847
- package/dist/api/server.js +32 -5
- package/dist/cli/v4/aidenCLI.js +351 -53
- package/dist/cli/v4/callbacks.js +170 -0
- package/dist/cli/v4/chatSession.js +138 -3
- package/dist/cli/v4/commands/_runtimeToggleHelpers.js +92 -0
- package/dist/cli/v4/commands/browserDepth.js +45 -0
- package/dist/cli/v4/commands/cron.js +264 -0
- package/dist/cli/v4/commands/daemon.js +541 -0
- package/dist/cli/v4/commands/daemonStatus.js +253 -0
- package/dist/cli/v4/commands/help.js +7 -0
- package/dist/cli/v4/commands/index.js +20 -1
- package/dist/cli/v4/commands/runs.js +203 -0
- package/dist/cli/v4/commands/sandbox.js +48 -0
- package/dist/cli/v4/commands/suggestions.js +68 -0
- package/dist/cli/v4/commands/tce.js +41 -0
- package/dist/cli/v4/commands/trigger.js +378 -0
- package/dist/cli/v4/commands/update.js +95 -3
- package/dist/cli/v4/daemonAgentBuilder.js +142 -0
- package/dist/cli/v4/defaultSoul.js +1 -1
- package/dist/cli/v4/display/capabilityCard.js +26 -0
- package/dist/cli/v4/display.js +18 -8
- package/dist/cli/v4/replyRenderer.js +31 -23
- package/dist/cli/v4/updateBootPrompt.js +170 -0
- package/dist/core/playwrightBridge.js +129 -0
- package/dist/core/v4/aidenAgent.js +308 -4
- package/dist/core/v4/browserState.js +436 -0
- package/dist/core/v4/checkpoint.js +79 -0
- package/dist/core/v4/daemon/bootstrap.js +604 -0
- package/dist/core/v4/daemon/cleanShutdown.js +154 -0
- package/dist/core/v4/daemon/cron/cronBridge.js +126 -0
- package/dist/core/v4/daemon/cron/cronEmitter.js +173 -0
- package/dist/core/v4/daemon/cron/migration.js +199 -0
- package/dist/core/v4/daemon/cron/misfirePolicy.js +115 -0
- package/dist/core/v4/daemon/daemonConfig.js +90 -0
- package/dist/core/v4/daemon/db/connection.js +106 -0
- package/dist/core/v4/daemon/db/migrations.js +296 -0
- package/dist/core/v4/daemon/db/schema/v1.spec.js +18 -0
- package/dist/core/v4/daemon/dispatcher/agentRunner.js +98 -0
- package/dist/core/v4/daemon/dispatcher/budgetGate.js +127 -0
- package/dist/core/v4/daemon/dispatcher/daemonApproval.js +113 -0
- package/dist/core/v4/daemon/dispatcher/dailyBudgetTracker.js +120 -0
- package/dist/core/v4/daemon/dispatcher/dispatcher.js +389 -0
- package/dist/core/v4/daemon/dispatcher/fireRateLimiter.js +113 -0
- package/dist/core/v4/daemon/dispatcher/index.js +53 -0
- package/dist/core/v4/daemon/dispatcher/promptTemplate.js +95 -0
- package/dist/core/v4/daemon/dispatcher/realAgentRunner.js +356 -0
- package/dist/core/v4/daemon/dispatcher/resolveModel.js +93 -0
- package/dist/core/v4/daemon/dispatcher/sessionId.js +93 -0
- package/dist/core/v4/daemon/drain.js +156 -0
- package/dist/core/v4/daemon/eventLoopLag.js +73 -0
- package/dist/core/v4/daemon/health.js +159 -0
- package/dist/core/v4/daemon/idempotencyStore.js +204 -0
- package/dist/core/v4/daemon/index.js +179 -0
- package/dist/core/v4/daemon/instanceTracker.js +99 -0
- package/dist/core/v4/daemon/resourceRegistry.js +150 -0
- package/dist/core/v4/daemon/restartCode.js +32 -0
- package/dist/core/v4/daemon/restartFailureCounter.js +77 -0
- package/dist/core/v4/daemon/runStore.js +114 -0
- package/dist/core/v4/daemon/runtimeLock.js +167 -0
- package/dist/core/v4/daemon/signals.js +50 -0
- package/dist/core/v4/daemon/supervisor.js +272 -0
- package/dist/core/v4/daemon/triggerBus.js +279 -0
- package/dist/core/v4/daemon/triggers/email/allowlist.js +70 -0
- package/dist/core/v4/daemon/triggers/email/automatedSender.js +78 -0
- package/dist/core/v4/daemon/triggers/email/bodyExtractor.js +0 -0
- package/dist/core/v4/daemon/triggers/email/emailSeenStore.js +99 -0
- package/dist/core/v4/daemon/triggers/email/emailSpec.js +107 -0
- package/dist/core/v4/daemon/triggers/email/imapConnection.js +211 -0
- package/dist/core/v4/daemon/triggers/email/index.js +332 -0
- package/dist/core/v4/daemon/triggers/email/seenUids.js +60 -0
- package/dist/core/v4/daemon/triggers/fileObservationsStore.js +93 -0
- package/dist/core/v4/daemon/triggers/fileWatcher.js +253 -0
- package/dist/core/v4/daemon/triggers/fileWatcherSpec.js +88 -0
- package/dist/core/v4/daemon/triggers/fsIdentity.js +42 -0
- package/dist/core/v4/daemon/triggers/globMatcher.js +100 -0
- package/dist/core/v4/daemon/triggers/reconcile.js +206 -0
- package/dist/core/v4/daemon/triggers/settleStat.js +81 -0
- package/dist/core/v4/daemon/triggers/webhook.js +376 -0
- package/dist/core/v4/daemon/triggers/webhookDeliveriesStore.js +109 -0
- package/dist/core/v4/daemon/triggers/webhookIdempotency.js +72 -0
- package/dist/core/v4/daemon/triggers/webhookRateLimit.js +56 -0
- package/dist/core/v4/daemon/triggers/webhookSpec.js +76 -0
- package/dist/core/v4/daemon/triggers/webhookVerifier.js +128 -0
- package/dist/core/v4/daemon/types.js +15 -0
- package/dist/core/v4/dockerSession.js +461 -0
- package/dist/core/v4/dryRun.js +117 -0
- package/dist/core/v4/failureClassifier.js +779 -0
- package/dist/core/v4/recoveryReport.js +449 -0
- package/dist/core/v4/runtimeToggles.js +187 -0
- package/dist/core/v4/sandboxConfig.js +285 -0
- package/dist/core/v4/sandboxFs.js +316 -0
- package/dist/core/v4/suggestionCatalog.js +41 -0
- package/dist/core/v4/suggestionEngine.js +210 -0
- package/dist/core/v4/toolRegistry.js +18 -0
- package/dist/core/v4/turnState.js +587 -0
- package/dist/core/v4/update/checkUpdate.js +63 -3
- package/dist/core/v4/update/installMethodDetect.js +115 -0
- package/dist/core/v4/update/registryClient.js +121 -0
- package/dist/core/v4/update/skipState.js +75 -0
- package/dist/core/v4/verifier.js +448 -0
- package/dist/core/version.js +1 -1
- package/dist/tools/v4/browser/_observer.js +224 -0
- package/dist/tools/v4/browser/browserBlocker.js +396 -0
- package/dist/tools/v4/browser/browserClick.js +18 -1
- package/dist/tools/v4/browser/browserClose.js +18 -1
- package/dist/tools/v4/browser/browserExtract.js +5 -1
- package/dist/tools/v4/browser/browserFill.js +17 -1
- package/dist/tools/v4/browser/browserGetUrl.js +5 -1
- package/dist/tools/v4/browser/browserNavigate.js +16 -1
- package/dist/tools/v4/browser/browserScreenshot.js +5 -1
- package/dist/tools/v4/browser/browserScroll.js +18 -1
- package/dist/tools/v4/browser/browserType.js +17 -1
- package/dist/tools/v4/browser/captchaCheck.js +5 -1
- package/dist/tools/v4/executeCode.js +1 -0
- package/dist/tools/v4/files/fileCopy.js +56 -2
- package/dist/tools/v4/files/fileDelete.js +38 -1
- package/dist/tools/v4/files/fileList.js +12 -1
- package/dist/tools/v4/files/fileMove.js +59 -2
- package/dist/tools/v4/files/filePatch.js +43 -1
- package/dist/tools/v4/files/fileRead.js +12 -1
- package/dist/tools/v4/files/fileWrite.js +41 -1
- package/dist/tools/v4/index.js +71 -58
- package/dist/tools/v4/memory/memoryAdd.js +14 -0
- package/dist/tools/v4/memory/memoryRemove.js +14 -0
- package/dist/tools/v4/memory/memoryReplace.js +15 -0
- package/dist/tools/v4/memory/sessionSummary.js +12 -0
- package/dist/tools/v4/process/processKill.js +19 -0
- package/dist/tools/v4/process/processList.js +1 -0
- package/dist/tools/v4/process/processLogRead.js +1 -0
- package/dist/tools/v4/process/processSpawn.js +13 -0
- package/dist/tools/v4/process/processWait.js +1 -0
- package/dist/tools/v4/sessions/recallSession.js +1 -0
- package/dist/tools/v4/sessions/sessionList.js +1 -0
- package/dist/tools/v4/sessions/sessionSearch.js +1 -0
- package/dist/tools/v4/skills/lookupToolSchema.js +2 -0
- package/dist/tools/v4/skills/skillManage.js +13 -0
- package/dist/tools/v4/skills/skillView.js +1 -0
- package/dist/tools/v4/skills/skillsList.js +1 -0
- package/dist/tools/v4/subagent/subagentFanout.js +1 -0
- package/dist/tools/v4/system/aidenSelfUpdate.js +16 -0
- package/dist/tools/v4/system/appClose.js +13 -0
- package/dist/tools/v4/system/appInput.js +13 -0
- package/dist/tools/v4/system/appLaunch.js +13 -0
- package/dist/tools/v4/system/clipboardRead.js +1 -0
- package/dist/tools/v4/system/clipboardWrite.js +14 -0
- package/dist/tools/v4/system/mediaKey.js +12 -0
- package/dist/tools/v4/system/mediaSessions.js +1 -0
- package/dist/tools/v4/system/mediaTransport.js +13 -0
- package/dist/tools/v4/system/naturalEvents.js +1 -0
- package/dist/tools/v4/system/nowPlaying.js +1 -0
- package/dist/tools/v4/system/osProcessList.js +1 -0
- package/dist/tools/v4/system/screenshot.js +1 -0
- package/dist/tools/v4/system/systemInfo.js +1 -0
- package/dist/tools/v4/system/volumeSet.js +17 -0
- package/dist/tools/v4/terminal/shellExec.js +81 -9
- package/dist/tools/v4/web/deepResearch.js +1 -0
- package/dist/tools/v4/web/openUrl.js +1 -0
- package/dist/tools/v4/web/webFetch.js +1 -0
- package/dist/tools/v4/web/webPage.js +1 -0
- package/dist/tools/v4/web/webSearch.js +1 -0
- package/dist/tools/v4/web/youtubeSearch.js +1 -0
- package/package.json +7 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/triggers/settleStat.ts — v4.5 Phase 2.
|
|
10
|
+
*
|
|
11
|
+
* Stable-stat helper. Watches a path's size+mtime over time and
|
|
12
|
+
* returns once two consecutive stats agree (or maxSettleMs elapses).
|
|
13
|
+
*
|
|
14
|
+
* Second layer on top of chokidar's `awaitWriteFinish`. The two
|
|
15
|
+
* cover different editor patterns:
|
|
16
|
+
* - awaitWriteFinish handles "write then flush" streams
|
|
17
|
+
* - settleStat handles the rapid-rename pattern many editors
|
|
18
|
+
* use (write to <name>.swp, fsync, rename onto <name>) —
|
|
19
|
+
* chokidar emits unlink+add in rapid succession; our debounce
|
|
20
|
+
* window plus a final settle gives one stable snapshot.
|
|
21
|
+
*
|
|
22
|
+
* Returns the FINAL stat. Returns null when the path doesn't exist
|
|
23
|
+
* at the final read (file deleted during settle — caller decides).
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.settleStat = settleStat;
|
|
30
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
31
|
+
function defaultStat(p) {
|
|
32
|
+
try {
|
|
33
|
+
const s = node_fs_1.default.statSync(p);
|
|
34
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function defaultSleep(ms) {
|
|
41
|
+
return new Promise((r) => {
|
|
42
|
+
const t = setTimeout(r, ms);
|
|
43
|
+
if (typeof t.unref === 'function')
|
|
44
|
+
t.unref();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Stat repeatedly until two adjacent reads agree on size+mtime, OR
|
|
49
|
+
* until `maxSettleMs` has elapsed. Returns the stable stat (or the
|
|
50
|
+
* last non-null read if we timed out).
|
|
51
|
+
*
|
|
52
|
+
* Returns null when the path doesn't exist at all (caller can treat
|
|
53
|
+
* as deleted-mid-settle).
|
|
54
|
+
*/
|
|
55
|
+
async function settleStat(absPath, opts) {
|
|
56
|
+
const stat = opts.stat ?? defaultStat;
|
|
57
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
58
|
+
const interval = Math.max(10, opts.intervalMs);
|
|
59
|
+
const deadline = Date.now() + Math.max(interval * 2, opts.maxSettleMs);
|
|
60
|
+
let prev = stat(absPath);
|
|
61
|
+
if (prev === null)
|
|
62
|
+
return null;
|
|
63
|
+
// Loop: sleep, re-stat, compare to previous.
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
await sleep(interval);
|
|
66
|
+
const cur = stat(absPath);
|
|
67
|
+
if (cur === null) {
|
|
68
|
+
// Disappeared mid-settle. Caller decides — most likely an
|
|
69
|
+
// unlink event will follow.
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (cur.size === prev.size && cur.mtimeMs === prev.mtimeMs) {
|
|
73
|
+
return cur;
|
|
74
|
+
}
|
|
75
|
+
prev = cur;
|
|
76
|
+
}
|
|
77
|
+
// Timed out without two-in-a-row agreement. Return the last read
|
|
78
|
+
// so the caller still has something to record. The agent loop
|
|
79
|
+
// tolerates this (file may be a never-stable log file etc.).
|
|
80
|
+
return prev;
|
|
81
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/triggers/webhook.ts — v4.5 Phase 3.
|
|
10
|
+
*
|
|
11
|
+
* Mounts POST /api/triggers/webhook/:id onto the daemon's Express
|
|
12
|
+
* app. Single dispatch endpoint handles every registered webhook
|
|
13
|
+
* trigger — route looked up by :id at request time.
|
|
14
|
+
*
|
|
15
|
+
* Ordering invariant (every request, every route):
|
|
16
|
+
*
|
|
17
|
+
* 1. Route lookup → 404 if unknown, 503 if disabled
|
|
18
|
+
* 2. Content-Length → 413 if > spec.maxBodyBytes
|
|
19
|
+
* (express.raw enforces a global cap via `limit`)
|
|
20
|
+
* 3. Body read
|
|
21
|
+
* 4. HMAC verify → 401 if bad
|
|
22
|
+
* 5. Event filter → 204 No Content if event-name excluded
|
|
23
|
+
* 6. Rate limit → 429 if over cap (POST-auth so attackers
|
|
24
|
+
* can't burn quota)
|
|
25
|
+
* 7. Idempotency → 200 with cached body if duplicate
|
|
26
|
+
* 8. trigger_events insert → 202 Accepted
|
|
27
|
+
* 9. webhook_deliveries row logged
|
|
28
|
+
* 10. Idempotency cache populated with cached response
|
|
29
|
+
*
|
|
30
|
+
* Per-route stats are recorded in-memory and surfaced via
|
|
31
|
+
* GET /api/triggers/webhook/:id/stats (Phase 3 ships this minimal
|
|
32
|
+
* diagnostic; Phase 6 adds richer surface).
|
|
33
|
+
*/
|
|
34
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
35
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
36
|
+
};
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.mountWebhookRoutes = mountWebhookRoutes;
|
|
39
|
+
exports.assertSafeBind = assertSafeBind;
|
|
40
|
+
const express_1 = __importDefault(require("express"));
|
|
41
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
42
|
+
const webhookSpec_1 = require("./webhookSpec");
|
|
43
|
+
const webhookVerifier_1 = require("./webhookVerifier");
|
|
44
|
+
const webhookRateLimit_1 = require("./webhookRateLimit");
|
|
45
|
+
const webhookIdempotency_1 = require("./webhookIdempotency");
|
|
46
|
+
const webhookDeliveriesStore_1 = require("./webhookDeliveriesStore");
|
|
47
|
+
const initialStats = () => ({
|
|
48
|
+
totalRequests: 0,
|
|
49
|
+
accepted: 0,
|
|
50
|
+
rejectedAuth: 0,
|
|
51
|
+
rejectedRate: 0,
|
|
52
|
+
rejectedSize: 0,
|
|
53
|
+
rejectedFilter: 0,
|
|
54
|
+
duplicates: 0,
|
|
55
|
+
triggerEventsEmitted: 0,
|
|
56
|
+
lastError: null,
|
|
57
|
+
});
|
|
58
|
+
const noopLog = (_l, _m) => undefined;
|
|
59
|
+
function mountWebhookRoutes(opts) {
|
|
60
|
+
const log = opts.log ?? noopLog;
|
|
61
|
+
const rateLimiter = (0, webhookRateLimit_1.createRateLimiter)();
|
|
62
|
+
const deliveries = (0, webhookDeliveriesStore_1.createWebhookDeliveriesStore)({ db: opts.db });
|
|
63
|
+
const statsByRoute = new Map();
|
|
64
|
+
let shuttingDown = false;
|
|
65
|
+
// Register as a global resource so shutdown drain can reach us.
|
|
66
|
+
const resourceId = opts.resourceRegistry.register({
|
|
67
|
+
kind: 'webhook_server',
|
|
68
|
+
owner: 'global',
|
|
69
|
+
close: async () => { shuttingDown = true; },
|
|
70
|
+
});
|
|
71
|
+
void resourceId;
|
|
72
|
+
// express.raw — give us the raw bytes so we can compute the HMAC.
|
|
73
|
+
// `limit` is the global guard; per-route maxBodyBytes is the
|
|
74
|
+
// primary check (we check after parsing the route).
|
|
75
|
+
const globalLimit = opts.globalMaxBodyBytes ?? 1048576;
|
|
76
|
+
const rawParser = express_1.default.raw({ type: '*/*', limit: globalLimit });
|
|
77
|
+
opts.app.post('/api/triggers/webhook/:id', rawParser, (req, res) => {
|
|
78
|
+
void handleWebhookRequest({
|
|
79
|
+
req, res, opts, log, rateLimiter, deliveries,
|
|
80
|
+
statsByRoute, shuttingDown,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// GET /stats — per-route diagnostic.
|
|
84
|
+
opts.app.get('/api/triggers/webhook/:id/stats', (req, res) => {
|
|
85
|
+
const routeId = String(req.params.id);
|
|
86
|
+
const s = statsByRoute.get(routeId);
|
|
87
|
+
if (!s) {
|
|
88
|
+
res.status(404).json({ error: 'unknown route or no traffic yet' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
res.status(200).json(s);
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
getStats(routeId) {
|
|
95
|
+
return statsByRoute.get(routeId) ?? null;
|
|
96
|
+
},
|
|
97
|
+
listRoutes() {
|
|
98
|
+
return [...statsByRoute.keys()];
|
|
99
|
+
},
|
|
100
|
+
shutdown() {
|
|
101
|
+
shuttingDown = true;
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
},
|
|
104
|
+
sweepDeliveries(retentionDays) {
|
|
105
|
+
return deliveries.sweep(retentionDays);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function handleWebhookRequest(ctx) {
|
|
110
|
+
const { req, res, opts, log, rateLimiter, deliveries, statsByRoute, shuttingDown } = ctx;
|
|
111
|
+
const routeId = String(req.params.id);
|
|
112
|
+
const stats = (() => {
|
|
113
|
+
let s = statsByRoute.get(routeId);
|
|
114
|
+
if (!s) {
|
|
115
|
+
s = initialStats();
|
|
116
|
+
statsByRoute.set(routeId, s);
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
})();
|
|
120
|
+
stats.totalRequests += 1;
|
|
121
|
+
if (shuttingDown) {
|
|
122
|
+
res.status(503).json({ error: 'daemon shutting down' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Step 1: load spec from DB.
|
|
126
|
+
const spec = loadSpec(opts.db, routeId);
|
|
127
|
+
if (!spec) {
|
|
128
|
+
res.status(404).json({ error: 'unknown route' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!spec.enabled) {
|
|
132
|
+
res.status(503).json({ error: 'route disabled' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const clientIp = typeof req.ip === 'string'
|
|
136
|
+
? req.ip
|
|
137
|
+
: (typeof req.socket?.remoteAddress === 'string' ? req.socket.remoteAddress : null);
|
|
138
|
+
const bodyBuf = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
|
|
139
|
+
const bodyHash = node_crypto_1.default.createHash('sha256').update(bodyBuf).digest('hex');
|
|
140
|
+
// Step 2: per-route size cap.
|
|
141
|
+
if (bodyBuf.length > spec.spec.maxBodyBytes) {
|
|
142
|
+
stats.rejectedSize += 1;
|
|
143
|
+
const responseBody = JSON.stringify({ error: 'payload too large' });
|
|
144
|
+
res.status(413).type('application/json').send(responseBody);
|
|
145
|
+
deliveries.record({
|
|
146
|
+
routeId, deliveryId: null, signatureVerified: false,
|
|
147
|
+
statusCode: 413, responseBody, clientIp,
|
|
148
|
+
headers: req.headers,
|
|
149
|
+
bodyHash, triggerEventId: null,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Step 3: HMAC verify.
|
|
154
|
+
const verified = (0, webhookVerifier_1.verifyWebhookSignature)({
|
|
155
|
+
format: spec.spec.hmacFormat,
|
|
156
|
+
secret: spec.spec.secret,
|
|
157
|
+
body: bodyBuf,
|
|
158
|
+
headers: req.headers,
|
|
159
|
+
});
|
|
160
|
+
if (!verified) {
|
|
161
|
+
stats.rejectedAuth += 1;
|
|
162
|
+
const responseBody = JSON.stringify({ error: 'invalid signature' });
|
|
163
|
+
res.status(401).type('application/json').send(responseBody);
|
|
164
|
+
deliveries.record({
|
|
165
|
+
routeId, deliveryId: null, signatureVerified: false,
|
|
166
|
+
statusCode: 401, responseBody, clientIp,
|
|
167
|
+
headers: req.headers,
|
|
168
|
+
bodyHash, triggerEventId: null,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Step 4: event filter.
|
|
173
|
+
if (spec.spec.allowedEvents && spec.spec.allowedEvents.length > 0) {
|
|
174
|
+
const ev = (0, webhookVerifier_1.deriveEventName)(spec.spec.hmacFormat, req.headers);
|
|
175
|
+
if (!spec.spec.allowedEvents.includes(ev)) {
|
|
176
|
+
stats.rejectedFilter += 1;
|
|
177
|
+
res.status(204).end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Step 5: rate limit (POST-auth).
|
|
182
|
+
if (!rateLimiter.allow(routeId, spec.spec.rateLimit.perMinute)) {
|
|
183
|
+
stats.rejectedRate += 1;
|
|
184
|
+
const responseBody = JSON.stringify({ error: 'rate limit exceeded' });
|
|
185
|
+
res.status(429).type('application/json').send(responseBody);
|
|
186
|
+
deliveries.record({
|
|
187
|
+
routeId, deliveryId: null, signatureVerified: true,
|
|
188
|
+
statusCode: 429, responseBody, clientIp,
|
|
189
|
+
headers: req.headers,
|
|
190
|
+
bodyHash, triggerEventId: null,
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Step 6: idempotency check.
|
|
195
|
+
const idemKey = (0, webhookIdempotency_1.deriveIdempotencyKey)(routeId, spec.spec.hmacFormat, bodyBuf, req.headers);
|
|
196
|
+
const cached = opts.idempotencyStore.get('webhook', idemKey);
|
|
197
|
+
if (cached) {
|
|
198
|
+
stats.duplicates += 1;
|
|
199
|
+
// v4.5 Phase 6 — idempotency-replay cosmetic fix.
|
|
200
|
+
// The cached body was stamped with `deduplicated: false` on the
|
|
201
|
+
// first (successful) accept. Re-issuing it verbatim on a replay
|
|
202
|
+
// made every retried delivery look like a brand-new accept,
|
|
203
|
+
// which confused operators investigating duplicate POSTs. Inject
|
|
204
|
+
// `deduplicated: true` dynamically here so retries report the
|
|
205
|
+
// truth without a schema change.
|
|
206
|
+
let replayBody = cached.responseJson;
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(cached.responseJson);
|
|
209
|
+
parsed.deduplicated = true;
|
|
210
|
+
replayBody = JSON.stringify(parsed);
|
|
211
|
+
}
|
|
212
|
+
catch { /* malformed cache entry — fall back to verbatim */ }
|
|
213
|
+
res.status(cached.statusCode).type('application/json').send(replayBody);
|
|
214
|
+
// Log the replay in webhook_deliveries with the corrected body so
|
|
215
|
+
// forensic search ("show me all deduped replays") works.
|
|
216
|
+
try {
|
|
217
|
+
deliveries.record({
|
|
218
|
+
routeId,
|
|
219
|
+
deliveryId: spec.spec.hmacFormat === 'github'
|
|
220
|
+
? (pickHeaderStr(req.headers, 'x-github-delivery') ?? null)
|
|
221
|
+
: null,
|
|
222
|
+
signatureVerified: true,
|
|
223
|
+
statusCode: cached.statusCode,
|
|
224
|
+
responseBody: replayBody,
|
|
225
|
+
clientIp,
|
|
226
|
+
headers: req.headers,
|
|
227
|
+
bodyHash,
|
|
228
|
+
triggerEventId: null,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch { /* never let delivery logging crash the replay path */ }
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Step 7: insert trigger_event + record delivery.
|
|
235
|
+
try {
|
|
236
|
+
let payload;
|
|
237
|
+
try {
|
|
238
|
+
payload = JSON.parse(bodyBuf.toString('utf-8'));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
payload = { _raw: bodyBuf.toString('base64'), _format: 'base64' };
|
|
242
|
+
}
|
|
243
|
+
const event = (0, webhookVerifier_1.deriveEventName)(spec.spec.hmacFormat, req.headers);
|
|
244
|
+
const deliveryMode = spec.spec.deliverOnly ? 'deliver_only' : 'agent';
|
|
245
|
+
const inserted = opts.triggerBus.insert({
|
|
246
|
+
source: 'webhook',
|
|
247
|
+
sourceKey: routeId,
|
|
248
|
+
idempotencyKey: idemKey,
|
|
249
|
+
payload: {
|
|
250
|
+
headers: selectHeadersForPayload(req.headers),
|
|
251
|
+
event,
|
|
252
|
+
body: payload,
|
|
253
|
+
deliveryMode,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
stats.triggerEventsEmitted += 1;
|
|
257
|
+
stats.accepted += 1;
|
|
258
|
+
const responseBody = JSON.stringify({
|
|
259
|
+
status: 'accepted',
|
|
260
|
+
event_id: inserted.id,
|
|
261
|
+
deduplicated: !inserted.inserted,
|
|
262
|
+
deliveryMode,
|
|
263
|
+
});
|
|
264
|
+
res.status(202).type('application/json').send(responseBody);
|
|
265
|
+
// Cache the response for retries within the idempotency TTL.
|
|
266
|
+
opts.idempotencyStore.set('webhook', idemKey, null, { responseJson: responseBody, statusCode: 202 }, spec.spec.idempotencyTtlMs);
|
|
267
|
+
deliveries.record({
|
|
268
|
+
routeId,
|
|
269
|
+
deliveryId: spec.spec.hmacFormat === 'github'
|
|
270
|
+
? (pickHeaderStr(req.headers, 'x-github-delivery') ?? null)
|
|
271
|
+
: null,
|
|
272
|
+
signatureVerified: true,
|
|
273
|
+
statusCode: 202,
|
|
274
|
+
responseBody,
|
|
275
|
+
clientIp,
|
|
276
|
+
headers: req.headers,
|
|
277
|
+
bodyHash,
|
|
278
|
+
triggerEventId: inserted.id,
|
|
279
|
+
});
|
|
280
|
+
if (spec.spec.deliverOnly) {
|
|
281
|
+
log('info', `[webhook] deliver_only stub for ${routeId} — Phase 5 will dispatch via channel target`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
286
|
+
stats.lastError = msg;
|
|
287
|
+
log('error', `[webhook] handler error for ${routeId}: ${msg}`);
|
|
288
|
+
const responseBody = JSON.stringify({ error: 'internal error' });
|
|
289
|
+
res.status(500).type('application/json').send(responseBody);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function loadSpec(db, routeId) {
|
|
293
|
+
const row = db
|
|
294
|
+
.prepare(`SELECT enabled, spec_json FROM triggers WHERE id = ? AND source = 'webhook'`)
|
|
295
|
+
.get(routeId);
|
|
296
|
+
if (!row)
|
|
297
|
+
return null;
|
|
298
|
+
try {
|
|
299
|
+
const spec = (0, webhookSpec_1.parseWebhookSpec)(row.spec_json);
|
|
300
|
+
return { enabled: row.enabled === 1, spec };
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function selectHeadersForPayload(headers) {
|
|
307
|
+
// Same selective subset as the deliveries store, plus event names
|
|
308
|
+
// so the agent payload includes what triggered it.
|
|
309
|
+
const out = {};
|
|
310
|
+
const want = new Set([
|
|
311
|
+
'content-type', 'user-agent',
|
|
312
|
+
'x-github-event', 'x-github-delivery',
|
|
313
|
+
'x-gitlab-event',
|
|
314
|
+
'x-webhook-event',
|
|
315
|
+
'x-request-id',
|
|
316
|
+
'x-forwarded-for',
|
|
317
|
+
]);
|
|
318
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
319
|
+
const lk = k.toLowerCase();
|
|
320
|
+
if (!want.has(lk))
|
|
321
|
+
continue;
|
|
322
|
+
const value = Array.isArray(v) ? v[0] : v;
|
|
323
|
+
if (typeof value === 'string')
|
|
324
|
+
out[lk] = value;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function pickHeaderStr(headers, name) {
|
|
329
|
+
const v = headers[name.toLowerCase()];
|
|
330
|
+
if (Array.isArray(v))
|
|
331
|
+
return v[0] ?? null;
|
|
332
|
+
if (typeof v === 'string')
|
|
333
|
+
return v;
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Public-bind guard — refuses to start a daemon bound to a non-
|
|
338
|
+
* loopback address when (a) any registered webhook route uses the
|
|
339
|
+
* INSECURE_NO_AUTH sentinel OR (b) AIDEN_API_KEY is unset.
|
|
340
|
+
*
|
|
341
|
+
* Called from bootstrap BEFORE the HTTP listener binds.
|
|
342
|
+
*/
|
|
343
|
+
function assertSafeBind(opts) {
|
|
344
|
+
if (opts.bindHost === '127.0.0.1' || opts.bindHost === 'localhost' || opts.bindHost === '::1') {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!opts.apiKeyConfigured) {
|
|
348
|
+
const msg = `[daemon] AIDEN_DAEMON_BIND=${opts.bindHost} requires AIDEN_API_KEY to be set. ` +
|
|
349
|
+
`Refusing to start — public bind without a bearer token would expose ` +
|
|
350
|
+
`/api/chat, /v1/*, and /api/scheduler/* unauthenticated. ` +
|
|
351
|
+
`Set AIDEN_API_KEY or revert to AIDEN_DAEMON_BIND=127.0.0.1.`;
|
|
352
|
+
opts.log('error', msg);
|
|
353
|
+
throw new Error(msg);
|
|
354
|
+
}
|
|
355
|
+
// Look up any INSECURE_NO_AUTH webhook routes — REFUSE.
|
|
356
|
+
const rows = opts.db
|
|
357
|
+
.prepare(`SELECT id, name, spec_json FROM triggers WHERE source = 'webhook' AND enabled = 1`)
|
|
358
|
+
.all();
|
|
359
|
+
for (const r of rows) {
|
|
360
|
+
try {
|
|
361
|
+
const spec = (0, webhookSpec_1.parseWebhookSpec)(r.spec_json);
|
|
362
|
+
if (spec.secret === webhookSpec_1.INSECURE_NO_AUTH) {
|
|
363
|
+
const msg = `[daemon] webhook route '${r.name}' (${r.id}) uses the INSECURE_NO_AUTH ` +
|
|
364
|
+
`sentinel which is only valid on loopback. Daemon is bound to ` +
|
|
365
|
+
`${opts.bindHost}; refusing to start. Disable the route or set a real secret.`;
|
|
366
|
+
opts.log('error', msg);
|
|
367
|
+
throw new Error(msg);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
if (e instanceof Error && e.message.includes('INSECURE_NO_AUTH'))
|
|
372
|
+
throw e;
|
|
373
|
+
// Bad spec — let the request-time loadSpec handle it.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/triggers/webhookDeliveriesStore.ts — v4.5 Phase 3.
|
|
10
|
+
*
|
|
11
|
+
* Writer for the `webhook_deliveries` table. Every POST gets a row
|
|
12
|
+
* (verified or not) so operators have a forensic trail.
|
|
13
|
+
*
|
|
14
|
+
* Retention: AIDEN_DAEMON_WEBHOOK_RETENTION_DAYS env var (default 7).
|
|
15
|
+
* Sweep runs on boot + every 24h.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.createWebhookDeliveriesStore = createWebhookDeliveriesStore;
|
|
19
|
+
function rowToTs(r) {
|
|
20
|
+
return {
|
|
21
|
+
id: r.id,
|
|
22
|
+
routeId: r.route_id,
|
|
23
|
+
deliveryId: r.delivery_id,
|
|
24
|
+
signatureVerified: r.signature_verified === 1,
|
|
25
|
+
statusCode: r.status_code,
|
|
26
|
+
responseBody: r.response_body,
|
|
27
|
+
clientIp: r.client_ip,
|
|
28
|
+
headersJson: r.headers_json,
|
|
29
|
+
bodyHash: r.body_hash,
|
|
30
|
+
receivedAt: r.received_at,
|
|
31
|
+
processedAt: r.processed_at,
|
|
32
|
+
triggerEventId: r.trigger_event_id,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const FORENSIC_HEADER_NAMES = new Set([
|
|
36
|
+
'content-type',
|
|
37
|
+
'content-length',
|
|
38
|
+
'user-agent',
|
|
39
|
+
'x-github-event',
|
|
40
|
+
'x-github-delivery',
|
|
41
|
+
'x-gitlab-event',
|
|
42
|
+
'x-gitlab-token', // present-flag only — VALUE redacted below
|
|
43
|
+
'x-hub-signature-256', // present-flag only — VALUE redacted below
|
|
44
|
+
'x-webhook-signature', // present-flag only — VALUE redacted below
|
|
45
|
+
'x-webhook-event',
|
|
46
|
+
'x-request-id',
|
|
47
|
+
'x-forwarded-for',
|
|
48
|
+
]);
|
|
49
|
+
const REDACT_HEADERS = new Set([
|
|
50
|
+
'x-gitlab-token',
|
|
51
|
+
'x-hub-signature-256',
|
|
52
|
+
'x-webhook-signature',
|
|
53
|
+
'authorization',
|
|
54
|
+
'cookie',
|
|
55
|
+
]);
|
|
56
|
+
function selectHeaders(headers) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
59
|
+
const lk = k.toLowerCase();
|
|
60
|
+
if (!FORENSIC_HEADER_NAMES.has(lk))
|
|
61
|
+
continue;
|
|
62
|
+
const value = Array.isArray(v) ? v[0] : v;
|
|
63
|
+
if (typeof value !== 'string')
|
|
64
|
+
continue;
|
|
65
|
+
out[lk] = REDACT_HEADERS.has(lk) ? '<redacted>' : value;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function createWebhookDeliveriesStore(opts) {
|
|
70
|
+
const db = opts.db;
|
|
71
|
+
return {
|
|
72
|
+
record(input) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const headersJson = JSON.stringify(selectHeaders(input.headers));
|
|
75
|
+
// Truncate response body for forensic storage (1 KB cap).
|
|
76
|
+
const responseBody = input.responseBody != null
|
|
77
|
+
? input.responseBody.length > 1024 ? input.responseBody.slice(0, 1024) + '…' : input.responseBody
|
|
78
|
+
: null;
|
|
79
|
+
const r = db
|
|
80
|
+
.prepare(`INSERT INTO webhook_deliveries
|
|
81
|
+
(route_id, delivery_id, signature_verified, status_code,
|
|
82
|
+
response_body, client_ip, headers_json, body_hash,
|
|
83
|
+
received_at, processed_at, trigger_event_id)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
85
|
+
.run(input.routeId, input.deliveryId, input.signatureVerified ? 1 : 0, input.statusCode, responseBody, input.clientIp, headersJson, input.bodyHash, now, now, input.triggerEventId);
|
|
86
|
+
return Number(r.lastInsertRowid);
|
|
87
|
+
},
|
|
88
|
+
list(routeId, limit = 100) {
|
|
89
|
+
const rows = db
|
|
90
|
+
.prepare(`SELECT * FROM webhook_deliveries WHERE route_id = ?
|
|
91
|
+
ORDER BY received_at DESC LIMIT ?`)
|
|
92
|
+
.all(routeId, limit);
|
|
93
|
+
return rows.map(rowToTs);
|
|
94
|
+
},
|
|
95
|
+
sweep(retentionDays, now) {
|
|
96
|
+
const cutoff = (now ?? Date.now()) - retentionDays * 24 * 60 * 60 * 1000;
|
|
97
|
+
const r = db
|
|
98
|
+
.prepare(`DELETE FROM webhook_deliveries WHERE received_at < ?`)
|
|
99
|
+
.run(cutoff);
|
|
100
|
+
return { deleted: r.changes };
|
|
101
|
+
},
|
|
102
|
+
countForRoute(routeId) {
|
|
103
|
+
const r = db
|
|
104
|
+
.prepare(`SELECT COUNT(*) AS c FROM webhook_deliveries WHERE route_id = ?`)
|
|
105
|
+
.get(routeId);
|
|
106
|
+
return r.c;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/triggers/webhookIdempotency.ts — v4.5 Phase 3.
|
|
10
|
+
*
|
|
11
|
+
* Key derivation for the L1/L2 idempotency cache. Phase 1's
|
|
12
|
+
* `idempotencyStore` does the actual caching; this module just
|
|
13
|
+
* computes the key per format.
|
|
14
|
+
*
|
|
15
|
+
* Priority order:
|
|
16
|
+
* github → X-GitHub-Delivery header
|
|
17
|
+
* gitlab → X-Gitlab-Event + X-Request-Id (or X-Gitlab-Event alone)
|
|
18
|
+
* any → X-Request-Id
|
|
19
|
+
* fallback→ sha256(routeId + body + 5_000ms_bucket) — defeats burst
|
|
20
|
+
* retries while still letting deliberate re-posts through
|
|
21
|
+
* a few seconds apart
|
|
22
|
+
*/
|
|
23
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
24
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.deriveIdempotencyKey = deriveIdempotencyKey;
|
|
28
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
29
|
+
function deriveIdempotencyKey(routeId, format, body, headers, now) {
|
|
30
|
+
if (format === 'github') {
|
|
31
|
+
const id = pickHeader(headers, 'x-github-delivery');
|
|
32
|
+
if (id)
|
|
33
|
+
return `gh:${id}`;
|
|
34
|
+
}
|
|
35
|
+
if (format === 'gitlab') {
|
|
36
|
+
const event = pickHeader(headers, 'x-gitlab-event');
|
|
37
|
+
const reqId = pickHeader(headers, 'x-request-id');
|
|
38
|
+
if (event && reqId)
|
|
39
|
+
return `gl:${event}:${reqId}`;
|
|
40
|
+
if (event)
|
|
41
|
+
return `gl:${event}:${shortHash(routeId, body, now)}`;
|
|
42
|
+
}
|
|
43
|
+
const reqId = pickHeader(headers, 'x-request-id');
|
|
44
|
+
if (reqId)
|
|
45
|
+
return `gen:${reqId}`;
|
|
46
|
+
return `sha:${shortHash(routeId, body, now)}`;
|
|
47
|
+
}
|
|
48
|
+
function shortHash(routeId, body, now) {
|
|
49
|
+
const bucket = Math.floor((now ?? Date.now()) / 5000);
|
|
50
|
+
const h = node_crypto_1.default.createHash('sha256');
|
|
51
|
+
h.update(routeId);
|
|
52
|
+
h.update('|');
|
|
53
|
+
h.update(body);
|
|
54
|
+
h.update('|');
|
|
55
|
+
h.update(String(bucket));
|
|
56
|
+
return h.digest('hex').slice(0, 32);
|
|
57
|
+
}
|
|
58
|
+
function pickHeader(headers, name) {
|
|
59
|
+
const k = name.toLowerCase();
|
|
60
|
+
// Express middleware lowercases header keys, but unit tests + ad-hoc
|
|
61
|
+
// callers may pass mixed-case. Scan with a lowercase compare.
|
|
62
|
+
for (const [hk, v] of Object.entries(headers)) {
|
|
63
|
+
if (hk.toLowerCase() !== k)
|
|
64
|
+
continue;
|
|
65
|
+
if (Array.isArray(v))
|
|
66
|
+
return v[0] ?? null;
|
|
67
|
+
if (typeof v === 'string')
|
|
68
|
+
return v;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/triggers/webhookRateLimit.ts — v4.5 Phase 3.
|
|
10
|
+
*
|
|
11
|
+
* Per-route fixed-window rate limiter. In-memory only — daemon
|
|
12
|
+
* restart resets the counters (acceptable).
|
|
13
|
+
*
|
|
14
|
+
* Window: 60 seconds. Per-route cap configured by spec.rateLimit.perMinute.
|
|
15
|
+
*
|
|
16
|
+
* Critical ordering: called AFTER HMAC verification so an
|
|
17
|
+
* unauthenticated attacker cannot burn the legitimate quota with
|
|
18
|
+
* bad-signature spam.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.createRateLimiter = createRateLimiter;
|
|
22
|
+
const WINDOW_MS = 60000;
|
|
23
|
+
function createRateLimiter(opts = {}) {
|
|
24
|
+
const now = opts.now ?? (() => Date.now());
|
|
25
|
+
const windows = new Map();
|
|
26
|
+
return {
|
|
27
|
+
allow(routeId, perMinute) {
|
|
28
|
+
const t = now();
|
|
29
|
+
let w = windows.get(routeId);
|
|
30
|
+
if (!w) {
|
|
31
|
+
w = [];
|
|
32
|
+
windows.set(routeId, w);
|
|
33
|
+
}
|
|
34
|
+
// Prune entries older than the window.
|
|
35
|
+
const cutoff = t - WINDOW_MS;
|
|
36
|
+
while (w.length > 0 && w[0] < cutoff)
|
|
37
|
+
w.shift();
|
|
38
|
+
if (w.length >= perMinute)
|
|
39
|
+
return false;
|
|
40
|
+
w.push(t);
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
stats(routeId) {
|
|
44
|
+
const w = windows.get(routeId);
|
|
45
|
+
if (!w || w.length === 0)
|
|
46
|
+
return { count: 0, oldestMs: null };
|
|
47
|
+
return { count: w.length, oldestMs: w[0] };
|
|
48
|
+
},
|
|
49
|
+
reset(routeId) {
|
|
50
|
+
if (routeId === undefined)
|
|
51
|
+
windows.clear();
|
|
52
|
+
else
|
|
53
|
+
windows.delete(routeId);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|