@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/index.js CHANGED
@@ -7,8 +7,8 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
7
7
  import postgres from "postgres";
8
8
  import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
9
9
  import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
10
- import { createHash, randomUUID } from "node:crypto";
11
- import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags } from "@electric-ax/agents-runtime";
10
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
11
+ import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
12
12
  import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
13
13
  import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
14
14
  import pino from "pino";
@@ -571,7 +571,7 @@ var PostgresRegistry = class {
571
571
  const heartbeatAt = input.heartbeatAt ?? new Date();
572
572
  await this.db.update(consumerClaims).set({
573
573
  lastHeartbeatAt: heartbeatAt,
574
- leaseExpiresAt: input.leaseExpiresAt ?? null,
574
+ ...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
575
575
  updatedAt: heartbeatAt
576
576
  }).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch)));
577
577
  }
@@ -584,17 +584,24 @@ var PostgresRegistry = class {
584
584
  updatedAt: releasedAt
585
585
  }).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch))).returning();
586
586
  const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null;
587
- if (claim) await this.db.update(entityDispatchState).set({
588
- activeConsumerId: null,
589
- activeRunnerId: null,
590
- activeEpoch: null,
591
- activeClaimedAt: null,
592
- activeLeaseExpiresAt: null,
593
- lastReleasedAt: releasedAt,
594
- lastCompletedAt: releasedAt,
595
- updatedAt: releasedAt
596
- }).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch)));
597
- return claim;
587
+ let entityCleared = false;
588
+ if (claim) {
589
+ const cleared = await this.db.update(entityDispatchState).set({
590
+ activeConsumerId: null,
591
+ activeRunnerId: null,
592
+ activeEpoch: null,
593
+ activeClaimedAt: null,
594
+ activeLeaseExpiresAt: null,
595
+ lastReleasedAt: releasedAt,
596
+ lastCompletedAt: releasedAt,
597
+ updatedAt: releasedAt
598
+ }).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 });
599
+ entityCleared = cleared.length > 0;
600
+ }
601
+ return {
602
+ claim,
603
+ entityCleared
604
+ };
598
605
  }
599
606
  async getActiveClaimsForRunner(runnerId) {
600
607
  const rows = await this.db.select().from(consumerClaims).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.runnerId, runnerId), eq(consumerClaims.status, `active`)));
@@ -6115,6 +6122,87 @@ function sqlStringLiteral(value) {
6115
6122
  return `'${value.replace(/'/g, `''`)}'`;
6116
6123
  }
6117
6124
 
6125
+ //#endregion
6126
+ //#region src/webhook-signing.ts
6127
+ const encoder = new TextEncoder();
6128
+ const defaultWebhookSigner = createEd25519WebhookSigner();
6129
+ function createEd25519WebhookSigner(options = {}) {
6130
+ const privateKey = options.privateKey ? importPrivateKey(options.privateKey) : generateKeyPairSync(`ed25519`).privateKey;
6131
+ if (privateKey.asymmetricKeyType !== `ed25519`) throw new Error(`Webhook signing key must be an Ed25519 private key`);
6132
+ const publicJwk = buildPublicJwk(privateKey, options.kid);
6133
+ return {
6134
+ sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
6135
+ jwks: () => ({ keys: [{ ...publicJwk }] })
6136
+ };
6137
+ }
6138
+ function getDefaultWebhookSigner() {
6139
+ return defaultWebhookSigner;
6140
+ }
6141
+ async function webhookSigningMetadata(signer, streamRootUrl) {
6142
+ const jwks = await signer.jwks();
6143
+ const key = jwks.keys[0];
6144
+ if (!key) throw new Error(`Webhook signer did not provide any public keys`);
6145
+ return {
6146
+ alg: `ed25519`,
6147
+ kid: key.kid,
6148
+ jwks_url: appendPathToUrl(streamRootUrl, `/__ds/jwks.json`)
6149
+ };
6150
+ }
6151
+ function signWebhookBody(privateKey, kid, body) {
6152
+ const timestamp$1 = Math.floor(Date.now() / 1e3);
6153
+ const payload = bytesWithTimestamp(timestamp$1, body);
6154
+ const signature = sign(null, payload, privateKey).toString(`base64url`);
6155
+ return `t=${timestamp$1},kid=${kid},ed25519=${signature}`;
6156
+ }
6157
+ function bytesWithTimestamp(timestamp$1, body) {
6158
+ const prefix = encoder.encode(`${timestamp$1}.`);
6159
+ const bodyBytes = typeof body === `string` ? encoder.encode(body) : body;
6160
+ return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)]);
6161
+ }
6162
+ function importPrivateKey(input) {
6163
+ if (isKeyObject(input)) return input;
6164
+ if (typeof input === `string`) {
6165
+ const trimmed = input.trim();
6166
+ if (trimmed.startsWith(`{`)) return createPrivateKey({
6167
+ key: JSON.parse(trimmed),
6168
+ format: `jwk`
6169
+ });
6170
+ return createPrivateKey(trimmed.replace(/\\n/g, `\n`));
6171
+ }
6172
+ if (Buffer.isBuffer(input)) return createPrivateKey(input);
6173
+ return createPrivateKey({
6174
+ key: input,
6175
+ format: `jwk`
6176
+ });
6177
+ }
6178
+ function isKeyObject(input) {
6179
+ return typeof input === `object` && `type` in input && input.type === `private`;
6180
+ }
6181
+ function buildPublicJwk(privateKey, kid) {
6182
+ const exported = createPublicKey(privateKey).export({ format: `jwk` });
6183
+ if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) throw new Error(`Failed to export Ed25519 webhook signing key`);
6184
+ return {
6185
+ kty: `OKP`,
6186
+ crv: `Ed25519`,
6187
+ x: exported.x,
6188
+ kid: kid ?? deriveKeyId({
6189
+ kty: exported.kty,
6190
+ crv: exported.crv,
6191
+ x: exported.x
6192
+ }),
6193
+ use: `sig`,
6194
+ alg: `EdDSA`
6195
+ };
6196
+ }
6197
+ function deriveKeyId(jwk) {
6198
+ const thumbprintInput = JSON.stringify({
6199
+ crv: jwk.crv,
6200
+ kty: jwk.kty,
6201
+ x: jwk.x
6202
+ });
6203
+ return `ds_${createHash(`sha256`).update(thumbprintInput).digest(`base64url`)}`;
6204
+ }
6205
+
6118
6206
  //#endregion
6119
6207
  //#region src/routing/durable-streams-router.ts
6120
6208
  const subscriptionProxyBodySchema = Type.Object({ webhook: Type.Optional(Type.Object({ url: Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
@@ -6131,6 +6219,7 @@ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscri
6131
6219
  durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
6132
6220
  durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
6133
6221
  for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
6222
+ durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks);
6134
6223
  durableStreamsRouter.all(`/__ds`, controlPassThrough);
6135
6224
  durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
6136
6225
  durableStreamsRouter.post(`*`, streamAppend);
@@ -6139,12 +6228,16 @@ function bodyFromBytes$1(body) {
6139
6228
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
6140
6229
  }
6141
6230
  function responseFromUpstream$1(response, body) {
6142
- return new Response(body ? bodyFromBytes$1(body) : response.body, {
6231
+ const responseBody = forbidsResponseBody$1(response.status) ? null : body !== void 0 ? bodyFromBytes$1(body) : response.body;
6232
+ return new Response(responseBody, {
6143
6233
  status: response.status,
6144
6234
  statusText: response.statusText,
6145
6235
  headers: responseHeaders(response)
6146
6236
  });
6147
6237
  }
6238
+ function forbidsResponseBody$1(status$1) {
6239
+ return status$1 === 204 || status$1 === 205 || status$1 === 304;
6240
+ }
6148
6241
  async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
6149
6242
  const headers = new Headers(request.headers);
6150
6243
  headers.delete(`host`);
@@ -6178,28 +6271,32 @@ function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
6178
6271
  return next;
6179
6272
  });
6180
6273
  }
6181
- function rewriteSubscriptionResponseForClient(bytes, response, service, routingAdapter) {
6274
+ async function rewriteSubscriptionResponseForClient(bytes, response, ctx, routingAdapter) {
6182
6275
  if (!response.headers.get(`content-type`)?.includes(`application/json`)) return bytes;
6183
6276
  const payload = decodeJson(bytes);
6184
6277
  if (!payload) return bytes;
6185
- if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(service, payload.pattern);
6278
+ if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(ctx.service, payload.pattern);
6186
6279
  if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => {
6187
- if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(service, stream);
6280
+ if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(ctx.service, stream);
6188
6281
  if (stream && typeof stream === `object` && typeof stream.path === `string`) return {
6189
6282
  ...stream,
6190
- path: routingAdapter.toRuntimeStreamPath(service, stream.path)
6283
+ path: routingAdapter.toRuntimeStreamPath(ctx.service, stream.path)
6191
6284
  };
6192
6285
  return stream;
6193
6286
  });
6194
- if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(service, payload.wake_stream);
6195
- if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream);
6287
+ if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.wake_stream);
6288
+ if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.stream);
6196
6289
  if (Array.isArray(payload.acks)) payload.acks = payload.acks.map((ack) => {
6197
6290
  if (!ack || typeof ack !== `object`) return ack;
6198
6291
  const next = { ...ack };
6199
- if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream);
6200
- if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(service, next.path);
6292
+ if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(ctx.service, next.stream);
6293
+ if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path);
6201
6294
  return next;
6202
6295
  });
6296
+ if (payload.webhook && typeof payload.webhook === `object` && !Array.isArray(payload.webhook)) {
6297
+ const webhook = payload.webhook;
6298
+ webhook.signing = await webhookSigningMetadata(resolveWebhookSigner$1(ctx), ctx.publicUrl);
6299
+ }
6203
6300
  return new TextEncoder().encode(JSON.stringify(payload));
6204
6301
  }
6205
6302
  function decodeJson(bytes) {
@@ -6218,6 +6315,9 @@ function routeParam$2(request, name) {
6218
6315
  function subscriptionRoutingAdapter(ctx) {
6219
6316
  return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
6220
6317
  }
6318
+ function resolveWebhookSigner$1(ctx) {
6319
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
6320
+ }
6221
6321
  async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
6222
6322
  const body = await readRequestBody(request);
6223
6323
  if (body.length === 0) return {
@@ -6246,7 +6346,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
6246
6346
  async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
6247
6347
  const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
6248
6348
  let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
6249
- responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx.service, routingAdapter);
6349
+ responseBytes = await rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx, routingAdapter);
6250
6350
  return {
6251
6351
  upstream,
6252
6352
  response: responseFromUpstream$1(upstream, responseBytes)
@@ -6319,6 +6419,15 @@ async function controlPassThrough(request, ctx) {
6319
6419
  const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
6320
6420
  return responseFromUpstream$1(upstream);
6321
6421
  }
6422
+ async function webhookJwks(_request, ctx) {
6423
+ return new Response(JSON.stringify(await resolveWebhookSigner$1(ctx).jwks()), {
6424
+ status: 200,
6425
+ headers: {
6426
+ "content-type": `application/jwk-set+json`,
6427
+ "cache-control": `public, max-age=300`
6428
+ }
6429
+ });
6430
+ }
6322
6431
  async function streamAppend(request, ctx) {
6323
6432
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
6324
6433
  request: {
@@ -7309,12 +7418,16 @@ function bodyFromBytes(body) {
7309
7418
  return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
7310
7419
  }
7311
7420
  function responseFromUpstream(response, body) {
7312
- return new Response(body ? bodyFromBytes(body) : response.body, {
7421
+ const responseBody = forbidsResponseBody(response.status) ? null : body !== void 0 ? bodyFromBytes(body) : response.body;
7422
+ return new Response(responseBody, {
7313
7423
  status: response.status,
7314
7424
  statusText: response.statusText,
7315
7425
  headers: responseHeaders(response)
7316
7426
  });
7317
7427
  }
7428
+ function forbidsResponseBody(status$1) {
7429
+ return status$1 === 204 || status$1 === 205 || status$1 === 304;
7430
+ }
7318
7431
  function forwardHeadersFromRequest(request) {
7319
7432
  const headers = new Headers(request.headers);
7320
7433
  headers.delete(`host`);
@@ -7323,6 +7436,45 @@ function forwardHeadersFromRequest(request) {
7323
7436
  function durableStreamsSubscriptionCallback(value) {
7324
7437
  return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX) ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length) : null;
7325
7438
  }
7439
+ function resolveWebhookSigner(ctx) {
7440
+ return ctx.webhookSigner ?? getDefaultWebhookSigner();
7441
+ }
7442
+ function durableStreamsWebhookJwksUrl(ctx) {
7443
+ if (!ctx.durableStreamsRouting) return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
7444
+ return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
7445
+ durableStreamsUrl: ctx.durableStreamsUrl,
7446
+ serviceId: ctx.service,
7447
+ requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`)
7448
+ }).toString();
7449
+ }
7450
+ function durableStreamsJwksFetchClient(ctx) {
7451
+ return async (input, init) => {
7452
+ const headers = new Headers(init?.headers);
7453
+ await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, { overwrite: false });
7454
+ const nextInit = {
7455
+ ...init ?? {},
7456
+ headers
7457
+ };
7458
+ if (ctx.durableStreamsDispatcher) nextInit.dispatcher = ctx.durableStreamsDispatcher;
7459
+ return await fetch(input, nextInit);
7460
+ };
7461
+ }
7462
+ function resolveDurableStreamsWebhookSignature(ctx) {
7463
+ if (ctx.durableStreamsWebhookSignature === false) return false;
7464
+ return {
7465
+ jwksUrl: ctx.durableStreamsWebhookSignature?.jwksUrl ?? durableStreamsWebhookJwksUrl(ctx),
7466
+ toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
7467
+ cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
7468
+ fetchClient: ctx.durableStreamsWebhookSignature?.fetchClient ?? durableStreamsJwksFetchClient(ctx)
7469
+ };
7470
+ }
7471
+ async function verifyDurableStreamsWebhook(request, ctx, body) {
7472
+ const config = resolveDurableStreamsWebhookSignature(ctx);
7473
+ if (config === false) return null;
7474
+ const verification = await verifyWebhookSignature(body, request.headers.get(`webhook-signature`), config);
7475
+ if (verification.ok) return null;
7476
+ return apiError(verification.status, verification.status === 401 ? ErrCodeUnauthorized : `WEBHOOK_SIGNATURE_UNAVAILABLE`, verification.error);
7477
+ }
7326
7478
  function claimTokenFromRequest(request) {
7327
7479
  const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
7328
7480
  if (electricClaimToken) return electricClaimToken;
@@ -7356,7 +7508,10 @@ async function webhookForward(request, ctx) {
7356
7508
  const rootSpan = getRequestSpan(request);
7357
7509
  rootSpan?.updateName(`webhook-forward`);
7358
7510
  rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
7359
- const lookupPromise = tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7511
+ const body = await readRequestBody(request);
7512
+ const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
7513
+ if (signatureError) return signatureError;
7514
+ const targetWebhookUrl = await tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
7360
7515
  try {
7361
7516
  const rows = await ctx.pgDb.select().from(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId))).limit(1);
7362
7517
  return rows[0]?.webhookUrl ?? null;
@@ -7364,7 +7519,6 @@ async function webhookForward(request, ctx) {
7364
7519
  span.end();
7365
7520
  }
7366
7521
  });
7367
- const [targetWebhookUrl, body] = await Promise.all([lookupPromise, readRequestBody(request)]);
7368
7522
  if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
7369
7523
  const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
7370
7524
  if (!parsedBodyResult.ok) return parsedBodyResult.response;
@@ -7453,6 +7607,7 @@ async function webhookForward(request, ctx) {
7453
7607
  const headers = forwardHeadersFromRequest(request);
7454
7608
  headers.set(`content-type`, `application/json`);
7455
7609
  headers.delete(`content-length`);
7610
+ headers.set(`webhook-signature`, await resolveWebhookSigner(ctx).sign(forwardBody));
7456
7611
  let upstream;
7457
7612
  try {
7458
7613
  upstream = await tracer.startActiveSpan(`fetch.agent-handler`, async (span) => {
@@ -7540,8 +7695,9 @@ async function callbackForward(request, ctx) {
7540
7695
  serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
7541
7696
  const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
7542
7697
  const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
7543
- if (entity && stillOwnsClaim) {
7544
- if (epoch !== void 0) await ctx.entityManager.registry.materializeReleasedClaim?.({
7698
+ let entityCleared = false;
7699
+ if (epoch !== void 0) {
7700
+ const result = await ctx.entityManager.registry.materializeReleasedClaim?.({
7545
7701
  consumerId,
7546
7702
  epoch,
7547
7703
  ackedStreams: Array.isArray(requestBody?.acks) ? requestBody.acks.flatMap((ack) => {
@@ -7553,13 +7709,15 @@ async function callbackForward(request, ctx) {
7553
7709
  }] : [];
7554
7710
  }) : void 0
7555
7711
  });
7712
+ entityCleared = result?.entityCleared ?? false;
7713
+ }
7714
+ if (entity && (entityCleared || stillOwnsClaim)) {
7556
7715
  await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
7557
- ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7558
7716
  await ctx.entityBridgeManager.onEntityChanged(entity.url);
7559
7717
  serverLog.info(`[callback-forward] status updated to idle for ${entity.url}`);
7560
- } else if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7561
- else if (entity) serverLog.info(`[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`);
7562
- else serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7718
+ } else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
7719
+ if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
7720
+ else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
7563
7721
  } else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
7564
7722
  } catch (err) {
7565
7723
  serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -7612,4 +7770,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
7612
7770
  globalRouter.all(`*`, durableStreamsRouter.fetch);
7613
7771
 
7614
7772
  //#endregion
7615
- export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter };
7773
+ export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@anthropic-ai/sdk": "^0.78.0",
39
39
  "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
- "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@5d5c217",
40
+ "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@eac712f",
41
41
  "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
42
  "@electric-sql/client": "^1.5.18",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.0"
57
+ "@electric-ax/agents-runtime": "0.3.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents-server-ui": "0.4.5",
68
+ "@electric-ax/agents": "0.4.5",
69
69
  "@electric-ax/agents-server-conformance-tests": "0.1.6",
70
- "@electric-ax/agents": "0.4.4"
70
+ "@electric-ax/agents-server-ui": "0.4.6"
71
71
  },
72
72
  "files": [
73
73
  "dist",
@@ -360,11 +360,17 @@ export class PostgresRegistry {
360
360
  input: MaterializeHeartbeatClaimInput
361
361
  ): Promise<void> {
362
362
  const heartbeatAt = input.heartbeatAt ?? new Date()
363
+ // Only touch leaseExpiresAt when the caller explicitly provides one.
364
+ // The lease was set at materializeActiveClaim time from the upstream
365
+ // lease_ttl_ms and remains the authoritative expiry; heartbeats are
366
+ // alive-pings, not lease extensions.
363
367
  await this.db
364
368
  .update(consumerClaims)
365
369
  .set({
366
370
  lastHeartbeatAt: heartbeatAt,
367
- leaseExpiresAt: input.leaseExpiresAt ?? null,
371
+ ...(input.leaseExpiresAt !== undefined
372
+ ? { leaseExpiresAt: input.leaseExpiresAt }
373
+ : {}),
368
374
  updatedAt: heartbeatAt,
369
375
  })
370
376
  .where(
@@ -378,7 +384,7 @@ export class PostgresRegistry {
378
384
 
379
385
  async materializeReleasedClaim(
380
386
  input: MaterializeReleasedClaimInput
381
- ): Promise<ConsumerClaim | null> {
387
+ ): Promise<{ claim: ConsumerClaim | null; entityCleared: boolean }> {
382
388
  const releasedAt = input.releasedAt ?? new Date()
383
389
  const rows = await this.db
384
390
  .update(consumerClaims)
@@ -398,8 +404,13 @@ export class PostgresRegistry {
398
404
  .returning()
399
405
 
400
406
  const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null
407
+ let entityCleared = false
401
408
  if (claim) {
402
- await this.db
409
+ // entityCleared distinguishes "we were the active dispatch and now it's
410
+ // empty" from "a newer claim was already active for this entity." The
411
+ // WHERE clause matches our (consumerId, epoch) so an evicted-by-newer
412
+ // case correctly returns zero rows.
413
+ const cleared = await this.db
403
414
  .update(entityDispatchState)
404
415
  .set({
405
416
  activeConsumerId: null,
@@ -419,8 +430,10 @@ export class PostgresRegistry {
419
430
  eq(entityDispatchState.activeEpoch, input.epoch)
420
431
  )
421
432
  )
433
+ .returning({ entityUrl: entityDispatchState.entityUrl })
434
+ entityCleared = cleared.length > 0
422
435
  }
423
- return claim
436
+ return { claim, entityCleared }
424
437
  }
425
438
 
426
439
  async getActiveClaimsForRunner(
@@ -139,6 +139,10 @@ export function resolveElectricAgentsEntrypointOptions(
139
139
  `ELECTRIC_URL`,
140
140
  ])
141
141
  const electricSecret = readEnv(env, [`ELECTRIC_AGENTS_ELECTRIC_SECRET`])
142
+ const webhookSigningKey = readEnv(env, [
143
+ `ELECTRIC_AGENTS_WEBHOOK_SIGNING_PRIVATE_KEY`,
144
+ `WEBHOOK_SIGNING_PRIVATE_KEY`,
145
+ ])
142
146
  const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BASE_URL`, `BASE_URL`])
143
147
  return {
144
148
  service: readEnv(env, [`ELECTRIC_AGENTS_SERVICE`, `SERVICE`]),
@@ -153,6 +157,7 @@ export function resolveElectricAgentsEntrypointOptions(
153
157
  ? validateUrl(`Electric URL`, electricUrl)
154
158
  : undefined,
155
159
  electricSecret,
160
+ webhookSigningKey,
156
161
  host: readEnv(env, [`ELECTRIC_AGENTS_HOST`, `HOST`]) ?? DEFAULT_HOST,
157
162
  port: readPort(env),
158
163
  workingDirectory:
package/src/index.ts CHANGED
@@ -46,6 +46,19 @@ export type {
46
46
  DurableStreamsRoutingAdapter,
47
47
  DurableStreamsRoutingInput,
48
48
  } from './routing/durable-streams-routing-adapter.js'
49
+ export {
50
+ createEd25519WebhookSigner,
51
+ getDefaultWebhookSigner,
52
+ webhookSigningMetadata,
53
+ } from './webhook-signing.js'
54
+ export type {
55
+ Ed25519WebhookSignerOptions,
56
+ WebhookJwks,
57
+ WebhookPublicJwk,
58
+ WebhookSigner,
59
+ WebhookSigningKeyInput,
60
+ WebhookSigningMetadata,
61
+ } from './webhook-signing.js'
49
62
  export type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
50
63
  export {
51
64
  DEFAULT_TENANT_ID,
@@ -1,4 +1,5 @@
1
1
  import type { Agent } from 'undici'
2
+ import type { WebhookSignatureVerifierConfig } from '@electric-ax/agents-runtime'
2
3
  import type { DrizzleDB } from '../db/index.js'
3
4
  import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
4
5
  import type { EntityManager } from '../entity-manager.js'
@@ -7,6 +8,7 @@ import type { StreamClient } from '../stream-client.js'
7
8
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
8
9
  import type { Principal } from '../principal.js'
9
10
  import type { DurableStreamsBearerProvider } from '../stream-client.js'
11
+ import type { WebhookSigner } from '../webhook-signing.js'
10
12
 
11
13
  /**
12
14
  * Per-request tenant context passed through every router and handler.
@@ -24,6 +26,10 @@ export interface TenantContext {
24
26
  durableStreamsBearer?: DurableStreamsBearerProvider
25
27
  durableStreamsRouting?: DurableStreamsRoutingAdapter
26
28
  durableStreamsDispatcher: Agent
29
+ durableStreamsWebhookSignature?:
30
+ | false
31
+ | Partial<WebhookSignatureVerifierConfig>
32
+ webhookSigner?: WebhookSigner
27
33
  electricUrl?: string
28
34
  electricSecret?: string
29
35
  ownAgentHandlerPaths?: ReadonlyArray<string>