@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.
- package/dist/entrypoint.js +404 -68
- package/dist/index.cjs +421 -67
- package/dist/index.d.cts +97 -11
- package/dist/index.d.ts +97 -11
- package/dist/index.js +414 -69
- package/drizzle/0009_entity_signal_statuses.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +1 -1
- package/src/electric-agents-types.ts +76 -1
- package/src/entity-manager.ts +256 -33
- package/src/entity-registry.ts +57 -20
- package/src/entrypoint-lib.ts +5 -0
- package/src/index.ts +33 -0
- package/src/routing/context.ts +6 -0
- package/src/routing/dispatch-policy.ts +6 -0
- package/src/routing/durable-streams-router.ts +62 -13
- package/src/routing/entities-router.ts +57 -1
- package/src/routing/internal-router.ts +147 -23
- package/src/routing/runners-router.ts +1 -1
- package/src/runtime.ts +5 -1
- package/src/server.ts +18 -0
- package/src/stream-client.ts +10 -4
- package/src/webhook-signing.ts +173 -0
|
@@ -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,
|
|
@@ -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
|
-
|
|
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,44 @@ async function callbackForward(
|
|
|
643
751
|
})
|
|
644
752
|
: undefined,
|
|
645
753
|
})
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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.
|
|
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
|
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
|
+
}
|