@durable-streams/server 0.3.2 → 0.3.3

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.
@@ -0,0 +1,882 @@
1
+ import { createHash } from "node:crypto"
2
+ import { isIP } from "node:net"
3
+ import {
4
+ generateCallbackToken,
5
+ generateWakeId,
6
+ getWebhookJwks,
7
+ getWebhookSigningKeyId,
8
+ signWebhookPayload,
9
+ validateCallbackToken,
10
+ } from "./crypto"
11
+ import { globMatch } from "./glob"
12
+ import { serverLog } from "./log"
13
+ import type {
14
+ SubscriptionCallbackRequest,
15
+ SubscriptionCreateInput,
16
+ SubscriptionError,
17
+ SubscriptionRecord,
18
+ SubscriptionStreamInfo,
19
+ SubscriptionStreamLink,
20
+ } from "./subscription-types"
21
+
22
+ const DEFAULT_LEASE_TTL_MS = 30_000
23
+ const MIN_LEASE_TTL_MS = 1_000
24
+ const MAX_LEASE_TTL_MS: number = 10 * 60_000
25
+ const ZERO_OFFSET = `0000000000000000_0000000000000000`
26
+ const BEFORE_FIRST_OFFSET = `-1`
27
+ const MAX_RETRY_DELAY_MS = 60_000
28
+
29
+ interface StreamLike {
30
+ currentOffset: string
31
+ softDeleted?: boolean
32
+ }
33
+
34
+ interface SubscriptionStreamStore {
35
+ has: (path: string) => boolean
36
+ get: (path: string) => StreamLike | undefined
37
+ list: () => Array<string>
38
+ append: (path: string, data: Uint8Array) => unknown
39
+ }
40
+
41
+ function compareOffsets(a: string, b: string): number {
42
+ if (a < b) return -1
43
+ if (a > b) return 1
44
+ return 0
45
+ }
46
+
47
+ function normalizeRelativePath(path: string): string {
48
+ return path.replace(/^\/+/, ``).replace(/\/+$/, ``)
49
+ }
50
+
51
+ function toAbsoluteStreamPath(streamPath: string): string {
52
+ return `/v1/stream/${normalizeRelativePath(streamPath)}`
53
+ }
54
+
55
+ function toStreamRelativePath(absolutePath: string): string | null {
56
+ const streamRoot = `/v1/stream/`
57
+ if (!absolutePath.startsWith(streamRoot)) return null
58
+
59
+ const path = absolutePath.slice(streamRoot.length)
60
+ if (path === `__ds` || path.startsWith(`__ds/`)) return null
61
+ return path.length > 0 ? path : null
62
+ }
63
+
64
+ function stableConfigHash(input: SubscriptionCreateInput): string {
65
+ const canonical = {
66
+ type: input.type,
67
+ pattern: input.pattern,
68
+ streams: [...new Set(input.streams)].sort(),
69
+ webhook: input.webhook ? { url: input.webhook.url } : undefined,
70
+ wake_stream: input.wake_stream,
71
+ lease_ttl_ms: input.lease_ttl_ms,
72
+ description: input.description,
73
+ }
74
+ return createHash(`sha256`).update(JSON.stringify(canonical)).digest(`hex`)
75
+ }
76
+
77
+ function isPrivateOrLinkLocalIpv4(host: string): boolean {
78
+ const parts = host.split(`.`).map((part) => Number(part))
79
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
80
+ return false
81
+ }
82
+ const [a, b] = parts as [number, number, number, number]
83
+ return (
84
+ a === 10 ||
85
+ a === 127 ||
86
+ a === 0 ||
87
+ (a === 172 && b >= 16 && b <= 31) ||
88
+ (a === 192 && b === 168) ||
89
+ (a === 169 && b === 254)
90
+ )
91
+ }
92
+
93
+ function isLocalDevHost(host: string): boolean {
94
+ return host === `localhost` || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
95
+ }
96
+
97
+ export function validateWebhookUrl(
98
+ rawUrl: string
99
+ ): { ok: true } | { ok: false; message: string } {
100
+ let url: URL
101
+ try {
102
+ url = new URL(rawUrl)
103
+ } catch {
104
+ return { ok: false, message: `webhook.url must be a valid URL` }
105
+ }
106
+
107
+ const host = url.hostname.toLowerCase()
108
+ if (url.protocol === `http:`) {
109
+ if (isLocalDevHost(host)) return { ok: true }
110
+ return {
111
+ ok: false,
112
+ message: `http webhook URLs are only allowed for localhost or 127.0.0.x`,
113
+ }
114
+ }
115
+
116
+ if (url.protocol !== `https:`) {
117
+ return { ok: false, message: `webhook.url must use https` }
118
+ }
119
+
120
+ if (host === `localhost`) {
121
+ return {
122
+ ok: false,
123
+ message: `localhost webhook URLs must use http for dev`,
124
+ }
125
+ }
126
+
127
+ if (isIP(host) === 4 && isPrivateOrLinkLocalIpv4(host)) {
128
+ return {
129
+ ok: false,
130
+ message: `webhook.url must not target private or link-local hosts`,
131
+ }
132
+ }
133
+
134
+ if (isIP(host) === 6) {
135
+ return {
136
+ ok: false,
137
+ message: `IPv6 webhook hosts are not accepted by the reference server`,
138
+ }
139
+ }
140
+
141
+ return { ok: true }
142
+ }
143
+
144
+ export class SubscriptionManager {
145
+ private readonly subscriptions = new Map<string, SubscriptionRecord>()
146
+ private readonly streamStore: SubscriptionStreamStore
147
+ private readonly callbackBaseUrl: string
148
+ private readonly webhooksEnabled: boolean
149
+ private isShuttingDown = false
150
+
151
+ constructor(opts: {
152
+ callbackBaseUrl: string
153
+ streamStore: SubscriptionStreamStore
154
+ webhooksEnabled?: boolean
155
+ }) {
156
+ this.callbackBaseUrl = opts.callbackBaseUrl
157
+ this.streamStore = opts.streamStore
158
+ this.webhooksEnabled = opts.webhooksEnabled ?? true
159
+ }
160
+
161
+ createOrConfirm(
162
+ id: string,
163
+ input: SubscriptionCreateInput
164
+ ):
165
+ | { subscription: SubscriptionRecord; created: boolean }
166
+ | { error: SubscriptionError } {
167
+ const configHash = stableConfigHash(input)
168
+ const existing = this.subscriptions.get(id)
169
+ if (existing) {
170
+ if (existing.config_hash !== configHash) {
171
+ return {
172
+ error: {
173
+ code: `SUBSCRIPTION_ALREADY_EXISTS`,
174
+ message: `Subscription already exists with different configuration`,
175
+ },
176
+ }
177
+ }
178
+ return { subscription: existing, created: false }
179
+ }
180
+
181
+ if (input.type === `webhook`) {
182
+ if (!this.webhooksEnabled) {
183
+ return {
184
+ error: {
185
+ code: `INVALID_REQUEST`,
186
+ message: `webhook subscriptions are not enabled on this server`,
187
+ },
188
+ }
189
+ }
190
+ if (!input.webhook) {
191
+ return {
192
+ error: {
193
+ code: `INVALID_REQUEST`,
194
+ message: `webhook subscriptions require webhook.url`,
195
+ },
196
+ }
197
+ }
198
+ const validation = validateWebhookUrl(input.webhook.url)
199
+ if (!validation.ok) {
200
+ return {
201
+ error: { code: `WEBHOOK_URL_REJECTED`, message: validation.message },
202
+ }
203
+ }
204
+ }
205
+
206
+ if (input.type === `pull-wake` && !input.wake_stream) {
207
+ return {
208
+ error: {
209
+ code: `INVALID_REQUEST`,
210
+ message: `pull-wake subscriptions require wake_stream`,
211
+ },
212
+ }
213
+ }
214
+
215
+ const subscription: SubscriptionRecord = {
216
+ id,
217
+ type: input.type,
218
+ pattern: input.pattern,
219
+ webhook: input.webhook ? { url: input.webhook.url } : undefined,
220
+ wake_stream: input.wake_stream,
221
+ lease_ttl_ms: input.lease_ttl_ms,
222
+ description: input.description,
223
+ created_at: new Date().toISOString(),
224
+ status: `active`,
225
+ config_hash: configHash,
226
+ streams: new Map(),
227
+ generation: 0,
228
+ wake_id: null,
229
+ wake_snapshot: new Map(),
230
+ token: null,
231
+ holder: null,
232
+ lease_timer: null,
233
+ retry_count: 0,
234
+ retry_timer: null,
235
+ next_attempt_at: null,
236
+ }
237
+
238
+ for (const stream of input.streams) {
239
+ this.linkStream(
240
+ subscription,
241
+ stream,
242
+ `explicit`,
243
+ this.getTailOffset(stream)
244
+ )
245
+ }
246
+
247
+ if (input.pattern) {
248
+ for (const stream of this.listStreams()) {
249
+ if (globMatch(input.pattern, stream)) {
250
+ this.linkStream(
251
+ subscription,
252
+ stream,
253
+ `glob`,
254
+ this.getTailOffset(stream)
255
+ )
256
+ }
257
+ }
258
+ }
259
+
260
+ this.subscriptions.set(id, subscription)
261
+ return { subscription, created: true }
262
+ }
263
+
264
+ get(id: string): SubscriptionRecord | undefined {
265
+ return this.subscriptions.get(id)
266
+ }
267
+
268
+ delete(id: string): boolean {
269
+ const subscription = this.subscriptions.get(id)
270
+ if (!subscription) return false
271
+ this.clearLease(subscription)
272
+ if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
273
+ this.subscriptions.delete(id)
274
+ return true
275
+ }
276
+
277
+ addExplicitStreams(id: string, streams: Array<string>): boolean {
278
+ const subscription = this.get(id)
279
+ if (!subscription) return false
280
+ for (const stream of streams) {
281
+ this.linkStream(
282
+ subscription,
283
+ stream,
284
+ `explicit`,
285
+ this.getTailOffset(stream)
286
+ )
287
+ }
288
+ return true
289
+ }
290
+
291
+ removeExplicitStream(id: string, streamPath: string): boolean {
292
+ const subscription = this.get(id)
293
+ if (!subscription) return false
294
+ const normalized = normalizeRelativePath(streamPath)
295
+ const link = subscription.streams.get(normalized)
296
+ if (!link) return true
297
+ link.link_types.delete(`explicit`)
298
+ if (link.link_types.size === 0) {
299
+ subscription.streams.delete(normalized)
300
+ }
301
+ return true
302
+ }
303
+
304
+ async onStreamAppend(absolutePath: string): Promise<void> {
305
+ if (this.isShuttingDown) return
306
+ for (const subscription of this.subscriptions.values()) {
307
+ const relative = toStreamRelativePath(absolutePath)
308
+ if (!relative) continue
309
+ if (subscription.pattern && globMatch(subscription.pattern, relative)) {
310
+ const existing = subscription.streams.get(relative)
311
+ this.linkStream(
312
+ subscription,
313
+ relative,
314
+ `glob`,
315
+ existing?.acked_offset ?? BEFORE_FIRST_OFFSET
316
+ )
317
+ }
318
+ if (subscription.streams.has(relative)) {
319
+ await this.maybeWake(subscription, relative)
320
+ }
321
+ }
322
+ }
323
+
324
+ onStreamDeleted(absolutePath: string): void {
325
+ for (const subscription of this.subscriptions.values()) {
326
+ const relative = toStreamRelativePath(absolutePath)
327
+ if (relative) subscription.streams.delete(relative)
328
+ }
329
+ }
330
+
331
+ async handleWebhookCallback(
332
+ id: string,
333
+ token: string,
334
+ request: SubscriptionCallbackRequest
335
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
336
+ const subscription = this.get(id)
337
+ if (!subscription) {
338
+ return this.errorResponse(
339
+ 404,
340
+ `SUBSCRIPTION_NOT_FOUND`,
341
+ `Subscription not found`
342
+ )
343
+ }
344
+ const fenced = this.validateWakeToken(subscription, token, request)
345
+ if (fenced) return fenced
346
+
347
+ const ackError = this.applyAcks(subscription, request)
348
+ if (ackError) return ackError
349
+
350
+ this.extendLease(subscription)
351
+ let nextWake = false
352
+ if (request.done === true) {
353
+ this.clearLease(subscription)
354
+ subscription.token = null
355
+ subscription.holder = null
356
+ subscription.wake_id = null
357
+ subscription.wake_snapshot.clear()
358
+ nextWake = await this.triggerNextWakeIfPending(subscription)
359
+ }
360
+
361
+ return { status: 200, body: { ok: true, next_wake: nextWake } }
362
+ }
363
+
364
+ async claim(
365
+ id: string,
366
+ worker: string
367
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
368
+ const subscription = this.get(id)
369
+ if (!subscription) {
370
+ return this.errorResponse(
371
+ 404,
372
+ `SUBSCRIPTION_NOT_FOUND`,
373
+ `Subscription not found`
374
+ )
375
+ }
376
+ if (subscription.type !== `pull-wake`) {
377
+ return this.errorResponse(
378
+ 400,
379
+ `INVALID_REQUEST`,
380
+ `Subscription is not pull-wake`
381
+ )
382
+ }
383
+ if (subscription.holder) {
384
+ return {
385
+ status: 409,
386
+ body: {
387
+ error: {
388
+ code: `ALREADY_CLAIMED`,
389
+ current_holder: subscription.holder,
390
+ generation: subscription.generation,
391
+ },
392
+ },
393
+ }
394
+ }
395
+ if (!this.hasPendingWork(subscription)) {
396
+ return this.errorResponse(
397
+ 409,
398
+ `NO_PENDING_WORK`,
399
+ `Subscription has no pending work`
400
+ )
401
+ }
402
+ if (!subscription.wake_id) {
403
+ await this.createWake(subscription, this.firstPendingStream(subscription))
404
+ }
405
+
406
+ subscription.holder = worker
407
+ subscription.token = generateCallbackToken(
408
+ this.tokenSubject(subscription),
409
+ subscription.generation
410
+ )
411
+ this.extendLease(subscription)
412
+
413
+ return {
414
+ status: 200,
415
+ body: {
416
+ wake_id: subscription.wake_id,
417
+ generation: subscription.generation,
418
+ token: subscription.token,
419
+ streams: this.streamInfos(subscription),
420
+ lease_ttl_ms: subscription.lease_ttl_ms,
421
+ },
422
+ }
423
+ }
424
+
425
+ async ack(
426
+ id: string,
427
+ token: string,
428
+ request: SubscriptionCallbackRequest
429
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
430
+ const subscription = this.get(id)
431
+ if (!subscription) {
432
+ return this.errorResponse(
433
+ 404,
434
+ `SUBSCRIPTION_NOT_FOUND`,
435
+ `Subscription not found`
436
+ )
437
+ }
438
+ if (subscription.type !== `pull-wake`) {
439
+ return this.errorResponse(
440
+ 400,
441
+ `INVALID_REQUEST`,
442
+ `Subscription is not pull-wake`
443
+ )
444
+ }
445
+ const fenced = this.validateWakeToken(subscription, token, request)
446
+ if (fenced) return fenced
447
+
448
+ const ackError = this.applyAcks(subscription, request)
449
+ if (ackError) return ackError
450
+
451
+ this.extendLease(subscription)
452
+ let nextWake = false
453
+ if (request.done === true) {
454
+ this.clearLease(subscription)
455
+ subscription.token = null
456
+ subscription.holder = null
457
+ subscription.wake_id = null
458
+ subscription.wake_snapshot.clear()
459
+ nextWake = await this.triggerNextWakeIfPending(subscription)
460
+ }
461
+ return { status: 200, body: { ok: true, next_wake: nextWake } }
462
+ }
463
+
464
+ async release(
465
+ id: string,
466
+ token: string,
467
+ request: SubscriptionCallbackRequest
468
+ ): Promise<{ status: number; body?: Record<string, unknown> }> {
469
+ const subscription = this.get(id)
470
+ if (!subscription) {
471
+ return this.errorResponse(
472
+ 404,
473
+ `SUBSCRIPTION_NOT_FOUND`,
474
+ `Subscription not found`
475
+ )
476
+ }
477
+ if (subscription.type !== `pull-wake`) {
478
+ return this.errorResponse(
479
+ 400,
480
+ `INVALID_REQUEST`,
481
+ `Subscription is not pull-wake`
482
+ )
483
+ }
484
+ const fenced = this.validateWakeToken(subscription, token, request)
485
+ if (fenced) return fenced
486
+
487
+ this.clearLease(subscription)
488
+ subscription.token = null
489
+ subscription.holder = null
490
+ subscription.wake_id = null
491
+ subscription.wake_snapshot.clear()
492
+ await this.triggerNextWakeIfPending(subscription)
493
+ return { status: 204 }
494
+ }
495
+
496
+ serialize(subscription: SubscriptionRecord): Record<string, unknown> {
497
+ return {
498
+ id: subscription.id,
499
+ subscription_id: subscription.id,
500
+ type: subscription.type,
501
+ pattern: subscription.pattern,
502
+ streams: this.streamInfos(subscription).map((stream) => ({
503
+ path: stream.path,
504
+ link_type: stream.link_type,
505
+ acked_offset: stream.acked_offset,
506
+ })),
507
+ webhook: subscription.webhook
508
+ ? {
509
+ url: subscription.webhook.url,
510
+ signing: this.webhookSigningMetadata(),
511
+ }
512
+ : undefined,
513
+ wake_stream: subscription.wake_stream,
514
+ lease_ttl_ms: subscription.lease_ttl_ms,
515
+ created_at: subscription.created_at,
516
+ status: subscription.status,
517
+ description: subscription.description,
518
+ }
519
+ }
520
+
521
+ getWebhookJwks(): ReturnType<typeof getWebhookJwks> {
522
+ return getWebhookJwks()
523
+ }
524
+
525
+ shutdown(): void {
526
+ this.isShuttingDown = true
527
+ for (const subscription of this.subscriptions.values()) {
528
+ this.clearLease(subscription)
529
+ if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
530
+ }
531
+ this.subscriptions.clear()
532
+ }
533
+
534
+ private async maybeWake(
535
+ subscription: SubscriptionRecord,
536
+ triggeredBy: string
537
+ ): Promise<void> {
538
+ if (subscription.wake_id || subscription.holder) return
539
+ if (!this.hasPendingWork(subscription)) return
540
+ await this.createWake(subscription, triggeredBy)
541
+ }
542
+
543
+ private async createWake(
544
+ subscription: SubscriptionRecord,
545
+ triggeredBy: string
546
+ ): Promise<void> {
547
+ subscription.generation++
548
+ subscription.wake_id = generateWakeId()
549
+ subscription.wake_snapshot = new Map(
550
+ this.streamInfos(subscription).map((stream) => [
551
+ stream.path,
552
+ stream.tail_offset,
553
+ ])
554
+ )
555
+
556
+ if (subscription.type === `webhook`) {
557
+ subscription.token = generateCallbackToken(
558
+ this.tokenSubject(subscription),
559
+ subscription.generation
560
+ )
561
+ this.extendLease(subscription)
562
+ void this.deliverWebhook(subscription, [triggeredBy])
563
+ return
564
+ }
565
+
566
+ await this.writePullWakeEvent(subscription, triggeredBy)
567
+ }
568
+
569
+ private async deliverWebhook(
570
+ subscription: SubscriptionRecord,
571
+ triggeredBy: Array<string>
572
+ ): Promise<void> {
573
+ if (!subscription.webhook || !subscription.wake_id || !subscription.token)
574
+ return
575
+
576
+ const body = JSON.stringify({
577
+ subscription_id: subscription.id,
578
+ wake_id: subscription.wake_id,
579
+ generation: subscription.generation,
580
+ streams: this.streamInfos(subscription),
581
+ callback_url: this.subscriptionActionUrl(subscription, `callback`),
582
+ callback_token: subscription.token,
583
+ })
584
+
585
+ const headers = {
586
+ "content-type": `application/json`,
587
+ "webhook-signature": signWebhookPayload(body),
588
+ }
589
+
590
+ try {
591
+ const response = await fetch(subscription.webhook.url, {
592
+ method: `POST`,
593
+ headers,
594
+ body,
595
+ })
596
+ if (!response.ok) {
597
+ this.scheduleWebhookRetry(subscription, triggeredBy)
598
+ return
599
+ }
600
+
601
+ subscription.status = `active`
602
+ subscription.retry_count = 0
603
+ subscription.next_attempt_at = null
604
+
605
+ let parsed: { done?: boolean } | null = null
606
+ try {
607
+ parsed = (await response.json()) as { done?: boolean }
608
+ } catch {
609
+ parsed = null
610
+ }
611
+
612
+ if (parsed?.done === true) {
613
+ this.autoAckWakeSnapshot(subscription)
614
+ this.clearLease(subscription)
615
+ subscription.token = null
616
+ subscription.holder = null
617
+ subscription.wake_id = null
618
+ subscription.wake_snapshot.clear()
619
+ await this.triggerNextWakeIfPending(subscription)
620
+ }
621
+ } catch (err) {
622
+ serverLog.warn(`[subscriptions] webhook delivery failed:`, err)
623
+ this.scheduleWebhookRetry(subscription, triggeredBy)
624
+ }
625
+ }
626
+
627
+ private scheduleWebhookRetry(
628
+ subscription: SubscriptionRecord,
629
+ triggeredBy: Array<string>
630
+ ): void {
631
+ if (this.isShuttingDown) return
632
+ subscription.retry_count++
633
+ const baseDelay = Math.min(
634
+ 1000 * Math.pow(2, Math.max(0, subscription.retry_count - 1)),
635
+ MAX_RETRY_DELAY_MS
636
+ )
637
+ const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1)
638
+ const delay = Math.max(0, Math.round(baseDelay + jitter))
639
+ subscription.status = `failed`
640
+ subscription.next_attempt_at = Date.now() + delay
641
+ if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
642
+ subscription.retry_timer = setTimeout(() => {
643
+ subscription.retry_timer = null
644
+ void this.deliverWebhook(subscription, triggeredBy)
645
+ }, delay)
646
+ }
647
+
648
+ private async writePullWakeEvent(
649
+ subscription: SubscriptionRecord,
650
+ streamPath: string
651
+ ): Promise<void> {
652
+ if (!subscription.wake_stream) return
653
+ const wakeStream = toAbsoluteStreamPath(subscription.wake_stream)
654
+ if (!this.streamStore.has(wakeStream)) {
655
+ serverLog.warn(
656
+ `[subscriptions] wake stream does not exist: ${wakeStream}`
657
+ )
658
+ return
659
+ }
660
+ const event = {
661
+ type: `wake`,
662
+ subscription_id: subscription.id,
663
+ stream: streamPath,
664
+ generation: subscription.generation,
665
+ ts: Date.now(),
666
+ }
667
+ await Promise.resolve(
668
+ this.streamStore.append(
669
+ wakeStream,
670
+ new TextEncoder().encode(JSON.stringify(event))
671
+ )
672
+ )
673
+ }
674
+
675
+ private autoAckWakeSnapshot(subscription: SubscriptionRecord): void {
676
+ for (const [stream, tail] of subscription.wake_snapshot) {
677
+ const link = subscription.streams.get(stream)
678
+ if (link) link.acked_offset = tail
679
+ }
680
+ }
681
+
682
+ private applyAcks(
683
+ subscription: SubscriptionRecord,
684
+ request: SubscriptionCallbackRequest
685
+ ): { status: number; body: Record<string, unknown> } | null {
686
+ if (!request.acks) return null
687
+ for (const ack of request.acks) {
688
+ const stream = normalizeRelativePath(ack.stream ?? ack.path ?? ``)
689
+ const link = subscription.streams.get(stream)
690
+ if (!stream || !link) {
691
+ return this.errorResponse(
692
+ 409,
693
+ `INVALID_OFFSET`,
694
+ `Ack references an unknown subscription stream`
695
+ )
696
+ }
697
+ if (ack.offset === BEFORE_FIRST_OFFSET) {
698
+ return this.errorResponse(
699
+ 409,
700
+ `INVALID_OFFSET`,
701
+ `Ack offset must not be -1`
702
+ )
703
+ }
704
+ if (compareOffsets(ack.offset, link.acked_offset) < 0) {
705
+ return this.errorResponse(
706
+ 409,
707
+ `INVALID_OFFSET`,
708
+ `Ack offset regresses the committed cursor`
709
+ )
710
+ }
711
+ if (compareOffsets(ack.offset, this.getTailOffset(stream)) > 0) {
712
+ return this.errorResponse(
713
+ 409,
714
+ `INVALID_OFFSET`,
715
+ `Ack offset is beyond stream tail`
716
+ )
717
+ }
718
+ }
719
+ for (const ack of request.acks) {
720
+ const stream = normalizeRelativePath(ack.stream ?? ack.path ?? ``)
721
+ subscription.streams.get(stream)!.acked_offset = ack.offset
722
+ }
723
+ return null
724
+ }
725
+
726
+ private validateWakeToken(
727
+ subscription: SubscriptionRecord,
728
+ token: string,
729
+ request: SubscriptionCallbackRequest
730
+ ): { status: number; body: Record<string, unknown> } | null {
731
+ const tokenResult = validateCallbackToken(
732
+ token,
733
+ this.tokenSubject(subscription)
734
+ )
735
+ if (!tokenResult.valid) {
736
+ return this.errorResponse(
737
+ 401,
738
+ tokenResult.code,
739
+ tokenResult.code === `TOKEN_EXPIRED` ? `Token expired` : `Token invalid`
740
+ )
741
+ }
742
+ if (
743
+ tokenResult.epoch !== subscription.generation ||
744
+ request.generation !== subscription.generation ||
745
+ request.wake_id !== subscription.wake_id
746
+ ) {
747
+ return this.errorResponse(409, `FENCED`, `Wake generation is stale`)
748
+ }
749
+ return null
750
+ }
751
+
752
+ private async triggerNextWakeIfPending(
753
+ subscription: SubscriptionRecord
754
+ ): Promise<boolean> {
755
+ if (!this.hasPendingWork(subscription)) return false
756
+ await this.createWake(subscription, this.firstPendingStream(subscription))
757
+ return true
758
+ }
759
+
760
+ private hasPendingWork(subscription: SubscriptionRecord): boolean {
761
+ return this.streamInfos(subscription).some((stream) => stream.has_pending)
762
+ }
763
+
764
+ private firstPendingStream(subscription: SubscriptionRecord): string {
765
+ return (
766
+ this.streamInfos(subscription).find((stream) => stream.has_pending)
767
+ ?.path ?? ``
768
+ )
769
+ }
770
+
771
+ private streamInfos(
772
+ subscription: SubscriptionRecord
773
+ ): Array<SubscriptionStreamInfo> {
774
+ return Array.from(subscription.streams.values()).map((link) => {
775
+ const tail = this.getTailOffset(link.path)
776
+ return {
777
+ path: link.path,
778
+ link_type: link.link_types.has(`explicit`) ? `explicit` : `glob`,
779
+ acked_offset: link.acked_offset,
780
+ tail_offset: tail,
781
+ has_pending: compareOffsets(tail, link.acked_offset) > 0,
782
+ }
783
+ })
784
+ }
785
+
786
+ private linkStream(
787
+ subscription: SubscriptionRecord,
788
+ streamPath: string,
789
+ linkType: `glob` | `explicit`,
790
+ ackedOffset: string
791
+ ): SubscriptionStreamLink {
792
+ const normalized = normalizeRelativePath(streamPath)
793
+ const existing = subscription.streams.get(normalized)
794
+ if (existing) {
795
+ existing.link_types.add(linkType)
796
+ return existing
797
+ }
798
+ const link: SubscriptionStreamLink = {
799
+ path: normalized,
800
+ link_types: new Set([linkType]),
801
+ acked_offset: ackedOffset,
802
+ }
803
+ subscription.streams.set(normalized, link)
804
+ return link
805
+ }
806
+
807
+ private listStreams(): Array<string> {
808
+ return this.streamStore
809
+ .list()
810
+ .map((path) => toStreamRelativePath(path))
811
+ .filter((path): path is string => path !== null)
812
+ }
813
+
814
+ private getTailOffset(streamPath: string): string {
815
+ return (
816
+ this.streamStore.get(toAbsoluteStreamPath(streamPath))?.currentOffset ??
817
+ ZERO_OFFSET
818
+ )
819
+ }
820
+
821
+ private subscriptionActionUrl(
822
+ subscription: SubscriptionRecord,
823
+ action: string
824
+ ): string {
825
+ const url = new URL(
826
+ `/v1/stream/__ds/subscriptions/${encodeURIComponent(subscription.id)}/${action}`,
827
+ this.callbackBaseUrl
828
+ )
829
+ return url.toString()
830
+ }
831
+
832
+ private webhookJwksUrl(): string {
833
+ const url = new URL(`/v1/stream/__ds/jwks.json`, this.callbackBaseUrl)
834
+ return url.toString()
835
+ }
836
+
837
+ private webhookSigningMetadata(): Record<string, string> {
838
+ return {
839
+ alg: `ed25519`,
840
+ kid: getWebhookSigningKeyId(),
841
+ jwks_url: this.webhookJwksUrl(),
842
+ }
843
+ }
844
+
845
+ private extendLease(subscription: SubscriptionRecord): void {
846
+ this.clearLease(subscription)
847
+ subscription.lease_timer = setTimeout(() => {
848
+ subscription.lease_timer = null
849
+ subscription.holder = null
850
+ subscription.token = null
851
+ subscription.wake_id = null
852
+ subscription.wake_snapshot.clear()
853
+ void this.triggerNextWakeIfPending(subscription)
854
+ }, subscription.lease_ttl_ms)
855
+ }
856
+
857
+ private clearLease(subscription: SubscriptionRecord): void {
858
+ if (subscription.lease_timer) {
859
+ clearTimeout(subscription.lease_timer)
860
+ subscription.lease_timer = null
861
+ }
862
+ }
863
+
864
+ private tokenSubject(subscription: SubscriptionRecord): string {
865
+ return `subscription:${subscription.id}`
866
+ }
867
+
868
+ private errorResponse(
869
+ status: number,
870
+ code: SubscriptionError[`code`],
871
+ message: string
872
+ ): { status: number; body: Record<string, unknown> } {
873
+ return { status, body: { error: { code, message } } }
874
+ }
875
+ }
876
+
877
+ export {
878
+ DEFAULT_LEASE_TTL_MS,
879
+ MIN_LEASE_TTL_MS,
880
+ MAX_LEASE_TTL_MS,
881
+ normalizeRelativePath,
882
+ }