@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.
@@ -15,10 +15,15 @@ import {
15
15
  import { validateBody } from './schema.js'
16
16
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
17
17
  import { forwardFetchRequest } from '../utils/server-utils.js'
18
+ import {
19
+ getDefaultWebhookSigner,
20
+ webhookSigningMetadata,
21
+ } from '../webhook-signing.js'
18
22
  import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
19
23
  import type { IRequest, RouterType } from 'itty-router'
20
24
  import type { TenantContext } from './context.js'
21
25
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
26
+ import type { WebhookSigner } from '../webhook-signing.js'
22
27
 
23
28
  const subscriptionProxyBodySchema = Type.Object(
24
29
  {
@@ -81,6 +86,7 @@ for (const action of subscriptionControlActions) {
81
86
  subscriptionAction(action)
82
87
  )
83
88
  }
89
+ durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks)
84
90
  durableStreamsRouter.all(`/__ds`, controlPassThrough)
85
91
  durableStreamsRouter.all(`/__ds/*`, controlPassThrough)
86
92
  durableStreamsRouter.post(`*`, streamAppend)
@@ -94,13 +100,22 @@ function bodyFromBytes(body: Uint8Array): ArrayBuffer {
94
100
  }
95
101
 
96
102
  function responseFromUpstream(response: Response, body?: Uint8Array): Response {
97
- return new Response(body ? bodyFromBytes(body) : response.body, {
103
+ const responseBody = forbidsResponseBody(response.status)
104
+ ? null
105
+ : body !== undefined
106
+ ? bodyFromBytes(body)
107
+ : response.body
108
+ return new Response(responseBody, {
98
109
  status: response.status,
99
110
  statusText: response.statusText,
100
111
  headers: responseHeaders(response),
101
112
  })
102
113
  }
103
114
 
115
+ function forbidsResponseBody(status: number): boolean {
116
+ return status === 204 || status === 205 || status === 304
117
+ }
118
+
104
119
  async function forwardToDurableStreams(
105
120
  ctx: TenantContext,
106
121
  request: IRequest,
@@ -178,12 +193,12 @@ function rewriteSubscriptionBodyForBackend(
178
193
  }
179
194
  }
180
195
 
181
- function rewriteSubscriptionResponseForClient(
196
+ async function rewriteSubscriptionResponseForClient(
182
197
  bytes: Uint8Array,
183
198
  response: Response,
184
- service: string,
199
+ ctx: TenantContext,
185
200
  routingAdapter: DurableStreamsRoutingAdapter
186
- ): Uint8Array {
201
+ ): Promise<Uint8Array> {
187
202
  if (!response.headers.get(`content-type`)?.includes(`application/json`)) {
188
203
  return bytes
189
204
  }
@@ -192,14 +207,14 @@ function rewriteSubscriptionResponseForClient(
192
207
 
193
208
  if (typeof payload.pattern === `string`) {
194
209
  payload.pattern = routingAdapter.toRuntimeStreamPath(
195
- service,
210
+ ctx.service,
196
211
  payload.pattern
197
212
  )
198
213
  }
199
214
  if (Array.isArray(payload.streams)) {
200
215
  payload.streams = payload.streams.map((stream) => {
201
216
  if (typeof stream === `string`) {
202
- return routingAdapter.toRuntimeStreamPath(service, stream)
217
+ return routingAdapter.toRuntimeStreamPath(ctx.service, stream)
203
218
  }
204
219
  if (
205
220
  stream &&
@@ -209,7 +224,7 @@ function rewriteSubscriptionResponseForClient(
209
224
  return {
210
225
  ...(stream as Record<string, unknown>),
211
226
  path: routingAdapter.toRuntimeStreamPath(
212
- service,
227
+ ctx.service,
213
228
  (stream as Record<string, string>).path
214
229
  ),
215
230
  }
@@ -219,26 +234,43 @@ function rewriteSubscriptionResponseForClient(
219
234
  }
220
235
  if (typeof payload.wake_stream === `string`) {
221
236
  payload.wake_stream = routingAdapter.toRuntimeStreamPath(
222
- service,
237
+ ctx.service,
223
238
  payload.wake_stream
224
239
  )
225
240
  }
226
241
  if (typeof payload.stream === `string`) {
227
- payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream)
242
+ payload.stream = routingAdapter.toRuntimeStreamPath(
243
+ ctx.service,
244
+ payload.stream
245
+ )
228
246
  }
229
247
  if (Array.isArray(payload.acks)) {
230
248
  payload.acks = payload.acks.map((ack) => {
231
249
  if (!ack || typeof ack !== `object`) return ack
232
250
  const next = { ...(ack as Record<string, unknown>) }
233
251
  if (typeof next.stream === `string`) {
234
- next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream)
252
+ next.stream = routingAdapter.toRuntimeStreamPath(
253
+ ctx.service,
254
+ next.stream
255
+ )
235
256
  }
236
257
  if (typeof next.path === `string`) {
237
- next.path = routingAdapter.toRuntimeStreamPath(service, next.path)
258
+ next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path)
238
259
  }
239
260
  return next
240
261
  })
241
262
  }
263
+ if (
264
+ payload.webhook &&
265
+ typeof payload.webhook === `object` &&
266
+ !Array.isArray(payload.webhook)
267
+ ) {
268
+ const webhook = payload.webhook as Record<string, unknown>
269
+ webhook.signing = await webhookSigningMetadata(
270
+ resolveWebhookSigner(ctx),
271
+ ctx.publicUrl
272
+ )
273
+ }
242
274
 
243
275
  return new TextEncoder().encode(JSON.stringify(payload))
244
276
  }
@@ -269,6 +301,10 @@ function subscriptionRoutingAdapter(
269
301
  )
270
302
  }
271
303
 
304
+ function resolveWebhookSigner(ctx: TenantContext): WebhookSigner {
305
+ return ctx.webhookSigner ?? getDefaultWebhookSigner()
306
+ }
307
+
272
308
  async function rewriteSubscriptionRequestBody(
273
309
  request: IRequest,
274
310
  ctx: TenantContext,
@@ -334,10 +370,10 @@ async function forwardSubscriptionRequest(
334
370
  let responseBytes: Uint8Array = upstream.body
335
371
  ? new Uint8Array(await upstream.arrayBuffer())
336
372
  : new Uint8Array()
337
- responseBytes = rewriteSubscriptionResponseForClient(
373
+ responseBytes = await rewriteSubscriptionResponseForClient(
338
374
  responseBytes,
339
375
  upstream,
340
- ctx.service,
376
+ ctx,
341
377
  routingAdapter
342
378
  )
343
379
  return {
@@ -530,6 +566,19 @@ async function controlPassThrough(
530
566
  return responseFromUpstream(upstream)
531
567
  }
532
568
 
569
+ async function webhookJwks(
570
+ _request: IRequest,
571
+ ctx: TenantContext
572
+ ): Promise<Response> {
573
+ return new Response(JSON.stringify(await resolveWebhookSigner(ctx).jwks()), {
574
+ status: 200,
575
+ headers: {
576
+ 'content-type': `application/jwk-set+json`,
577
+ 'cache-control': `public, max-age=300`,
578
+ },
579
+ })
580
+ }
581
+
533
582
  async function streamAppend(
534
583
  request: IRequest,
535
584
  ctx: TenantContext
@@ -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,
@@ -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,41 @@ async function callbackForward(
643
751
  })
644
752
  : undefined,
645
753
  })
646
- }
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)) {
647
767
  await ctx.entityManager.registry.updateStatus(entity.url, `idle`)
648
- ctx.runtime.claimWriteTokens.clearStream(
649
- ctx.service,
650
- target.primaryStream
651
- )
652
768
  await ctx.entityBridgeManager.onEntityChanged(entity.url)
653
769
  serverLog.info(
654
770
  `[callback-forward] status updated to idle for ${entity.url}`
655
771
  )
656
- } else if (stillOwnsClaim) {
772
+ } else if (!entity) {
773
+ serverLog.warn(
774
+ `[callback-forward] done received but no entity found for stream=${target.primaryStream}`
775
+ )
776
+ }
777
+
778
+ // Clear the in-memory write token only if this consumer still owns it.
779
+ // If a newer wake has taken over, that newer wake owns the token now
780
+ // and we must not clear it out from under it.
781
+ if (stillOwnsClaim) {
657
782
  ctx.runtime.claimWriteTokens.clearStream(
658
783
  ctx.service,
659
784
  target.primaryStream
660
785
  )
661
786
  } else if (entity) {
662
787
  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}`
788
+ `[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`
668
789
  )
669
790
  }
670
791
  } else if (requestBody?.done === true) {
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
+ }