@electric-ax/agents-server 0.4.4 → 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.
@@ -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
- return new Response(body ? bodyFromBytes$1(body) : response.body, {
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, service, routingAdapter) {
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.service, routingAdapter);
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 ?? null,
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
- if (claim) await this.db.update(entityDispatchState).set({
1983
- activeConsumerId: null,
1984
- activeRunnerId: null,
1985
- activeEpoch: null,
1986
- activeClaimedAt: null,
1987
- activeLeaseExpiresAt: null,
1988
- lastReleasedAt: releasedAt,
1989
- lastCompletedAt: releasedAt,
1990
- updatedAt: releasedAt
1991
- }).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch)));
1992
- return claim;
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
- return new Response(body ? bodyFromBytes(body) : response.body, {
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 lookupPromise = tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
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
- if (entity && stillOwnsClaim) {
5352
- if (epoch !== void 0) await ctx.entityManager.registry.materializeReleasedClaim?.({
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 (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
5369
- else if (entity) serverLog.info(`[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`);
5370
- else serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
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,