@electric-ax/agents-server 0.4.5 → 0.4.6
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/entrypoint.js +197 -33
- package/dist/index.cjs +193 -32
- package/dist/index.d.cts +46 -8
- package/dist/index.d.ts +46 -8
- package/dist/index.js +192 -34
- package/package.json +5 -5
- package/src/entity-registry.ts +17 -4
- package/src/entrypoint-lib.ts +5 -0
- package/src/index.ts +13 -0
- package/src/routing/context.ts +6 -0
- package/src/routing/durable-streams-router.ts +62 -13
- package/src/routing/internal-router.ts +142 -21
- package/src/server.ts +18 -0
- package/src/stream-client.ts +10 -4
- package/src/webhook-signing.ts +173 -0
package/dist/entrypoint.js
CHANGED
|
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createServerAdapter } from "@whatwg-node/server";
|
|
6
6
|
import { Agent } from "undici";
|
|
7
|
-
import { appendPathToUrl, assertTags, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags } from "@electric-ax/agents-runtime";
|
|
7
|
+
import { appendPathToUrl, assertTags, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
8
8
|
import fs, { existsSync } from "node:fs";
|
|
9
9
|
import path, { dirname, resolve } from "node:path";
|
|
10
10
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
@@ -19,7 +19,7 @@ import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentele
|
|
|
19
19
|
import { Type } from "@sinclair/typebox";
|
|
20
20
|
import pino from "pino";
|
|
21
21
|
import Ajv from "ajv";
|
|
22
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
22
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
23
23
|
import fastq from "fastq";
|
|
24
24
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
25
25
|
|
|
@@ -1378,6 +1378,87 @@ function isLoopbackHostname(hostname) {
|
|
|
1378
1378
|
return hostname === `localhost` || hostname === `127.0.0.1` || hostname === `::1`;
|
|
1379
1379
|
}
|
|
1380
1380
|
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/webhook-signing.ts
|
|
1383
|
+
const encoder = new TextEncoder();
|
|
1384
|
+
const defaultWebhookSigner = createEd25519WebhookSigner();
|
|
1385
|
+
function createEd25519WebhookSigner(options = {}) {
|
|
1386
|
+
const privateKey = options.privateKey ? importPrivateKey(options.privateKey) : generateKeyPairSync(`ed25519`).privateKey;
|
|
1387
|
+
if (privateKey.asymmetricKeyType !== `ed25519`) throw new Error(`Webhook signing key must be an Ed25519 private key`);
|
|
1388
|
+
const publicJwk = buildPublicJwk(privateKey, options.kid);
|
|
1389
|
+
return {
|
|
1390
|
+
sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
|
|
1391
|
+
jwks: () => ({ keys: [{ ...publicJwk }] })
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
function getDefaultWebhookSigner() {
|
|
1395
|
+
return defaultWebhookSigner;
|
|
1396
|
+
}
|
|
1397
|
+
async function webhookSigningMetadata(signer, streamRootUrl) {
|
|
1398
|
+
const jwks = await signer.jwks();
|
|
1399
|
+
const key = jwks.keys[0];
|
|
1400
|
+
if (!key) throw new Error(`Webhook signer did not provide any public keys`);
|
|
1401
|
+
return {
|
|
1402
|
+
alg: `ed25519`,
|
|
1403
|
+
kid: key.kid,
|
|
1404
|
+
jwks_url: appendPathToUrl(streamRootUrl, `/__ds/jwks.json`)
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
function signWebhookBody(privateKey, kid, body) {
|
|
1408
|
+
const timestamp$1 = Math.floor(Date.now() / 1e3);
|
|
1409
|
+
const payload = bytesWithTimestamp(timestamp$1, body);
|
|
1410
|
+
const signature = sign(null, payload, privateKey).toString(`base64url`);
|
|
1411
|
+
return `t=${timestamp$1},kid=${kid},ed25519=${signature}`;
|
|
1412
|
+
}
|
|
1413
|
+
function bytesWithTimestamp(timestamp$1, body) {
|
|
1414
|
+
const prefix = encoder.encode(`${timestamp$1}.`);
|
|
1415
|
+
const bodyBytes = typeof body === `string` ? encoder.encode(body) : body;
|
|
1416
|
+
return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)]);
|
|
1417
|
+
}
|
|
1418
|
+
function importPrivateKey(input) {
|
|
1419
|
+
if (isKeyObject(input)) return input;
|
|
1420
|
+
if (typeof input === `string`) {
|
|
1421
|
+
const trimmed = input.trim();
|
|
1422
|
+
if (trimmed.startsWith(`{`)) return createPrivateKey({
|
|
1423
|
+
key: JSON.parse(trimmed),
|
|
1424
|
+
format: `jwk`
|
|
1425
|
+
});
|
|
1426
|
+
return createPrivateKey(trimmed.replace(/\\n/g, `\n`));
|
|
1427
|
+
}
|
|
1428
|
+
if (Buffer.isBuffer(input)) return createPrivateKey(input);
|
|
1429
|
+
return createPrivateKey({
|
|
1430
|
+
key: input,
|
|
1431
|
+
format: `jwk`
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
function isKeyObject(input) {
|
|
1435
|
+
return typeof input === `object` && `type` in input && input.type === `private`;
|
|
1436
|
+
}
|
|
1437
|
+
function buildPublicJwk(privateKey, kid) {
|
|
1438
|
+
const exported = createPublicKey(privateKey).export({ format: `jwk` });
|
|
1439
|
+
if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) throw new Error(`Failed to export Ed25519 webhook signing key`);
|
|
1440
|
+
return {
|
|
1441
|
+
kty: `OKP`,
|
|
1442
|
+
crv: `Ed25519`,
|
|
1443
|
+
x: exported.x,
|
|
1444
|
+
kid: kid ?? deriveKeyId({
|
|
1445
|
+
kty: exported.kty,
|
|
1446
|
+
crv: exported.crv,
|
|
1447
|
+
x: exported.x
|
|
1448
|
+
}),
|
|
1449
|
+
use: `sig`,
|
|
1450
|
+
alg: `EdDSA`
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
function deriveKeyId(jwk) {
|
|
1454
|
+
const thumbprintInput = JSON.stringify({
|
|
1455
|
+
crv: jwk.crv,
|
|
1456
|
+
kty: jwk.kty,
|
|
1457
|
+
x: jwk.x
|
|
1458
|
+
});
|
|
1459
|
+
return `ds_${createHash(`sha256`).update(thumbprintInput).digest(`base64url`)}`;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1381
1462
|
//#endregion
|
|
1382
1463
|
//#region src/routing/durable-streams-router.ts
|
|
1383
1464
|
const subscriptionProxyBodySchema = Type.Object({ webhook: Type.Optional(Type.Object({ url: Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
|
|
@@ -1394,6 +1475,7 @@ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscri
|
|
|
1394
1475
|
durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
|
|
1395
1476
|
durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
|
|
1396
1477
|
for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
|
|
1478
|
+
durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks);
|
|
1397
1479
|
durableStreamsRouter.all(`/__ds`, controlPassThrough);
|
|
1398
1480
|
durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
|
|
1399
1481
|
durableStreamsRouter.post(`*`, streamAppend);
|
|
@@ -1402,12 +1484,16 @@ function bodyFromBytes$1(body) {
|
|
|
1402
1484
|
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
1403
1485
|
}
|
|
1404
1486
|
function responseFromUpstream$1(response, body) {
|
|
1405
|
-
|
|
1487
|
+
const responseBody = forbidsResponseBody$1(response.status) ? null : body !== void 0 ? bodyFromBytes$1(body) : response.body;
|
|
1488
|
+
return new Response(responseBody, {
|
|
1406
1489
|
status: response.status,
|
|
1407
1490
|
statusText: response.statusText,
|
|
1408
1491
|
headers: responseHeaders(response)
|
|
1409
1492
|
});
|
|
1410
1493
|
}
|
|
1494
|
+
function forbidsResponseBody$1(status$1) {
|
|
1495
|
+
return status$1 === 204 || status$1 === 205 || status$1 === 304;
|
|
1496
|
+
}
|
|
1411
1497
|
async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
|
|
1412
1498
|
const headers = new Headers(request.headers);
|
|
1413
1499
|
headers.delete(`host`);
|
|
@@ -1441,28 +1527,32 @@ function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
|
|
|
1441
1527
|
return next;
|
|
1442
1528
|
});
|
|
1443
1529
|
}
|
|
1444
|
-
function rewriteSubscriptionResponseForClient(bytes, response,
|
|
1530
|
+
async function rewriteSubscriptionResponseForClient(bytes, response, ctx, routingAdapter) {
|
|
1445
1531
|
if (!response.headers.get(`content-type`)?.includes(`application/json`)) return bytes;
|
|
1446
1532
|
const payload = decodeJson(bytes);
|
|
1447
1533
|
if (!payload) return bytes;
|
|
1448
|
-
if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(service, payload.pattern);
|
|
1534
|
+
if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(ctx.service, payload.pattern);
|
|
1449
1535
|
if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => {
|
|
1450
|
-
if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(service, stream);
|
|
1536
|
+
if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(ctx.service, stream);
|
|
1451
1537
|
if (stream && typeof stream === `object` && typeof stream.path === `string`) return {
|
|
1452
1538
|
...stream,
|
|
1453
|
-
path: routingAdapter.toRuntimeStreamPath(service, stream.path)
|
|
1539
|
+
path: routingAdapter.toRuntimeStreamPath(ctx.service, stream.path)
|
|
1454
1540
|
};
|
|
1455
1541
|
return stream;
|
|
1456
1542
|
});
|
|
1457
|
-
if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(service, payload.wake_stream);
|
|
1458
|
-
if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream);
|
|
1543
|
+
if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.wake_stream);
|
|
1544
|
+
if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.stream);
|
|
1459
1545
|
if (Array.isArray(payload.acks)) payload.acks = payload.acks.map((ack) => {
|
|
1460
1546
|
if (!ack || typeof ack !== `object`) return ack;
|
|
1461
1547
|
const next = { ...ack };
|
|
1462
|
-
if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream);
|
|
1463
|
-
if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(service, next.path);
|
|
1548
|
+
if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(ctx.service, next.stream);
|
|
1549
|
+
if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path);
|
|
1464
1550
|
return next;
|
|
1465
1551
|
});
|
|
1552
|
+
if (payload.webhook && typeof payload.webhook === `object` && !Array.isArray(payload.webhook)) {
|
|
1553
|
+
const webhook = payload.webhook;
|
|
1554
|
+
webhook.signing = await webhookSigningMetadata(resolveWebhookSigner$1(ctx), ctx.publicUrl);
|
|
1555
|
+
}
|
|
1466
1556
|
return new TextEncoder().encode(JSON.stringify(payload));
|
|
1467
1557
|
}
|
|
1468
1558
|
function decodeJson(bytes) {
|
|
@@ -1481,6 +1571,9 @@ function routeParam$2(request, name) {
|
|
|
1481
1571
|
function subscriptionRoutingAdapter(ctx) {
|
|
1482
1572
|
return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
|
|
1483
1573
|
}
|
|
1574
|
+
function resolveWebhookSigner$1(ctx) {
|
|
1575
|
+
return ctx.webhookSigner ?? getDefaultWebhookSigner();
|
|
1576
|
+
}
|
|
1484
1577
|
async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
|
|
1485
1578
|
const body = await readRequestBody(request);
|
|
1486
1579
|
if (body.length === 0) return {
|
|
@@ -1509,7 +1602,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
|
|
|
1509
1602
|
async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
|
|
1510
1603
|
const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
|
|
1511
1604
|
let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
|
|
1512
|
-
responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx
|
|
1605
|
+
responseBytes = await rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx, routingAdapter);
|
|
1513
1606
|
return {
|
|
1514
1607
|
upstream,
|
|
1515
1608
|
response: responseFromUpstream$1(upstream, responseBytes)
|
|
@@ -1582,6 +1675,15 @@ async function controlPassThrough(request, ctx) {
|
|
|
1582
1675
|
const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
|
|
1583
1676
|
return responseFromUpstream$1(upstream);
|
|
1584
1677
|
}
|
|
1678
|
+
async function webhookJwks(_request, ctx) {
|
|
1679
|
+
return new Response(JSON.stringify(await resolveWebhookSigner$1(ctx).jwks()), {
|
|
1680
|
+
status: 200,
|
|
1681
|
+
headers: {
|
|
1682
|
+
"content-type": `application/jwk-set+json`,
|
|
1683
|
+
"cache-control": `public, max-age=300`
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1585
1687
|
async function streamAppend(request, ctx) {
|
|
1586
1688
|
return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
|
|
1587
1689
|
request: {
|
|
@@ -1966,7 +2068,7 @@ var PostgresRegistry = class {
|
|
|
1966
2068
|
const heartbeatAt = input.heartbeatAt ?? new Date();
|
|
1967
2069
|
await this.db.update(consumerClaims).set({
|
|
1968
2070
|
lastHeartbeatAt: heartbeatAt,
|
|
1969
|
-
leaseExpiresAt: input.leaseExpiresAt
|
|
2071
|
+
...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
|
|
1970
2072
|
updatedAt: heartbeatAt
|
|
1971
2073
|
}).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch)));
|
|
1972
2074
|
}
|
|
@@ -1979,17 +2081,24 @@ var PostgresRegistry = class {
|
|
|
1979
2081
|
updatedAt: releasedAt
|
|
1980
2082
|
}).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch))).returning();
|
|
1981
2083
|
const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null;
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2084
|
+
let entityCleared = false;
|
|
2085
|
+
if (claim) {
|
|
2086
|
+
const cleared = await this.db.update(entityDispatchState).set({
|
|
2087
|
+
activeConsumerId: null,
|
|
2088
|
+
activeRunnerId: null,
|
|
2089
|
+
activeEpoch: null,
|
|
2090
|
+
activeClaimedAt: null,
|
|
2091
|
+
activeLeaseExpiresAt: null,
|
|
2092
|
+
lastReleasedAt: releasedAt,
|
|
2093
|
+
lastCompletedAt: releasedAt,
|
|
2094
|
+
updatedAt: releasedAt
|
|
2095
|
+
}).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch))).returning({ entityUrl: entityDispatchState.entityUrl });
|
|
2096
|
+
entityCleared = cleared.length > 0;
|
|
2097
|
+
}
|
|
2098
|
+
return {
|
|
2099
|
+
claim,
|
|
2100
|
+
entityCleared
|
|
2101
|
+
};
|
|
1993
2102
|
}
|
|
1994
2103
|
async getActiveClaimsForRunner(runnerId) {
|
|
1995
2104
|
const rows = await this.db.select().from(consumerClaims).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.runnerId, runnerId), eq(consumerClaims.status, `active`)));
|
|
@@ -5117,12 +5226,16 @@ function bodyFromBytes(body) {
|
|
|
5117
5226
|
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
5118
5227
|
}
|
|
5119
5228
|
function responseFromUpstream(response, body) {
|
|
5120
|
-
|
|
5229
|
+
const responseBody = forbidsResponseBody(response.status) ? null : body !== void 0 ? bodyFromBytes(body) : response.body;
|
|
5230
|
+
return new Response(responseBody, {
|
|
5121
5231
|
status: response.status,
|
|
5122
5232
|
statusText: response.statusText,
|
|
5123
5233
|
headers: responseHeaders(response)
|
|
5124
5234
|
});
|
|
5125
5235
|
}
|
|
5236
|
+
function forbidsResponseBody(status$1) {
|
|
5237
|
+
return status$1 === 204 || status$1 === 205 || status$1 === 304;
|
|
5238
|
+
}
|
|
5126
5239
|
function forwardHeadersFromRequest(request) {
|
|
5127
5240
|
const headers = new Headers(request.headers);
|
|
5128
5241
|
headers.delete(`host`);
|
|
@@ -5131,6 +5244,45 @@ function forwardHeadersFromRequest(request) {
|
|
|
5131
5244
|
function durableStreamsSubscriptionCallback(value) {
|
|
5132
5245
|
return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX) ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length) : null;
|
|
5133
5246
|
}
|
|
5247
|
+
function resolveWebhookSigner(ctx) {
|
|
5248
|
+
return ctx.webhookSigner ?? getDefaultWebhookSigner();
|
|
5249
|
+
}
|
|
5250
|
+
function durableStreamsWebhookJwksUrl(ctx) {
|
|
5251
|
+
if (!ctx.durableStreamsRouting) return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
|
|
5252
|
+
return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
|
|
5253
|
+
durableStreamsUrl: ctx.durableStreamsUrl,
|
|
5254
|
+
serviceId: ctx.service,
|
|
5255
|
+
requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`)
|
|
5256
|
+
}).toString();
|
|
5257
|
+
}
|
|
5258
|
+
function durableStreamsJwksFetchClient(ctx) {
|
|
5259
|
+
return async (input, init) => {
|
|
5260
|
+
const headers = new Headers(init?.headers);
|
|
5261
|
+
await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, { overwrite: false });
|
|
5262
|
+
const nextInit = {
|
|
5263
|
+
...init ?? {},
|
|
5264
|
+
headers
|
|
5265
|
+
};
|
|
5266
|
+
if (ctx.durableStreamsDispatcher) nextInit.dispatcher = ctx.durableStreamsDispatcher;
|
|
5267
|
+
return await fetch(input, nextInit);
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
function resolveDurableStreamsWebhookSignature(ctx) {
|
|
5271
|
+
if (ctx.durableStreamsWebhookSignature === false) return false;
|
|
5272
|
+
return {
|
|
5273
|
+
jwksUrl: ctx.durableStreamsWebhookSignature?.jwksUrl ?? durableStreamsWebhookJwksUrl(ctx),
|
|
5274
|
+
toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
|
|
5275
|
+
cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
|
|
5276
|
+
fetchClient: ctx.durableStreamsWebhookSignature?.fetchClient ?? durableStreamsJwksFetchClient(ctx)
|
|
5277
|
+
};
|
|
5278
|
+
}
|
|
5279
|
+
async function verifyDurableStreamsWebhook(request, ctx, body) {
|
|
5280
|
+
const config = resolveDurableStreamsWebhookSignature(ctx);
|
|
5281
|
+
if (config === false) return null;
|
|
5282
|
+
const verification = await verifyWebhookSignature(body, request.headers.get(`webhook-signature`), config);
|
|
5283
|
+
if (verification.ok) return null;
|
|
5284
|
+
return apiError(verification.status, verification.status === 401 ? ErrCodeUnauthorized : `WEBHOOK_SIGNATURE_UNAVAILABLE`, verification.error);
|
|
5285
|
+
}
|
|
5134
5286
|
function claimTokenFromRequest(request) {
|
|
5135
5287
|
const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
|
|
5136
5288
|
if (electricClaimToken) return electricClaimToken;
|
|
@@ -5164,7 +5316,10 @@ async function webhookForward(request, ctx) {
|
|
|
5164
5316
|
const rootSpan = getRequestSpan(request);
|
|
5165
5317
|
rootSpan?.updateName(`webhook-forward`);
|
|
5166
5318
|
rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
|
|
5167
|
-
const
|
|
5319
|
+
const body = await readRequestBody(request);
|
|
5320
|
+
const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
|
|
5321
|
+
if (signatureError) return signatureError;
|
|
5322
|
+
const targetWebhookUrl = await tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
|
|
5168
5323
|
try {
|
|
5169
5324
|
const rows = await ctx.pgDb.select().from(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId))).limit(1);
|
|
5170
5325
|
return rows[0]?.webhookUrl ?? null;
|
|
@@ -5172,7 +5327,6 @@ async function webhookForward(request, ctx) {
|
|
|
5172
5327
|
span.end();
|
|
5173
5328
|
}
|
|
5174
5329
|
});
|
|
5175
|
-
const [targetWebhookUrl, body] = await Promise.all([lookupPromise, readRequestBody(request)]);
|
|
5176
5330
|
if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
|
|
5177
5331
|
const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
|
|
5178
5332
|
if (!parsedBodyResult.ok) return parsedBodyResult.response;
|
|
@@ -5261,6 +5415,7 @@ async function webhookForward(request, ctx) {
|
|
|
5261
5415
|
const headers = forwardHeadersFromRequest(request);
|
|
5262
5416
|
headers.set(`content-type`, `application/json`);
|
|
5263
5417
|
headers.delete(`content-length`);
|
|
5418
|
+
headers.set(`webhook-signature`, await resolveWebhookSigner(ctx).sign(forwardBody));
|
|
5264
5419
|
let upstream;
|
|
5265
5420
|
try {
|
|
5266
5421
|
upstream = await tracer.startActiveSpan(`fetch.agent-handler`, async (span) => {
|
|
@@ -5348,8 +5503,9 @@ async function callbackForward(request, ctx) {
|
|
|
5348
5503
|
serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
|
|
5349
5504
|
const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
|
|
5350
5505
|
const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
|
|
5351
|
-
|
|
5352
|
-
|
|
5506
|
+
let entityCleared = false;
|
|
5507
|
+
if (epoch !== void 0) {
|
|
5508
|
+
const result = await ctx.entityManager.registry.materializeReleasedClaim?.({
|
|
5353
5509
|
consumerId,
|
|
5354
5510
|
epoch,
|
|
5355
5511
|
ackedStreams: Array.isArray(requestBody?.acks) ? requestBody.acks.flatMap((ack) => {
|
|
@@ -5361,13 +5517,15 @@ async function callbackForward(request, ctx) {
|
|
|
5361
5517
|
}] : [];
|
|
5362
5518
|
}) : void 0
|
|
5363
5519
|
});
|
|
5520
|
+
entityCleared = result?.entityCleared ?? false;
|
|
5521
|
+
}
|
|
5522
|
+
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
5364
5523
|
await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
|
|
5365
|
-
ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
5366
5524
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
5367
5525
|
serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
|
|
5368
|
-
} else if (
|
|
5369
|
-
|
|
5370
|
-
else serverLog.
|
|
5526
|
+
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
5527
|
+
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
5528
|
+
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
5371
5529
|
} else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
|
|
5372
5530
|
} catch (err) {
|
|
5373
5531
|
serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -7560,11 +7718,13 @@ var ElectricAgentsServer = class {
|
|
|
7560
7718
|
shuttingDown = false;
|
|
7561
7719
|
streamsAgent;
|
|
7562
7720
|
standaloneRuntime;
|
|
7721
|
+
webhookSigner;
|
|
7563
7722
|
streamClient;
|
|
7564
7723
|
options;
|
|
7565
7724
|
constructor(options) {
|
|
7566
7725
|
if (!options.durableStreamsUrl && !options.durableStreamsServer) throw new Error(`Either durableStreamsUrl or durableStreamsServer is required`);
|
|
7567
7726
|
this.options = options;
|
|
7727
|
+
this.webhookSigner = options.webhookSigner ?? createEd25519WebhookSigner({ privateKey: options.webhookSigningKey });
|
|
7568
7728
|
this.streamClient = options.durableStreamsUrl ? new StreamClient(options.durableStreamsUrl, { bearer: options.durableStreamsBearer }) : null;
|
|
7569
7729
|
}
|
|
7570
7730
|
get url() {
|
|
@@ -7720,6 +7880,8 @@ var ElectricAgentsServer = class {
|
|
|
7720
7880
|
durableStreamsBearer: this.options.durableStreamsBearer,
|
|
7721
7881
|
durableStreamsRouting: this.options.durableStreamsRouting,
|
|
7722
7882
|
durableStreamsDispatcher: this.streamsAgent,
|
|
7883
|
+
durableStreamsWebhookSignature: this.options.durableStreamsWebhookSignature,
|
|
7884
|
+
webhookSigner: this.webhookSigner,
|
|
7723
7885
|
electricUrl: this.options.electricUrl,
|
|
7724
7886
|
electricSecret: this.options.electricSecret,
|
|
7725
7887
|
ownAgentHandlerPaths: this.mockAgentBootstrap ? [MOCK_AGENT_HANDLER_PATH] : void 0,
|
|
@@ -7790,6 +7952,7 @@ function resolveElectricAgentsEntrypointOptions(env = process.env, cwd = process
|
|
|
7790
7952
|
const postgresUrl = validateUrl(`Postgres URL`, readRequiredEnv(env, [`ELECTRIC_AGENTS_DATABASE_URL`, `DATABASE_URL`], `Postgres connection URL`));
|
|
7791
7953
|
const electricUrl = readEnv(env, [`ELECTRIC_AGENTS_ELECTRIC_URL`, `ELECTRIC_URL`]);
|
|
7792
7954
|
const electricSecret = readEnv(env, [`ELECTRIC_AGENTS_ELECTRIC_SECRET`]);
|
|
7955
|
+
const webhookSigningKey = readEnv(env, [`ELECTRIC_AGENTS_WEBHOOK_SIGNING_PRIVATE_KEY`, `WEBHOOK_SIGNING_PRIVATE_KEY`]);
|
|
7793
7956
|
const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BASE_URL`, `BASE_URL`]);
|
|
7794
7957
|
return {
|
|
7795
7958
|
service: readEnv(env, [`ELECTRIC_AGENTS_SERVICE`, `SERVICE`]),
|
|
@@ -7800,6 +7963,7 @@ function resolveElectricAgentsEntrypointOptions(env = process.env, cwd = process
|
|
|
7800
7963
|
postgresUrl,
|
|
7801
7964
|
electricUrl: electricUrl ? validateUrl(`Electric URL`, electricUrl) : void 0,
|
|
7802
7965
|
electricSecret,
|
|
7966
|
+
webhookSigningKey,
|
|
7803
7967
|
host: readEnv(env, [`ELECTRIC_AGENTS_HOST`, `HOST`]) ?? DEFAULT_HOST,
|
|
7804
7968
|
port: readPort(env),
|
|
7805
7969
|
workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd,
|