@electric-ax/agents-server 0.4.2 → 0.4.4

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.
@@ -8,6 +8,7 @@ import {
8
8
  ErrCodeUnauthorized,
9
9
  } from '../electric-agents-types.js'
10
10
  import { runnerWakeStream } from '../entity-registry.js'
11
+ import { DurableStreamsSubscriptionError } from '../stream-client.js'
11
12
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
12
13
  import { serverLog } from '../utils/log.js'
13
14
  import type {
@@ -16,6 +17,9 @@ import type {
16
17
  ElectricAgentsEntity,
17
18
  } from '../electric-agents-types.js'
18
19
  import type { TenantContext } from './context.js'
20
+ import type { SubscriptionCreateInput } from '../stream-client.js'
21
+
22
+ const linkedDispatchSubscriptions = new WeakMap<object, Set<string>>()
19
23
 
20
24
  export function subscriptionIdForDispatchTarget(
21
25
  target: DispatchTarget
@@ -31,7 +35,7 @@ function subscriptionIdForEntityDispatchTarget(
31
35
  entityUrl: string
32
36
  ): string {
33
37
  const base = subscriptionIdForDispatchTarget(target)
34
- if (!target.subscription_id) return base
38
+ if (!target.subscription_id && target.type !== `runner`) return base
35
39
  const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
36
40
  return `${base}:${digest.slice(0, 16)}`
37
41
  }
@@ -109,12 +113,92 @@ function sameDispatchDestination(
109
113
  return false
110
114
  }
111
115
 
116
+ function subscriptionHasStream(
117
+ ctx: TenantContext,
118
+ existing: { streams?: Array<string | { path?: string }> },
119
+ streamPath: string
120
+ ): boolean {
121
+ const normalizedStream = streamPath.replace(/^\/+/, ``)
122
+ const backendStream = `${ctx.service}/${normalizedStream}`
123
+ return (
124
+ existing.streams?.some((stream) => {
125
+ const path = typeof stream === `string` ? stream : stream.path
126
+ if (!path) return false
127
+ const normalized = path.replace(/^\/+/, ``)
128
+ return normalized === normalizedStream || normalized === backendStream
129
+ }) ?? false
130
+ )
131
+ }
132
+
133
+ function dispatchLinkCacheKey(
134
+ ctx: TenantContext,
135
+ subscriptionId: string,
136
+ streamPath: string
137
+ ): string {
138
+ return `${ctx.service}:${subscriptionId}:${streamPath}`
139
+ }
140
+
141
+ function getDispatchLinkCache(ctx: TenantContext): Set<string> {
142
+ let cache = linkedDispatchSubscriptions.get(ctx.streamClient)
143
+ if (!cache) {
144
+ cache = new Set()
145
+ linkedDispatchSubscriptions.set(ctx.streamClient, cache)
146
+ }
147
+ return cache
148
+ }
149
+
150
+ function isSubscriptionAlreadyExistsError(err: unknown): boolean {
151
+ if (!(err instanceof DurableStreamsSubscriptionError)) return false
152
+ if (err.status === 409) return true
153
+ return (
154
+ err.code === `SUBSCRIPTION_ALREADY_EXISTS` ||
155
+ err.code === `ALREADY_EXISTS` ||
156
+ /already exists/i.test(err.errorMessage ?? err.body ?? err.message)
157
+ )
158
+ }
159
+
160
+ async function ensureSubscriptionIncludesStream(
161
+ ctx: TenantContext,
162
+ subscriptionId: string,
163
+ streamPath: string,
164
+ input: SubscriptionCreateInput,
165
+ existing: { streams?: Array<string | { path?: string }> } | null
166
+ ): Promise<void> {
167
+ if (!existing) {
168
+ try {
169
+ await ctx.streamClient.putSubscription(subscriptionId, input)
170
+ return
171
+ } catch (err) {
172
+ if (!isSubscriptionAlreadyExistsError(err)) throw err
173
+ existing = await ctx.streamClient.getSubscription(subscriptionId)
174
+ if (!existing) {
175
+ serverLog.warn(
176
+ `[dispatch-policy] subscription create raced with existing subscription but it could not be read`,
177
+ { subscriptionId, stream: streamPath }
178
+ )
179
+ return
180
+ }
181
+ }
182
+ }
183
+
184
+ if (!subscriptionHasStream(ctx, existing, streamPath)) {
185
+ await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
186
+ }
187
+ }
188
+
112
189
  export async function assertDispatchPolicyAllowed(
113
190
  ctx: TenantContext,
114
191
  policy: DispatchPolicy | undefined
115
192
  ): Promise<void> {
116
193
  const target = policy?.targets[0]
117
194
  if (!target || target.type !== `runner`) return
195
+ if (!ctx.principal) {
196
+ throw new ElectricAgentsError(
197
+ ErrCodeUnauthorized,
198
+ `Runner dispatch requires an authenticated owner`,
199
+ 401
200
+ )
201
+ }
118
202
 
119
203
  const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
120
204
  if (!runner) {
@@ -124,7 +208,7 @@ export async function assertDispatchPolicyAllowed(
124
208
  404
125
209
  )
126
210
  }
127
- if (ctx.principal && runner.owner_user_id !== ctx.principal.key) {
211
+ if (runner.owner_principal !== ctx.principal.url) {
128
212
  throw new ElectricAgentsError(
129
213
  ErrCodeUnauthorized,
130
214
  `Runner dispatch requires the authenticated owner`,
@@ -143,7 +227,19 @@ export async function linkEntityDispatchSubscription(
143
227
  )
144
228
  const target = dispatchPolicy?.targets[0]
145
229
  if (!target) return
146
- await linkStreamToTargetSubscription(ctx, target, entity)
230
+ const subscriptionId = subscriptionIdForEntityDispatchTarget(
231
+ target,
232
+ entity.url
233
+ )
234
+ const cacheKey = dispatchLinkCacheKey(
235
+ ctx,
236
+ subscriptionId,
237
+ entity.streams.main
238
+ )
239
+ const cache = getDispatchLinkCache(ctx)
240
+ if (cache.has(cacheKey)) return
241
+ await linkStreamToTargetSubscription(ctx, target, entity, subscriptionId)
242
+ cache.add(cacheKey)
147
243
  }
148
244
 
149
245
  export async function unlinkEntityDispatchSubscription(
@@ -160,6 +256,9 @@ export async function unlinkEntityDispatchSubscription(
160
256
  target,
161
257
  entity.url
162
258
  )
259
+ getDispatchLinkCache(ctx).delete(
260
+ dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main)
261
+ )
163
262
  await ctx.streamClient
164
263
  .removeSubscriptionStream(subscriptionId, entity.streams.main)
165
264
  .catch((err) => {
@@ -174,13 +273,13 @@ export async function unlinkEntityDispatchSubscription(
174
273
  async function linkStreamToTargetSubscription(
175
274
  ctx: TenantContext,
176
275
  target: DispatchTarget,
177
- entity: ElectricAgentsEntity
276
+ entity: ElectricAgentsEntity,
277
+ subscriptionId: string
178
278
  ): Promise<void> {
179
279
  const streamPath = entity.streams.main
180
- const subscriptionId = subscriptionIdForEntityDispatchTarget(
181
- target,
182
- entity.url
183
- )
280
+ await ctx.streamClient.ensure(streamPath, {
281
+ contentType: `application/json`,
282
+ })
184
283
  const existing = await ctx.streamClient.getSubscription(subscriptionId)
185
284
 
186
285
  if (target.type === `runner`) {
@@ -196,16 +295,18 @@ async function linkStreamToTargetSubscription(
196
295
  await ctx.streamClient.ensure(wakeStream, {
197
296
  contentType: `application/json`,
198
297
  })
199
- if (!existing) {
200
- await ctx.streamClient.putSubscription(subscriptionId, {
298
+ await ensureSubscriptionIncludesStream(
299
+ ctx,
300
+ subscriptionId,
301
+ streamPath,
302
+ {
201
303
  type: `pull-wake`,
202
304
  streams: [streamPath],
203
305
  wake_stream: wakeStream,
204
306
  description: `Electric Agents runner ${target.runnerId}`,
205
- })
206
- return
207
- }
208
- await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
307
+ },
308
+ existing
309
+ )
209
310
  return
210
311
  }
211
312
 
@@ -221,16 +322,18 @@ async function linkStreamToTargetSubscription(
221
322
  ctx.publicUrl,
222
323
  `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
223
324
  )
224
- if (!existing) {
225
- await ctx.streamClient.putSubscription(subscriptionId, {
325
+ await ensureSubscriptionIncludesStream(
326
+ ctx,
327
+ subscriptionId,
328
+ streamPath,
329
+ {
226
330
  type: `webhook`,
227
331
  streams: [streamPath],
228
332
  webhook: { url: forwardUrl },
229
333
  description: `Electric Agents webhook ${subscriptionId}`,
230
- })
231
- } else {
232
- await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
233
- }
334
+ },
335
+ existing
336
+ )
234
337
  await ctx.pgDb
235
338
  .insert(subscriptionWebhooks)
236
339
  .values({
@@ -36,6 +36,13 @@ const subscriptionProxyBodySchema = Type.Object(
36
36
 
37
37
  type SubscriptionProxyBody = Static<typeof subscriptionProxyBodySchema>
38
38
 
39
+ const subscriptionControlActions = [
40
+ `callback`,
41
+ `claim`,
42
+ `ack`,
43
+ `release`,
44
+ ] as const
45
+
39
46
  export type DurableStreamsRoutes = RouterType<
40
47
  IRequest,
41
48
  [TenantContext],
@@ -48,7 +55,34 @@ export const durableStreamsRouter: DurableStreamsRoutes = Router<
48
55
  Response | undefined
49
56
  >()
50
57
 
51
- durableStreamsRouter.all(`/v1/stream-meta/subscriptions/*`, subscriptionProxy)
58
+ durableStreamsRouter.put(
59
+ `/__ds/subscriptions/:subscriptionId`,
60
+ putSubscriptionBase
61
+ )
62
+ durableStreamsRouter.get(
63
+ `/__ds/subscriptions/:subscriptionId`,
64
+ getSubscriptionBase
65
+ )
66
+ durableStreamsRouter.delete(
67
+ `/__ds/subscriptions/:subscriptionId`,
68
+ deleteSubscriptionBase
69
+ )
70
+ durableStreamsRouter.post(
71
+ `/__ds/subscriptions/:subscriptionId/streams`,
72
+ postSubscriptionStreams
73
+ )
74
+ durableStreamsRouter.delete(
75
+ `/__ds/subscriptions/:subscriptionId/streams/:streamPath+`,
76
+ deleteSubscriptionStream
77
+ )
78
+ for (const action of subscriptionControlActions) {
79
+ durableStreamsRouter.post(
80
+ `/__ds/subscriptions/:subscriptionId/${action}`,
81
+ subscriptionAction(action)
82
+ )
83
+ }
84
+ durableStreamsRouter.all(`/__ds`, controlPassThrough)
85
+ durableStreamsRouter.all(`/__ds/*`, controlPassThrough)
52
86
  durableStreamsRouter.post(`*`, streamAppend)
53
87
  durableStreamsRouter.all(`*`, proxyPassThrough)
54
88
 
@@ -71,8 +105,9 @@ async function forwardToDurableStreams(
71
105
  ctx: TenantContext,
72
106
  request: IRequest,
73
107
  body?: Uint8Array,
74
- route: `stream` | `stream-meta` = `stream`,
75
- urlOverride?: string
108
+ route: `stream` | `control` = `stream`,
109
+ urlOverride?: string,
110
+ durableStreamsBearerMode: `overwrite` | `if-missing` = `overwrite`
76
111
  ): Promise<Response> {
77
112
  const headers = new Headers(request.headers)
78
113
  headers.delete(`host`)
@@ -94,11 +129,7 @@ async function forwardToDurableStreams(
94
129
  body: requestBody,
95
130
  durableStreamsUrl: ctx.durableStreamsUrl,
96
131
  durableStreamsBearer: ctx.durableStreamsBearer,
97
- durableStreamsBearerMode: usesSubscriptionScopedBearer(
98
- urlOverride ?? request.url
99
- )
100
- ? `if-missing`
101
- : `overwrite`,
132
+ durableStreamsBearerMode,
102
133
  durableStreamsRouting: ctx.durableStreamsRouting,
103
134
  serviceId: ctx.service,
104
135
  dispatcher: ctx.durableStreamsDispatcher,
@@ -106,23 +137,7 @@ async function forwardToDurableStreams(
106
137
  })
107
138
  }
108
139
 
109
- function subscriptionIdFromPath(pathname: string): string | null {
110
- const match = /^\/v1\/stream-meta\/subscriptions\/([^/]+)(?:\/.*)?$/.exec(
111
- pathname
112
- )
113
- return match ? decodeURIComponent(match[1]!) : null
114
- }
115
-
116
- function isSubscriptionBasePath(pathname: string): boolean {
117
- return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/?$/.test(pathname)
118
- }
119
-
120
- function usesSubscriptionScopedBearer(requestUrl: string): boolean {
121
- const pathname = new URL(requestUrl, `http://localhost`).pathname
122
- return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/(?:ack|release|callback)\/?$/.test(
123
- pathname
124
- )
125
- }
140
+ type SubscriptionControlAction = (typeof subscriptionControlActions)[number]
126
141
 
127
142
  function rewriteSubscriptionBodyForBackend(
128
143
  payload: Record<string, unknown>,
@@ -239,80 +254,82 @@ function decodeJson(bytes: Uint8Array): Record<string, unknown> | null {
239
254
  }
240
255
  }
241
256
 
242
- function rewriteSubscriptionStreamPathInUrl(
243
- requestUrl: URL,
244
- service: string,
245
- routingAdapter: DurableStreamsRoutingAdapter
246
- ): string {
247
- const match =
248
- /^(\/v1\/stream-meta\/subscriptions\/[^/]+\/streams\/)(.+)$/.exec(
249
- requestUrl.pathname
250
- )
251
- if (!match) return requestUrl.toString()
252
-
253
- const [, prefix, encodedPath] = match
254
- const streamPath = decodeURIComponent(encodedPath!)
255
- requestUrl.pathname = `${prefix}${encodeURIComponent(
256
- routingAdapter.toBackendStreamPath(service, streamPath)
257
- )}`
258
- return requestUrl.toString()
257
+ function routeParam(request: IRequest, name: string): string {
258
+ const value = request.params[name]
259
+ const raw = Array.isArray(value) ? value[0] : value
260
+ return decodeURIComponent(raw ?? ``)
259
261
  }
260
262
 
261
- async function subscriptionProxy(
262
- request: IRequest,
263
+ function subscriptionRoutingAdapter(
263
264
  ctx: TenantContext
264
- ): Promise<Response | undefined> {
265
- const url = new URL(request.url)
266
- const subscriptionId = subscriptionIdFromPath(url.pathname)
267
- if (!subscriptionId) return undefined
268
-
269
- const routingAdapter = resolveDurableStreamsRoutingAdapter(
270
- ctx.durableStreamsRouting
265
+ ): DurableStreamsRoutingAdapter {
266
+ return resolveDurableStreamsRoutingAdapter(
267
+ ctx.durableStreamsRouting,
268
+ ctx.durableStreamsUrl
271
269
  )
272
- let requestBody: Uint8Array | undefined
273
- let targetWebhookUrl: string | null = null
274
- let requestUrl = request.url
270
+ }
275
271
 
276
- if ([`PUT`, `POST`].includes(request.method.toUpperCase())) {
277
- requestBody = await readRequestBody(request as Request)
278
- if (requestBody.length > 0) {
279
- const validation = validateBody(subscriptionProxyBodySchema, requestBody)
280
- if (!validation.ok) return validation.response
281
- const payload = validation.value as SubscriptionProxyBody
282
- if (payload.webhook?.url !== undefined) {
283
- targetWebhookUrl =
284
- rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null
285
- payload.webhook.url = appendPathToUrl(
286
- ctx.publicUrl,
287
- `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
288
- )
289
- }
290
- rewriteSubscriptionBodyForBackend(
291
- payload as Record<string, unknown>,
292
- ctx.service,
293
- routingAdapter
294
- )
295
- requestBody = new TextEncoder().encode(JSON.stringify(payload))
272
+ async function rewriteSubscriptionRequestBody(
273
+ request: IRequest,
274
+ ctx: TenantContext,
275
+ subscriptionId: string,
276
+ routingAdapter: DurableStreamsRoutingAdapter
277
+ ): Promise<
278
+ | {
279
+ ok: true
280
+ body: Uint8Array
281
+ targetWebhookUrl: string | null
296
282
  }
283
+ | { ok: false; response: Response }
284
+ > {
285
+ const body = await readRequestBody(request as Request)
286
+ if (body.length === 0) {
287
+ return { ok: true, body, targetWebhookUrl: null }
297
288
  }
298
289
 
299
- if (
300
- request.method.toUpperCase() === `DELETE` &&
301
- /\/streams\/.+$/.test(url.pathname)
302
- ) {
303
- requestUrl = rewriteSubscriptionStreamPathInUrl(
304
- url,
305
- ctx.service,
306
- routingAdapter
290
+ const validation = validateBody(subscriptionProxyBodySchema, body)
291
+ if (!validation.ok) return { ok: false, response: validation.response }
292
+
293
+ const payload = validation.value as SubscriptionProxyBody
294
+ let targetWebhookUrl: string | null = null
295
+ if (payload.webhook?.url !== undefined) {
296
+ targetWebhookUrl = rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null
297
+ payload.webhook.url = appendPathToUrl(
298
+ ctx.publicUrl,
299
+ `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
307
300
  )
308
301
  }
309
302
 
303
+ rewriteSubscriptionBodyForBackend(
304
+ payload as Record<string, unknown>,
305
+ ctx.service,
306
+ routingAdapter
307
+ )
308
+
309
+ return {
310
+ ok: true,
311
+ body: new TextEncoder().encode(JSON.stringify(payload)),
312
+ targetWebhookUrl,
313
+ }
314
+ }
315
+
316
+ async function forwardSubscriptionRequest(
317
+ request: IRequest,
318
+ ctx: TenantContext,
319
+ routingAdapter: DurableStreamsRoutingAdapter,
320
+ opts: {
321
+ body?: Uint8Array
322
+ requestUrl?: string
323
+ bearerMode?: `overwrite` | `if-missing`
324
+ } = {}
325
+ ): Promise<{ upstream: Response; response: Response }> {
310
326
  const upstream = await forwardToDurableStreams(
311
327
  ctx,
312
328
  request,
313
- requestBody,
314
- `stream-meta`,
315
- requestUrl
329
+ opts.body,
330
+ `control`,
331
+ opts.requestUrl,
332
+ opts.bearerMode ?? `overwrite`
316
333
  )
317
334
  let responseBytes: Uint8Array = upstream.body
318
335
  ? new Uint8Array(await upstream.arrayBuffer())
@@ -323,42 +340,196 @@ async function subscriptionProxy(
323
340
  ctx.service,
324
341
  routingAdapter
325
342
  )
326
- const response = responseFromUpstream(upstream, responseBytes)
343
+ return {
344
+ upstream,
345
+ response: responseFromUpstream(upstream, responseBytes),
346
+ }
347
+ }
327
348
 
328
- if (!upstream.ok) return response
349
+ async function upsertSubscriptionWebhook(
350
+ ctx: TenantContext,
351
+ subscriptionId: string,
352
+ targetWebhookUrl: string
353
+ ): Promise<void> {
354
+ await ctx.pgDb
355
+ .insert(subscriptionWebhooks)
356
+ .values({
357
+ tenantId: ctx.service,
358
+ subscriptionId,
359
+ webhookUrl: targetWebhookUrl,
360
+ })
361
+ .onConflictDoUpdate({
362
+ target: [
363
+ subscriptionWebhooks.tenantId,
364
+ subscriptionWebhooks.subscriptionId,
365
+ ],
366
+ set: { webhookUrl: targetWebhookUrl },
367
+ })
368
+ }
329
369
 
330
- if (
331
- request.method.toUpperCase() === `DELETE` &&
332
- isSubscriptionBasePath(url.pathname)
333
- ) {
334
- await ctx.pgDb
335
- .delete(subscriptionWebhooks)
336
- .where(
337
- and(
338
- eq(subscriptionWebhooks.tenantId, ctx.service),
339
- eq(subscriptionWebhooks.subscriptionId, subscriptionId)
340
- )
370
+ async function deleteSubscriptionWebhook(
371
+ ctx: TenantContext,
372
+ subscriptionId: string
373
+ ): Promise<void> {
374
+ await ctx.pgDb
375
+ .delete(subscriptionWebhooks)
376
+ .where(
377
+ and(
378
+ eq(subscriptionWebhooks.tenantId, ctx.service),
379
+ eq(subscriptionWebhooks.subscriptionId, subscriptionId)
341
380
  )
342
- } else if (targetWebhookUrl) {
343
- await ctx.pgDb
344
- .insert(subscriptionWebhooks)
345
- .values({
346
- tenantId: ctx.service,
347
- subscriptionId,
348
- webhookUrl: targetWebhookUrl,
349
- })
350
- .onConflictDoUpdate({
351
- target: [
352
- subscriptionWebhooks.tenantId,
353
- subscriptionWebhooks.subscriptionId,
354
- ],
355
- set: { webhookUrl: targetWebhookUrl },
356
- })
381
+ )
382
+ }
383
+
384
+ function rewriteSubscriptionStreamPathInUrl(
385
+ requestUrl: URL,
386
+ service: string,
387
+ routingAdapter: DurableStreamsRoutingAdapter,
388
+ streamPath: string
389
+ ): string {
390
+ const prefix = requestUrl.pathname.slice(
391
+ 0,
392
+ requestUrl.pathname.indexOf(`/streams/`) + `/streams/`.length
393
+ )
394
+ requestUrl.pathname = `${prefix}${encodeURIComponent(
395
+ routingAdapter.toBackendStreamPath(service, streamPath)
396
+ )}`
397
+ return requestUrl.toString()
398
+ }
399
+
400
+ async function putSubscriptionBase(
401
+ request: IRequest,
402
+ ctx: TenantContext
403
+ ): Promise<Response> {
404
+ const subscriptionId = routeParam(request, `subscriptionId`)
405
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
406
+ const rewrite = await rewriteSubscriptionRequestBody(
407
+ request,
408
+ ctx,
409
+ subscriptionId,
410
+ routingAdapter
411
+ )
412
+ if (!rewrite.ok) return rewrite.response
413
+
414
+ const { upstream, response } = await forwardSubscriptionRequest(
415
+ request,
416
+ ctx,
417
+ routingAdapter,
418
+ { body: rewrite.body }
419
+ )
420
+ if (upstream.ok && rewrite.targetWebhookUrl) {
421
+ await upsertSubscriptionWebhook(
422
+ ctx,
423
+ subscriptionId,
424
+ rewrite.targetWebhookUrl
425
+ )
357
426
  }
427
+ return response
428
+ }
429
+
430
+ async function getSubscriptionBase(
431
+ request: IRequest,
432
+ ctx: TenantContext
433
+ ): Promise<Response> {
434
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
435
+ return (await forwardSubscriptionRequest(request, ctx, routingAdapter))
436
+ .response
437
+ }
358
438
 
439
+ async function deleteSubscriptionBase(
440
+ request: IRequest,
441
+ ctx: TenantContext
442
+ ): Promise<Response> {
443
+ const subscriptionId = routeParam(request, `subscriptionId`)
444
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
445
+ const { upstream, response } = await forwardSubscriptionRequest(
446
+ request,
447
+ ctx,
448
+ routingAdapter
449
+ )
450
+ if (upstream.ok) {
451
+ await deleteSubscriptionWebhook(ctx, subscriptionId)
452
+ }
359
453
  return response
360
454
  }
361
455
 
456
+ async function postSubscriptionStreams(
457
+ request: IRequest,
458
+ ctx: TenantContext
459
+ ): Promise<Response> {
460
+ const subscriptionId = routeParam(request, `subscriptionId`)
461
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
462
+ const rewrite = await rewriteSubscriptionRequestBody(
463
+ request,
464
+ ctx,
465
+ subscriptionId,
466
+ routingAdapter
467
+ )
468
+ if (!rewrite.ok) return rewrite.response
469
+
470
+ return (
471
+ await forwardSubscriptionRequest(request, ctx, routingAdapter, {
472
+ body: rewrite.body,
473
+ })
474
+ ).response
475
+ }
476
+
477
+ async function deleteSubscriptionStream(
478
+ request: IRequest,
479
+ ctx: TenantContext
480
+ ): Promise<Response> {
481
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
482
+ const requestUrl = rewriteSubscriptionStreamPathInUrl(
483
+ new URL(request.url),
484
+ ctx.service,
485
+ routingAdapter,
486
+ routeParam(request, `streamPath`)
487
+ )
488
+ return (
489
+ await forwardSubscriptionRequest(request, ctx, routingAdapter, {
490
+ requestUrl,
491
+ })
492
+ ).response
493
+ }
494
+
495
+ function subscriptionAction(action: SubscriptionControlAction) {
496
+ return async (request: IRequest, ctx: TenantContext): Promise<Response> => {
497
+ const subscriptionId = routeParam(request, `subscriptionId`)
498
+ const routingAdapter = subscriptionRoutingAdapter(ctx)
499
+ const rewrite = await rewriteSubscriptionRequestBody(
500
+ request,
501
+ ctx,
502
+ subscriptionId,
503
+ routingAdapter
504
+ )
505
+ if (!rewrite.ok) return rewrite.response
506
+
507
+ const bearerMode =
508
+ action === `ack` || action === `release` || action === `callback`
509
+ ? `if-missing`
510
+ : `overwrite`
511
+ return (
512
+ await forwardSubscriptionRequest(request, ctx, routingAdapter, {
513
+ body: rewrite.body,
514
+ bearerMode,
515
+ })
516
+ ).response
517
+ }
518
+ }
519
+
520
+ async function controlPassThrough(
521
+ request: IRequest,
522
+ ctx: TenantContext
523
+ ): Promise<Response> {
524
+ const upstream = await forwardToDurableStreams(
525
+ ctx,
526
+ request,
527
+ undefined,
528
+ `control`
529
+ )
530
+ return responseFromUpstream(upstream)
531
+ }
532
+
362
533
  async function streamAppend(
363
534
  request: IRequest,
364
535
  ctx: TenantContext
@@ -391,13 +562,12 @@ async function proxyPassThrough(
391
562
  const upstream = await forwardToDurableStreams(ctx, request)
392
563
  const streamPath = new URL(request.url).pathname
393
564
  const method = request.method.toUpperCase()
394
- const isControlPath = streamPath.startsWith(`/v1/stream-meta/`)
395
565
  const endTrackedRead =
396
- method === `GET` && !isControlPath
566
+ method === `GET`
397
567
  ? await ctx.entityBridgeManager.beginClientRead(streamPath)
398
568
  : null
399
569
  try {
400
- if (method === `HEAD` && !isControlPath) {
570
+ if (method === `HEAD`) {
401
571
  await ctx.entityBridgeManager.touchByStreamPath(streamPath)
402
572
  }
403
573
  return responseFromUpstream(upstream)