@electric-ax/agents-server 0.4.5 → 0.4.7

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.
@@ -2,7 +2,10 @@
2
2
  * Sub-router for /_electric/* control-plane routes.
3
3
  */
4
4
 
5
- import { appendPathToUrl } from '@electric-ax/agents-runtime'
5
+ import {
6
+ appendPathToUrl,
7
+ verifyWebhookSignature,
8
+ } from '@electric-ax/agents-runtime'
6
9
  import { Type, type Static } from '@sinclair/typebox'
7
10
  import { and, eq } from 'drizzle-orm'
8
11
  import { Router, json, status } from 'itty-router'
@@ -16,10 +19,13 @@ import {
16
19
  ErrCodeCallbackNotFound,
17
20
  ErrCodeForkInProgress,
18
21
  ErrCodeSubscriptionNotFound,
22
+ ErrCodeUnauthorized,
19
23
  } from '../electric-agents-types.js'
20
24
  import { ATTR, tracer } from '../tracing.js'
21
25
  import { decodeJsonObject } from '../utils/server-utils.js'
22
26
  import { serverLog } from '../utils/log.js'
27
+ import { applyDurableStreamsBearer } from '../stream-client.js'
28
+ import { getDefaultWebhookSigner } from '../webhook-signing.js'
23
29
  import { cronRouter } from './cron-router.js'
24
30
  import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
25
31
  import { electricProxyRouter } from './electric-proxy-router.js'
@@ -30,8 +36,10 @@ import { runnersRouter } from './runners-router.js'
30
36
  import { routeBody, validateOptionalJsonBody, withSchema } from './schema.js'
31
37
  import { withLeadingSlash } from './tenant-stream-paths.js'
32
38
  import type { IRequest, RouterType } from 'itty-router'
39
+ import type { WebhookSignatureVerifierConfig } from '@electric-ax/agents-runtime'
33
40
  import type { TenantContext } from './context.js'
34
41
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
42
+ import type { WebhookSigner } from '../webhook-signing.js'
35
43
 
36
44
  const wakeRegistrationBodySchema = Type.Object({
37
45
  subscriberUrl: Type.String(),
@@ -137,13 +145,22 @@ function bodyFromBytes(body: Uint8Array): ArrayBuffer {
137
145
  }
138
146
 
139
147
  function responseFromUpstream(response: Response, body?: Uint8Array): Response {
140
- return new Response(body ? bodyFromBytes(body) : response.body, {
148
+ const responseBody = forbidsResponseBody(response.status)
149
+ ? null
150
+ : body !== undefined
151
+ ? bodyFromBytes(body)
152
+ : response.body
153
+ return new Response(responseBody, {
141
154
  status: response.status,
142
155
  statusText: response.statusText,
143
156
  headers: responseHeaders(response),
144
157
  })
145
158
  }
146
159
 
160
+ function forbidsResponseBody(status: number): boolean {
161
+ return status === 204 || status === 205 || status === 304
162
+ }
163
+
147
164
  function forwardHeadersFromRequest(request: IRequest): Headers {
148
165
  const headers = new Headers(request.headers)
149
166
  headers.delete(`host`)
@@ -156,6 +173,87 @@ function durableStreamsSubscriptionCallback(value: string): string | null {
156
173
  : null
157
174
  }
158
175
 
176
+ function resolveWebhookSigner(ctx: TenantContext): WebhookSigner {
177
+ return ctx.webhookSigner ?? getDefaultWebhookSigner()
178
+ }
179
+
180
+ function durableStreamsWebhookJwksUrl(ctx: TenantContext): string {
181
+ if (!ctx.durableStreamsRouting) {
182
+ return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`)
183
+ }
184
+
185
+ return resolveDurableStreamsRoutingAdapter(
186
+ ctx.durableStreamsRouting,
187
+ ctx.durableStreamsUrl
188
+ )
189
+ .controlUrl({
190
+ durableStreamsUrl: ctx.durableStreamsUrl,
191
+ serviceId: ctx.service,
192
+ requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`),
193
+ })
194
+ .toString()
195
+ }
196
+
197
+ function durableStreamsJwksFetchClient(ctx: TenantContext): typeof fetch {
198
+ return async (input, init) => {
199
+ const headers = new Headers(init?.headers)
200
+ await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, {
201
+ overwrite: false,
202
+ })
203
+ const nextInit: RequestInit & {
204
+ dispatcher?: TenantContext[`durableStreamsDispatcher`]
205
+ } = {
206
+ ...(init ?? {}),
207
+ headers,
208
+ }
209
+ if (ctx.durableStreamsDispatcher) {
210
+ nextInit.dispatcher = ctx.durableStreamsDispatcher
211
+ }
212
+ return await fetch(input, nextInit as RequestInit)
213
+ }
214
+ }
215
+
216
+ function resolveDurableStreamsWebhookSignature(
217
+ ctx: TenantContext
218
+ ): false | WebhookSignatureVerifierConfig {
219
+ if (ctx.durableStreamsWebhookSignature === false) return false
220
+
221
+ return {
222
+ jwksUrl:
223
+ ctx.durableStreamsWebhookSignature?.jwksUrl ??
224
+ durableStreamsWebhookJwksUrl(ctx),
225
+ toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
226
+ cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
227
+ fetchClient:
228
+ ctx.durableStreamsWebhookSignature?.fetchClient ??
229
+ durableStreamsJwksFetchClient(ctx),
230
+ }
231
+ }
232
+
233
+ async function verifyDurableStreamsWebhook(
234
+ request: IRequest,
235
+ ctx: TenantContext,
236
+ body: Uint8Array
237
+ ): Promise<Response | null> {
238
+ const config = resolveDurableStreamsWebhookSignature(ctx)
239
+ if (config === false) return null
240
+
241
+ const verification = await verifyWebhookSignature(
242
+ body,
243
+ request.headers.get(`webhook-signature`),
244
+ config
245
+ )
246
+ if (verification.ok) return null
247
+
248
+ return apiError(
249
+ verification.status,
250
+ verification.status === 401
251
+ ? ErrCodeUnauthorized
252
+ : `WEBHOOK_SIGNATURE_UNAVAILABLE`,
253
+ verification.error
254
+ )
255
+ }
256
+
159
257
  function claimTokenFromRequest(request: IRequest): string | undefined {
160
258
  const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim()
161
259
  if (electricClaimToken) return electricClaimToken
@@ -249,7 +347,11 @@ async function webhookForward(
249
347
  subscriptionId
250
348
  )
251
349
 
252
- const lookupPromise: Promise<string | null> = tracer.startActiveSpan(
350
+ const body = await readRequestBody(request as Request)
351
+ const signatureError = await verifyDurableStreamsWebhook(request, ctx, body)
352
+ if (signatureError) return signatureError
353
+
354
+ const targetWebhookUrl: string | null = await tracer.startActiveSpan(
253
355
  `db.lookupSubscription`,
254
356
  async (span) => {
255
357
  try {
@@ -270,11 +372,6 @@ async function webhookForward(
270
372
  }
271
373
  )
272
374
 
273
- const [targetWebhookUrl, body] = await Promise.all([
274
- lookupPromise,
275
- readRequestBody(request as Request),
276
- ])
277
-
278
375
  if (!targetWebhookUrl) {
279
376
  return apiError(
280
377
  404,
@@ -382,7 +479,7 @@ async function webhookForward(
382
479
  enrichPromise,
383
480
  ])
384
481
 
385
- if (entity?.status === `stopped`) {
482
+ if (entity?.status === `stopped` || entity?.status === `paused`) {
386
483
  if (upsertPromise) await upsertPromise
387
484
  return json({ done: true })
388
485
  }
@@ -439,6 +536,10 @@ async function webhookForward(
439
536
  const headers = forwardHeadersFromRequest(request)
440
537
  headers.set(`content-type`, `application/json`)
441
538
  headers.delete(`content-length`)
539
+ headers.set(
540
+ `webhook-signature`,
541
+ await resolveWebhookSigner(ctx).sign(forwardBody)
542
+ )
442
543
 
443
544
  let upstream: Response
444
545
  try {
@@ -624,8 +725,15 @@ async function callbackForward(
624
725
  const entity = await ctx.entityManager.registry.getEntityByStream(
625
726
  target.primaryStream
626
727
  )
627
- if (entity && stillOwnsClaim) {
628
- if (epoch !== undefined) {
728
+
729
+ // Release the consumer_claims row by its DB identity (consumerId,
730
+ // epoch). The in-memory write token is a separate concern (write
731
+ // authorization during the run); release of the durable row must
732
+ // succeed even if the token was lost (server restart) or evicted
733
+ // (a later wake re-minted for the same stream).
734
+ let entityCleared = false
735
+ if (epoch !== undefined) {
736
+ const result =
629
737
  await ctx.entityManager.registry.materializeReleasedClaim?.({
630
738
  consumerId,
631
739
  epoch,
@@ -643,28 +751,44 @@ async function callbackForward(
643
751
  })
644
752
  : undefined,
645
753
  })
646
- }
647
- await ctx.entityManager.registry.updateStatus(entity.url, `idle`)
648
- ctx.runtime.claimWriteTokens.clearStream(
649
- ctx.service,
650
- target.primaryStream
754
+ entityCleared = result?.entityCleared ?? false
755
+ }
756
+
757
+ // Transition entity back to idle when either signal says it's safe:
758
+ // - entityCleared: our release just cleared the entity's active
759
+ // dispatch state, so no in-flight wake remains.
760
+ // - stillOwnsClaim: this consumer is still the in-memory write-token
761
+ // owner, so no newer wake has displaced it. Covers two cases:
762
+ // (a) retry of a failed done (first attempt cleared the DB state
763
+ // but failed to update status), (b) server restart scenarios where
764
+ // the token is intact even though entityDispatchState may diverge.
765
+ // If both are false, a newer wake owns the entity — leave status as-is.
766
+ if (entity && (entityCleared || stillOwnsClaim)) {
767
+ await ctx.entityManager.registry.updateStatus(
768
+ entity.url,
769
+ entity.status === `stopping` ? `stopped` : `idle`
651
770
  )
652
771
  await ctx.entityBridgeManager.onEntityChanged(entity.url)
653
772
  serverLog.info(
654
- `[callback-forward] status updated to idle for ${entity.url}`
773
+ `[callback-forward] status updated after done for ${entity.url}`
774
+ )
775
+ } else if (!entity) {
776
+ serverLog.warn(
777
+ `[callback-forward] done received but no entity found for stream=${target.primaryStream}`
655
778
  )
656
- } else if (stillOwnsClaim) {
779
+ }
780
+
781
+ // Clear the in-memory write token only if this consumer still owns it.
782
+ // If a newer wake has taken over, that newer wake owns the token now
783
+ // and we must not clear it out from under it.
784
+ if (stillOwnsClaim) {
657
785
  ctx.runtime.claimWriteTokens.clearStream(
658
786
  ctx.service,
659
787
  target.primaryStream
660
788
  )
661
789
  } else if (entity) {
662
790
  serverLog.info(
663
- `[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`
664
- )
665
- } else {
666
- serverLog.warn(
667
- `[callback-forward] done received but no entity found for stream=${target.primaryStream}`
791
+ `[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`
668
792
  )
669
793
  }
670
794
  } else if (requestBody?.done === true) {
@@ -575,7 +575,7 @@ async function notificationFromClaim(
575
575
  404
576
576
  )
577
577
  }
578
- if (entity.status === `stopped`) {
578
+ if (entity.status === `stopped` || entity.status === `paused`) {
579
579
  await ctx.streamClient.releaseSubscription(
580
580
  input.subscriptionId,
581
581
  input.claim.token,
package/src/runtime.ts CHANGED
@@ -533,7 +533,11 @@ export class ElectricAgentsTenantRuntime {
533
533
  return
534
534
  }
535
535
 
536
- await this.manager.registry.updateStatus(entityUrl, `idle`)
536
+ const entity = await this.manager.registry.getEntity(entityUrl)
537
+ await this.manager.registry.updateStatus(
538
+ entityUrl,
539
+ entity?.status === `stopping` ? `stopped` : `idle`
540
+ )
537
541
  await this.entityBridgeManager.onEntityChanged(entityUrl)
538
542
  }
539
543
  }
package/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from './electric-agents-types.js'
20
20
  import { ElectricAgentsError } from './entity-manager.js'
21
21
  import { serverLog } from './utils/log.js'
22
+ import { createEd25519WebhookSigner } from './webhook-signing.js'
22
23
  import type { DrizzleDB, PgClient } from './db/index.js'
23
24
  import type { Server } from 'node:http'
24
25
  import type { DurableStreamTestServer } from '@durable-streams/server'
@@ -27,6 +28,7 @@ import type {
27
28
  AgentModel,
28
29
  EntityRegistry,
29
30
  RuntimeHandler,
31
+ WebhookSignatureVerifierConfig,
30
32
  } from '@electric-ax/agents-runtime'
31
33
  import type { Principal } from './principal.js'
32
34
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
@@ -34,6 +36,10 @@ import type { DurableStreamsRoutingAdapter } from './routing/durable-streams-rou
34
36
  import type { OssServerContext } from './routing/oss-server-router.js'
35
37
  import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
36
38
  import type { DurableStreamsBearerProvider } from './stream-client.js'
39
+ import type {
40
+ WebhookSigner,
41
+ WebhookSigningKeyInput,
42
+ } from './webhook-signing.js'
37
43
 
38
44
  const MOCK_AGENT_HANDLER_PATH = `/_electric/mock-agent-handler`
39
45
 
@@ -45,6 +51,11 @@ export interface ElectricAgentsServerOptions {
45
51
  durableStreamsBearer?: DurableStreamsBearerProvider
46
52
  durableStreamsRouting?: DurableStreamsRoutingAdapter
47
53
  durableStreamsServer?: DurableStreamTestServer
54
+ durableStreamsWebhookSignature?:
55
+ | false
56
+ | Partial<WebhookSignatureVerifierConfig>
57
+ webhookSigner?: WebhookSigner
58
+ webhookSigningKey?: WebhookSigningKeyInput
48
59
  port: number
49
60
  host?: string
50
61
  workingDirectory?: string
@@ -141,6 +152,7 @@ export class ElectricAgentsServer {
141
152
  private shuttingDown = false
142
153
  private streamsAgent?: Agent
143
154
  private standaloneRuntime?: StartedStandaloneAgentsRuntime
155
+ private readonly webhookSigner: WebhookSigner
144
156
 
145
157
  streamClient: StreamClient
146
158
  readonly options: ElectricAgentsServerOptions
@@ -152,6 +164,9 @@ export class ElectricAgentsServer {
152
164
  )
153
165
  }
154
166
  this.options = options
167
+ this.webhookSigner =
168
+ options.webhookSigner ??
169
+ createEd25519WebhookSigner({ privateKey: options.webhookSigningKey })
155
170
  this.streamClient = options.durableStreamsUrl
156
171
  ? new StreamClient(options.durableStreamsUrl, {
157
172
  bearer: options.durableStreamsBearer,
@@ -413,6 +428,9 @@ export class ElectricAgentsServer {
413
428
  durableStreamsBearer: this.options.durableStreamsBearer,
414
429
  durableStreamsRouting: this.options.durableStreamsRouting,
415
430
  durableStreamsDispatcher: this.streamsAgent,
431
+ durableStreamsWebhookSignature:
432
+ this.options.durableStreamsWebhookSignature,
433
+ webhookSigner: this.webhookSigner,
416
434
  electricUrl: this.options.electricUrl,
417
435
  electricSecret: this.options.electricSecret,
418
436
  ownAgentHandlerPaths: this.mockAgentBootstrap
@@ -44,11 +44,17 @@ export interface SubscriptionResponse {
44
44
  type?: `webhook` | `pull-wake`
45
45
  pattern?: string
46
46
  streams?: Array<string | SubscriptionStreamInfo>
47
- webhook?: { url?: string }
47
+ webhook?: {
48
+ url?: string
49
+ signing?: {
50
+ alg?: string
51
+ kid?: string
52
+ jwks_url?: string
53
+ }
54
+ }
48
55
  wake_stream?: string
49
56
  callback_url?: string
50
57
  callback_token?: string
51
- webhook_secret?: string
52
58
  }
53
59
 
54
60
  export interface SubscriptionCreateInput {
@@ -535,14 +541,14 @@ export class StreamClient {
535
541
  subscriptionId: string,
536
542
  webhookUrl: string,
537
543
  description?: string
538
- ): Promise<{ subscription_id: string; webhook_secret?: string }> {
544
+ ): Promise<SubscriptionResponse> {
539
545
  const res = await this.putSubscription(subscriptionId, {
540
546
  type: `webhook`,
541
547
  pattern: normalizeSubscriptionPattern(pattern),
542
548
  webhook: { url: webhookUrl },
543
549
  ...(description ? { description } : {}),
544
550
  })
545
- return res as { subscription_id: string; webhook_secret?: string }
551
+ return res
546
552
  }
547
553
 
548
554
  async putSubscription(
@@ -0,0 +1,173 @@
1
+ import {
2
+ createHash,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ generateKeyPairSync,
6
+ sign,
7
+ } from 'node:crypto'
8
+ import { appendPathToUrl } from '@electric-ax/agents-runtime'
9
+ import type { JsonWebKey as NodeJsonWebKey, KeyObject } from 'node:crypto'
10
+
11
+ export interface WebhookPublicJwk {
12
+ kty: `OKP`
13
+ crv: `Ed25519`
14
+ x: string
15
+ kid: string
16
+ use: `sig`
17
+ alg: `EdDSA`
18
+ }
19
+
20
+ export interface WebhookJwks {
21
+ keys: Array<WebhookPublicJwk>
22
+ }
23
+
24
+ export interface WebhookSigningMetadata {
25
+ alg: `ed25519`
26
+ kid: string
27
+ jwks_url: string
28
+ }
29
+
30
+ export interface WebhookSigner {
31
+ sign: (body: Uint8Array | string) => string | Promise<string>
32
+ jwks: () => WebhookJwks | Promise<WebhookJwks>
33
+ }
34
+
35
+ export type WebhookSigningKeyInput =
36
+ | string
37
+ | Buffer
38
+ | NodeJsonWebKey
39
+ | KeyObject
40
+
41
+ export interface Ed25519WebhookSignerOptions {
42
+ privateKey?: WebhookSigningKeyInput
43
+ kid?: string
44
+ }
45
+
46
+ const encoder = new TextEncoder()
47
+ const defaultWebhookSigner = createEd25519WebhookSigner()
48
+
49
+ export function createEd25519WebhookSigner(
50
+ options: Ed25519WebhookSignerOptions = {}
51
+ ): WebhookSigner {
52
+ const privateKey = options.privateKey
53
+ ? importPrivateKey(options.privateKey)
54
+ : generateKeyPairSync(`ed25519`).privateKey
55
+
56
+ if (privateKey.asymmetricKeyType !== `ed25519`) {
57
+ throw new Error(`Webhook signing key must be an Ed25519 private key`)
58
+ }
59
+
60
+ const publicJwk = buildPublicJwk(privateKey, options.kid)
61
+
62
+ return {
63
+ sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
64
+ jwks: () => ({ keys: [{ ...publicJwk }] }),
65
+ }
66
+ }
67
+
68
+ export function getDefaultWebhookSigner(): WebhookSigner {
69
+ return defaultWebhookSigner
70
+ }
71
+
72
+ export async function webhookSigningMetadata(
73
+ signer: WebhookSigner,
74
+ streamRootUrl: string
75
+ ): Promise<WebhookSigningMetadata> {
76
+ const jwks = await signer.jwks()
77
+ const key = jwks.keys[0]
78
+ if (!key) {
79
+ throw new Error(`Webhook signer did not provide any public keys`)
80
+ }
81
+
82
+ return {
83
+ alg: `ed25519`,
84
+ kid: key.kid,
85
+ jwks_url: appendPathToUrl(streamRootUrl, `/__ds/jwks.json`),
86
+ }
87
+ }
88
+
89
+ export function signWebhookBody(
90
+ privateKey: KeyObject,
91
+ kid: string,
92
+ body: Uint8Array | string
93
+ ): string {
94
+ const timestamp = Math.floor(Date.now() / 1000)
95
+ const payload = bytesWithTimestamp(timestamp, body)
96
+ const signature = sign(null, payload, privateKey).toString(`base64url`)
97
+ return `t=${timestamp},kid=${kid},ed25519=${signature}`
98
+ }
99
+
100
+ export function bytesWithTimestamp(
101
+ timestamp: number | string,
102
+ body: Uint8Array | string
103
+ ): Buffer {
104
+ const prefix = encoder.encode(`${timestamp}.`)
105
+ const bodyBytes = typeof body === `string` ? encoder.encode(body) : body
106
+ return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)])
107
+ }
108
+
109
+ function importPrivateKey(input: WebhookSigningKeyInput): KeyObject {
110
+ if (isKeyObject(input)) return input
111
+
112
+ if (typeof input === `string`) {
113
+ const trimmed = input.trim()
114
+ if (trimmed.startsWith(`{`)) {
115
+ return createPrivateKey({
116
+ key: JSON.parse(trimmed) as NodeJsonWebKey,
117
+ format: `jwk`,
118
+ })
119
+ }
120
+ return createPrivateKey(trimmed.replace(/\\n/g, `\n`))
121
+ }
122
+
123
+ if (Buffer.isBuffer(input)) {
124
+ return createPrivateKey(input)
125
+ }
126
+
127
+ return createPrivateKey({ key: input, format: `jwk` })
128
+ }
129
+
130
+ function isKeyObject(input: WebhookSigningKeyInput): input is KeyObject {
131
+ return (
132
+ typeof input === `object` && `type` in input && input.type === `private`
133
+ )
134
+ }
135
+
136
+ function buildPublicJwk(
137
+ privateKey: KeyObject,
138
+ kid: string | undefined
139
+ ): WebhookPublicJwk {
140
+ const exported = createPublicKey(privateKey).export({ format: `jwk` }) as {
141
+ kty?: string
142
+ crv?: string
143
+ x?: string
144
+ }
145
+
146
+ if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) {
147
+ throw new Error(`Failed to export Ed25519 webhook signing key`)
148
+ }
149
+
150
+ return {
151
+ kty: `OKP`,
152
+ crv: `Ed25519`,
153
+ x: exported.x,
154
+ kid:
155
+ kid ??
156
+ deriveKeyId({
157
+ kty: exported.kty,
158
+ crv: exported.crv,
159
+ x: exported.x,
160
+ }),
161
+ use: `sig`,
162
+ alg: `EdDSA`,
163
+ }
164
+ }
165
+
166
+ function deriveKeyId(jwk: { kty: string; crv: string; x: string }): string {
167
+ const thumbprintInput = JSON.stringify({
168
+ crv: jwk.crv,
169
+ kty: jwk.kty,
170
+ x: jwk.x,
171
+ })
172
+ return `ds_${createHash(`sha256`).update(thumbprintInput).digest(`base64url`)}`
173
+ }