@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.
- 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 +8 -8
- 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/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
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
7544
|
-
|
|
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 (
|
|
7561
|
-
|
|
7562
|
-
else serverLog.
|
|
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.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"sideEffects": false,
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
39
|
-
"@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@
|
|
40
|
-
"@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@
|
|
41
|
-
"@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@
|
|
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@eac712f",
|
|
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",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -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.
|
|
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": "0.4.
|
|
69
|
-
"@electric-ax/agents-server-conformance-tests": "0.1.
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.4.
|
|
68
|
+
"@electric-ax/agents": "0.4.5",
|
|
69
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.6",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.6"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/entity-registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
package/src/entrypoint-lib.ts
CHANGED
|
@@ -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,
|
package/src/routing/context.ts
CHANGED
|
@@ -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>
|