@secondlayer/subgraphs 7.2.0 → 7.4.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/dist/index.d.ts +10 -2
- package/dist/index.js +195 -127
- package/dist/index.js.map +5 -5
- package/dist/runtime/emitter.d.ts +2 -1
- package/dist/runtime/emitter.js +134 -24
- package/dist/runtime/emitter.js.map +6 -5
- package/dist/runtime/replay.js +30 -12
- package/dist/runtime/replay.js.map +4 -4
- package/dist/schema/index.js +3 -3
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/index.js +1691 -1627
- package/dist/testing/index.js.map +7 -7
- package/dist/verification.js +3 -3
- package/dist/verification.js.map +1 -1
- package/dist/verify/index.js +101 -39
- package/dist/verify/index.js.map +5 -5
- package/package.json +2 -2
|
@@ -16,6 +16,7 @@ import { Kysely } from "kysely";
|
|
|
16
16
|
*/
|
|
17
17
|
declare const MAX_WEBHOOK_TIMEOUT_MS: number;
|
|
18
18
|
declare const LOCK_WINDOW_MS: number;
|
|
19
|
+
declare function allowPrivateEgress(): boolean;
|
|
19
20
|
interface ResolvedAddress {
|
|
20
21
|
address: string;
|
|
21
22
|
family: number;
|
|
@@ -105,4 +106,4 @@ interface StartEmitterOptions {
|
|
|
105
106
|
stopDrainDeadlineMs?: number;
|
|
106
107
|
}
|
|
107
108
|
declare function startEmitter(opts?: StartEmitterOptions): Promise<() => Promise<void>>;
|
|
108
|
-
export { LOCK_WINDOW_MS, MAX_REDIRECT_HOPS, MAX_WEBHOOK_TIMEOUT_MS, StartEmitterOptions, __postToWebhookForTest, __setDnsLookupForTest, checkEgressAllowed, deliverTestEvent, startEmitter };
|
|
109
|
+
export { LOCK_WINDOW_MS, MAX_REDIRECT_HOPS, MAX_WEBHOOK_TIMEOUT_MS, StartEmitterOptions, __postToWebhookForTest, __setDnsLookupForTest, allowPrivateEgress, checkEgressAllowed, deliverTestEvent, startEmitter };
|
package/dist/runtime/emitter.js
CHANGED
|
@@ -6,12 +6,12 @@ import {
|
|
|
6
6
|
getTargetDb
|
|
7
7
|
} from "@secondlayer/shared/db";
|
|
8
8
|
import { getWebhookSigningSecret } from "@secondlayer/shared/db/queries/webhooks";
|
|
9
|
-
import { logger as
|
|
9
|
+
import { logger as logger4 } from "@secondlayer/shared/logger";
|
|
10
10
|
import { listen, targetListenerUrl } from "@secondlayer/shared/queue/listener";
|
|
11
11
|
import {
|
|
12
12
|
webhookTimeoutMsCeiling
|
|
13
13
|
} from "@secondlayer/shared/schemas/webhooks";
|
|
14
|
-
import { sql } from "kysely";
|
|
14
|
+
import { sql as sql2 } from "kysely";
|
|
15
15
|
|
|
16
16
|
// src/runtime/formats/index.ts
|
|
17
17
|
import { signSecondlayerWebhook } from "@secondlayer/shared/crypto/secondlayer-webhook";
|
|
@@ -194,6 +194,106 @@ function recordDelivery() {
|
|
|
194
194
|
pending += 1;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// src/runtime/receiver-cert.ts
|
|
198
|
+
import { isIP } from "node:net";
|
|
199
|
+
import tls from "node:tls";
|
|
200
|
+
import { logger as logger3 } from "@secondlayer/shared/logger";
|
|
201
|
+
import { sql } from "kysely";
|
|
202
|
+
var PROBE_TIMEOUT_MS = 5000;
|
|
203
|
+
var RECHECK_AFTER_HOURS = 6;
|
|
204
|
+
var BATCH_LIMIT = 50;
|
|
205
|
+
async function probeCertExpiry(url, opts) {
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = new URL(url);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (parsed.protocol !== "https:")
|
|
213
|
+
return null;
|
|
214
|
+
if (!allowPrivateEgress()) {
|
|
215
|
+
let refusal;
|
|
216
|
+
try {
|
|
217
|
+
refusal = await checkEgressAllowed(url);
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
if (refusal)
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const raw = parsed.hostname;
|
|
225
|
+
const host = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
|
|
226
|
+
const connect = opts?.connect ?? tls.connect;
|
|
227
|
+
const timeoutMs = opts?.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const open = {};
|
|
230
|
+
let settled = false;
|
|
231
|
+
const finish = (expires) => {
|
|
232
|
+
if (settled)
|
|
233
|
+
return;
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
try {
|
|
237
|
+
open.socket?.destroy();
|
|
238
|
+
} catch {}
|
|
239
|
+
resolve(expires);
|
|
240
|
+
};
|
|
241
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
242
|
+
let probe;
|
|
243
|
+
try {
|
|
244
|
+
probe = connect({
|
|
245
|
+
host,
|
|
246
|
+
port: Number(parsed.port) || 443,
|
|
247
|
+
...isIP(host) ? {} : { servername: host },
|
|
248
|
+
rejectUnauthorized: false
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
finish(null);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
open.socket = probe;
|
|
255
|
+
probe.once("secureConnect", () => {
|
|
256
|
+
try {
|
|
257
|
+
const expires = new Date(probe.getPeerCertificate().valid_to);
|
|
258
|
+
finish(Number.isNaN(expires.getTime()) ? null : expires);
|
|
259
|
+
} catch {
|
|
260
|
+
finish(null);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
probe.once("error", () => finish(null));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async function refreshReceiverCerts(db, opts) {
|
|
267
|
+
const probe = opts?.probe ?? probeCertExpiry;
|
|
268
|
+
const due = await db.selectFrom("webhooks").select(["id", "url"]).where("status", "=", "active").where("url", "like", "https:%").where((eb) => eb.or([
|
|
269
|
+
eb("receiver_cert_checked_at", "is", null),
|
|
270
|
+
eb("receiver_cert_checked_at", "<", sql`now() - make_interval(hours => ${RECHECK_AFTER_HOURS})`)
|
|
271
|
+
])).orderBy(sql`receiver_cert_checked_at asc nulls first`).limit(BATCH_LIMIT).execute();
|
|
272
|
+
const idsByOrigin = new Map;
|
|
273
|
+
for (const { id, url } of due) {
|
|
274
|
+
let origin;
|
|
275
|
+
try {
|
|
276
|
+
origin = new URL(url).origin;
|
|
277
|
+
} catch {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const entry = idsByOrigin.get(origin) ?? { url, ids: [] };
|
|
281
|
+
entry.ids.push(id);
|
|
282
|
+
idsByOrigin.set(origin, entry);
|
|
283
|
+
}
|
|
284
|
+
for (const [origin, { url, ids }] of idsByOrigin) {
|
|
285
|
+
const expires = await probe(url);
|
|
286
|
+
await db.updateTable("webhooks").set({
|
|
287
|
+
receiver_cert_expires_at: expires,
|
|
288
|
+
receiver_cert_checked_at: new Date
|
|
289
|
+
}).where("id", "in", ids).execute();
|
|
290
|
+
logger3.debug("[emitter] receiver cert probed", {
|
|
291
|
+
origin,
|
|
292
|
+
expiresAt: expires?.toISOString() ?? null
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
197
297
|
// src/runtime/emitter.ts
|
|
198
298
|
var BATCH_SIZE = 50;
|
|
199
299
|
var LIVE_SHARE = 0.9;
|
|
@@ -288,6 +388,7 @@ function isPrivateEgress(url) {
|
|
|
288
388
|
return true;
|
|
289
389
|
return isPrivateIp(host);
|
|
290
390
|
}
|
|
391
|
+
var RECEIVER_CERT_INTERVAL_MS = 30 * 60000;
|
|
291
392
|
function allowPrivateEgress() {
|
|
292
393
|
return process.env.SECONDLAYER_ALLOW_PRIVATE_EGRESS === "true";
|
|
293
394
|
}
|
|
@@ -345,7 +446,7 @@ async function postToWebhook(url, body, headers, timeoutMs) {
|
|
|
345
446
|
if (!allowPrivateEgress()) {
|
|
346
447
|
const refusal = await checkEgressAllowed(target);
|
|
347
448
|
if (refusal) {
|
|
348
|
-
|
|
449
|
+
logger4.warn("[emitter] refused private egress", {
|
|
349
450
|
url: target,
|
|
350
451
|
reason: refusal
|
|
351
452
|
});
|
|
@@ -485,7 +586,7 @@ async function settleDelivered(db, outboxRow) {
|
|
|
485
586
|
locked_until: null
|
|
486
587
|
}).where("id", "=", outboxRow.id).where("status", "=", "pending").executeTakeFirst();
|
|
487
588
|
if (Number(result.numUpdatedRows ?? 0) === 0) {
|
|
488
|
-
|
|
589
|
+
logger4.info("webhook.settle.orphaned", {
|
|
489
590
|
outboxId: outboxRow.id,
|
|
490
591
|
webhookId: outboxRow.webhook_id,
|
|
491
592
|
outcome: "delivered"
|
|
@@ -516,14 +617,14 @@ async function settleFailed(db, outboxRow, sub, errText) {
|
|
|
516
617
|
locked_until: null
|
|
517
618
|
}).where("id", "=", outboxRow.id).where("status", "=", "pending").executeTakeFirst();
|
|
518
619
|
if (Number(result.numUpdatedRows ?? 0) === 0) {
|
|
519
|
-
|
|
620
|
+
logger4.info("webhook.settle.orphaned", {
|
|
520
621
|
outboxId: outboxRow.id,
|
|
521
622
|
webhookId: outboxRow.webhook_id,
|
|
522
623
|
outcome: "failed"
|
|
523
624
|
});
|
|
524
625
|
return;
|
|
525
626
|
}
|
|
526
|
-
const incResult = await
|
|
627
|
+
const incResult = await sql2`
|
|
527
628
|
UPDATE webhooks
|
|
528
629
|
SET circuit_failures = circuit_failures + 1,
|
|
529
630
|
last_delivery_at = NOW(),
|
|
@@ -540,7 +641,7 @@ async function settleFailed(db, outboxRow, sub, errText) {
|
|
|
540
641
|
circuit_opened_at: new Date,
|
|
541
642
|
updated_at: new Date
|
|
542
643
|
}).where("id", "=", sub.id).execute();
|
|
543
|
-
|
|
644
|
+
logger4.warn("Webhook circuit tripped — paused after consecutive failures", {
|
|
544
645
|
webhook: sub.name,
|
|
545
646
|
failures: newFailures
|
|
546
647
|
});
|
|
@@ -585,23 +686,23 @@ async function claimAndDispatchOnce(db, state, emitterId, trigger) {
|
|
|
585
686
|
const liveLimit = Math.max(1, Math.round(BATCH_SIZE * LIVE_SHARE));
|
|
586
687
|
const replayLimit = BATCH_SIZE - liveLimit;
|
|
587
688
|
const { combined, liveCount, replayCount } = await db.transaction().execute(async (tx) => {
|
|
588
|
-
const live = await
|
|
689
|
+
const live = await sql2`
|
|
589
690
|
SELECT * FROM webhook_outbox
|
|
590
691
|
WHERE status = 'pending'
|
|
591
692
|
AND next_attempt_at <= NOW()
|
|
592
693
|
AND is_replay = FALSE
|
|
593
694
|
ORDER BY next_attempt_at ASC
|
|
594
695
|
FOR UPDATE SKIP LOCKED
|
|
595
|
-
LIMIT ${
|
|
696
|
+
LIMIT ${sql2.lit(liveLimit)}
|
|
596
697
|
`.execute(tx);
|
|
597
|
-
const replay = await
|
|
698
|
+
const replay = await sql2`
|
|
598
699
|
SELECT * FROM webhook_outbox
|
|
599
700
|
WHERE status = 'pending'
|
|
600
701
|
AND next_attempt_at <= NOW()
|
|
601
702
|
AND is_replay = TRUE
|
|
602
703
|
ORDER BY next_attempt_at ASC
|
|
603
704
|
FOR UPDATE SKIP LOCKED
|
|
604
|
-
LIMIT ${
|
|
705
|
+
LIMIT ${sql2.lit(replayLimit)}
|
|
605
706
|
`.execute(tx);
|
|
606
707
|
const rows = [...live.rows, ...replay.rows];
|
|
607
708
|
if (rows.length === 0) {
|
|
@@ -654,7 +755,7 @@ async function claimAndDispatchOnce(db, state, emitterId, trigger) {
|
|
|
654
755
|
const oldestCreatedAtMs = Math.min(...combined.map((r) => new Date(r.created_at).getTime()));
|
|
655
756
|
const inFlight = subIds.reduce((sum, id) => sum + (state.subQueues.get(id)?.inFlight ?? 0), 0);
|
|
656
757
|
const concurrency = subIds.reduce((sum, id) => sum + (subById.get(id)?.concurrency || 4), 0);
|
|
657
|
-
|
|
758
|
+
logger4.info("emitter claim cycle", {
|
|
658
759
|
event: "emitter_claim",
|
|
659
760
|
trigger,
|
|
660
761
|
claimed: combined.length,
|
|
@@ -709,7 +810,7 @@ async function runOne(db, state, sq, row) {
|
|
|
709
810
|
await settleFailed(db, row, sq.sub, err);
|
|
710
811
|
}
|
|
711
812
|
} catch (err) {
|
|
712
|
-
|
|
813
|
+
logger4.error("Emitter dispatch crashed", {
|
|
713
814
|
outboxId: row.id,
|
|
714
815
|
error: err instanceof Error ? err.message : String(err)
|
|
715
816
|
});
|
|
@@ -720,15 +821,15 @@ async function runOne(db, state, sq, row) {
|
|
|
720
821
|
}
|
|
721
822
|
}
|
|
722
823
|
async function runRetention(db) {
|
|
723
|
-
await
|
|
824
|
+
await sql2`
|
|
724
825
|
DELETE FROM webhook_outbox
|
|
725
826
|
WHERE status = 'delivered' AND delivered_at < NOW() - interval '7 days'
|
|
726
827
|
`.execute(db);
|
|
727
|
-
await
|
|
828
|
+
await sql2`
|
|
728
829
|
DELETE FROM webhook_deliveries
|
|
729
830
|
WHERE dispatched_at < NOW() - interval '30 days'
|
|
730
831
|
`.execute(db);
|
|
731
|
-
await
|
|
832
|
+
await sql2`
|
|
732
833
|
DELETE FROM webhook_outbox
|
|
733
834
|
WHERE status = 'dead' AND failed_at < NOW() - interval '90 days'
|
|
734
835
|
`.execute(db);
|
|
@@ -746,30 +847,30 @@ async function startEmitter(opts) {
|
|
|
746
847
|
};
|
|
747
848
|
const pollIntervalMs = opts?.pollIntervalMs ?? 120000;
|
|
748
849
|
const retentionIntervalMs = opts?.retentionIntervalMs ?? 60 * 60000;
|
|
749
|
-
|
|
850
|
+
logger4.info("[emitter] started", { id: emitterId });
|
|
750
851
|
const listenUrl = targetListenerUrl();
|
|
751
852
|
const stopNew = await listen("webhooks:new_outbox", () => {
|
|
752
853
|
if (!state.running)
|
|
753
854
|
return;
|
|
754
|
-
claimAndDrain(db, state, emitterId, "notify").catch((err) =>
|
|
855
|
+
claimAndDrain(db, state, emitterId, "notify").catch((err) => logger4.error("[emitter] claim failed", {
|
|
755
856
|
error: err instanceof Error ? err.message : String(err)
|
|
756
857
|
}));
|
|
757
858
|
}, { connectionString: listenUrl });
|
|
758
859
|
const stopChanged = await listen("webhooks:changed", () => {
|
|
759
860
|
if (!state.running)
|
|
760
861
|
return;
|
|
761
|
-
claimAndDrain(db, state, emitterId, "changed").catch((err) =>
|
|
862
|
+
claimAndDrain(db, state, emitterId, "changed").catch((err) => logger4.error("[emitter] claim failed", {
|
|
762
863
|
error: err instanceof Error ? err.message : String(err)
|
|
763
864
|
}));
|
|
764
865
|
}, { connectionString: listenUrl });
|
|
765
|
-
|
|
866
|
+
logger4.info("[emitter] wake listeners connected", {
|
|
766
867
|
channels: ["webhooks:new_outbox", "webhooks:changed"],
|
|
767
868
|
db: describeDbUrl(listenUrl)
|
|
768
869
|
});
|
|
769
870
|
const poll = setInterval(() => {
|
|
770
871
|
if (!state.running)
|
|
771
872
|
return;
|
|
772
|
-
claimAndDrain(db, state, emitterId, "poll").catch((err) =>
|
|
873
|
+
claimAndDrain(db, state, emitterId, "poll").catch((err) => logger4.error("[emitter] poll claim failed", {
|
|
773
874
|
error: err instanceof Error ? err.message : String(err)
|
|
774
875
|
}));
|
|
775
876
|
}, pollIntervalMs);
|
|
@@ -777,14 +878,22 @@ async function startEmitter(opts) {
|
|
|
777
878
|
const retention = setInterval(() => {
|
|
778
879
|
if (!state.running)
|
|
779
880
|
return;
|
|
780
|
-
runRetention(db).catch((err) =>
|
|
881
|
+
runRetention(db).catch((err) => logger4.error("[emitter] retention failed", {
|
|
781
882
|
error: err instanceof Error ? err.message : String(err)
|
|
782
883
|
}));
|
|
783
884
|
}, retentionIntervalMs);
|
|
885
|
+
const receiverCerts = setInterval(() => {
|
|
886
|
+
if (!state.running)
|
|
887
|
+
return;
|
|
888
|
+
refreshReceiverCerts(db).catch((err) => logger4.error("[emitter] receiver cert refresh failed", {
|
|
889
|
+
error: err instanceof Error ? err.message : String(err)
|
|
890
|
+
}));
|
|
891
|
+
}, RECEIVER_CERT_INTERVAL_MS);
|
|
784
892
|
return async () => {
|
|
785
893
|
state.running = false;
|
|
786
894
|
clearInterval(poll);
|
|
787
895
|
clearInterval(retention);
|
|
896
|
+
clearInterval(receiverCerts);
|
|
788
897
|
await stopNew();
|
|
789
898
|
await stopChanged();
|
|
790
899
|
const pendingRuns = new Set(state.inFlightRuns);
|
|
@@ -804,7 +913,7 @@ async function startEmitter(opts) {
|
|
|
804
913
|
clearTimeout(timer);
|
|
805
914
|
const abandoned = timedOut ? state.inFlightRuns.size : 0;
|
|
806
915
|
const drained = drainTarget - abandoned;
|
|
807
|
-
|
|
916
|
+
logger4.info("[emitter] stopped", {
|
|
808
917
|
id: emitterId,
|
|
809
918
|
event: "emitter_stopped",
|
|
810
919
|
drained,
|
|
@@ -818,10 +927,11 @@ export {
|
|
|
818
927
|
MAX_WEBHOOK_TIMEOUT_MS,
|
|
819
928
|
__postToWebhookForTest,
|
|
820
929
|
__setDnsLookupForTest,
|
|
930
|
+
allowPrivateEgress,
|
|
821
931
|
checkEgressAllowed,
|
|
822
932
|
deliverTestEvent,
|
|
823
933
|
startEmitter
|
|
824
934
|
};
|
|
825
935
|
|
|
826
|
-
//# debugId=
|
|
936
|
+
//# debugId=C2E9CBDDCAC108F464756E2164756E21
|
|
827
937
|
//# sourceMappingURL=emitter.js.map
|