@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
628
|
-
|
|
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 (
|
|
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
|
|
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
|
package/src/stream-client.ts
CHANGED
|
@@ -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?: {
|
|
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<
|
|
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
|
|
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
|
+
}
|