@spine-event-engine/auth 2.0.0-snapshot.25 → 2.0.0-snapshot.26

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.
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { clone, create, fromBinary, toBinary } from "@bufbuild/protobuf";
15
15
  import { SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS } from "@spine-event-engine/core/spi/subscription-lifecycle";
16
+ import { ReaderIdentities, snapshotTrustedReader, } from "@spine-event-engine/core/spi/trusted-reader";
16
17
  import { ActorContextSchema, TenantIdSchema } from "@spine-event-engine/proto";
17
18
  import { SubscriptionSchema, SubscriptionUpdateSchema, TopicSchema, } from "@spine-event-engine/proto/client";
18
19
  import { IncomingRequests } from "../request/index.js";
@@ -34,6 +35,7 @@ export class InMemorySubscriptionBindings {
34
35
  #limits;
35
36
  /**
36
37
  * Creates the in-memory binding store.
38
+ *
37
39
  * @param options Supplies identifiers, process limits, and disposal behavior.
38
40
  */
39
41
  constructor(options) {
@@ -43,6 +45,7 @@ export class InMemorySubscriptionBindings {
43
45
  }
44
46
  /**
45
47
  * Returns the number of retained private bindings for lifecycle observability.
48
+ *
46
49
  * @returns Returns the retained binding count.
47
50
  */
48
51
  get size() {
@@ -50,6 +53,7 @@ export class InMemorySubscriptionBindings {
50
53
  }
51
54
  /**
52
55
  * Removes already-expired logical bindings without a background timer.
56
+ *
53
57
  * @param nowMs Supplies the current time in milliseconds.
54
58
  * @returns Completes after expired envelopes are removed.
55
59
  */
@@ -61,14 +65,13 @@ export class InMemorySubscriptionBindings {
61
65
  }
62
66
  /**
63
67
  * Closes the store by aborting work and waiting for bounded cleanup.
64
- *
65
68
  * Cooperative callbacks allow cleanup to complete before this method resolves.
66
69
  * Non-cooperative raw callbacks cause an `AggregateError` after
67
70
  * `shutdownTimeoutMs`; their exactly-once disposal remains queued until they
68
71
  * settle.
72
+ * `AggregateError` when cleanup exceeds its shutdown limit.
69
73
  *
70
74
  * @returns Completes after bounded cooperative cleanup, or rejects with an
71
- * `AggregateError` when cleanup exceeds its shutdown limit.
72
75
  */
73
76
  async close() {
74
77
  this.#closed = true;
@@ -84,6 +87,7 @@ export class InMemorySubscriptionBindings {
84
87
  }
85
88
  /**
86
89
  * Creates an inactive binding from a trusted rewritten topic.
90
+ *
87
91
  * @param input Supplies the topic and expiry.
88
92
  * @returns Returns the retained public subscription wire.
89
93
  */
@@ -103,6 +107,7 @@ export class InMemorySubscriptionBindings {
103
107
  this.#bindings.set(id, {
104
108
  definition: wire.wire.bytes.slice(),
105
109
  context: clone(ActorContextSchema, topic.context),
110
+ reader: input.reader,
106
111
  expiresAtMs: input.whenExpires,
107
112
  state: "inactive",
108
113
  controller: new AbortController(),
@@ -120,6 +125,7 @@ export class InMemorySubscriptionBindings {
120
125
  }
121
126
  /**
122
127
  * Activates only an owned inactive binding and restores retry state after callback failure.
128
+ *
123
129
  * @param input Supplies ownership facts and the backend callback.
124
130
  * @returns Returns the ownership transition outcome.
125
131
  */
@@ -134,6 +140,9 @@ export class InMemorySubscriptionBindings {
134
140
  return { kind: "denied" };
135
141
  binding.state = "active";
136
142
  binding.controller = new AbortController();
143
+ /**
144
+ * Cancels this activation when the caller aborts.
145
+ */
137
146
  const abort = () => {
138
147
  binding.controller.abort();
139
148
  };
@@ -155,6 +164,7 @@ export class InMemorySubscriptionBindings {
155
164
  }
156
165
  /**
157
166
  * Cancels an owned binding and retains retry state after failed cleanup.
167
+ *
158
168
  * @param input Supplies ownership facts and the backend callback.
159
169
  * @returns Returns the ownership transition outcome.
160
170
  */
@@ -182,7 +192,12 @@ export class InMemorySubscriptionBindings {
182
192
  return { kind: "closed" };
183
193
  });
184
194
  }
185
- #owned(input) {
195
+ /**
196
+ * Checks whether the retained binding still matches its current ID slot.
197
+ *
198
+ * @param input Retained binding and current lifecycle state.
199
+ * @returns Whether this binding is still current.
200
+ */ #owned(input) {
186
201
  const binding = this.#bindings.get(input.id);
187
202
  if (binding === undefined)
188
203
  return Promise.resolve(undefined);
@@ -190,15 +205,22 @@ export class InMemorySubscriptionBindings {
190
205
  this.#expire(input.id, binding);
191
206
  return Promise.resolve(undefined);
192
207
  }
193
- return SubscriptionGatewayValues.contextsEqual(binding.context, input.context)
208
+ return SubscriptionGatewayValues.contextsEqual(binding.context, input.context) &&
209
+ ReaderIdentities.matches(binding.reader, input.reader)
194
210
  ? Promise.resolve(binding)
195
211
  : Promise.resolve(undefined);
196
212
  }
197
- #precheck(input) {
213
+ /**
214
+ * Checks retained binding state before an asynchronous effect.
215
+ *
216
+ * @param input Retained binding and current lifecycle state.
217
+ * @returns Whether the effect may proceed.
218
+ */ #precheck(input) {
198
219
  const binding = this.#bindings.get(input.id);
199
220
  if (binding === undefined)
200
221
  return "absent";
201
- if (!SubscriptionGatewayValues.contextsEqual(binding.context, input.context))
222
+ if (!SubscriptionGatewayValues.contextsEqual(binding.context, input.context) ||
223
+ !ReaderIdentities.matches(binding.reader, input.reader))
202
224
  return "denied";
203
225
  if (binding.expiring ||
204
226
  (binding.expiresAtMs !== undefined && binding.expiresAtMs <= input.nowMs)) {
@@ -207,7 +229,12 @@ export class InMemorySubscriptionBindings {
207
229
  }
208
230
  return "owned";
209
231
  }
210
- #expire(id, binding) {
232
+ /**
233
+ * Expires one retained binding only if it is still current.
234
+ *
235
+ * @param id The stable identifier for this record.
236
+ * @param binding Retained subscription binding.
237
+ */ #expire(id, binding) {
211
238
  if (binding.expiring || this.#bindings.get(id) !== binding)
212
239
  return;
213
240
  binding.expiring = true;
@@ -215,16 +242,33 @@ export class InMemorySubscriptionBindings {
215
242
  // spine-log-boundary: auth.subscription_expiry_cleanup
216
243
  void this.#disposeAfterWork(id, binding).catch(() => undefined);
217
244
  }
218
- #dispose(id, binding) {
245
+ /**
246
+ * Clears retained definition bytes and releases binding state.
247
+ *
248
+ * @param id The stable identifier for this record.
249
+ * @param binding Retained subscription binding.
250
+ */ #dispose(id, binding) {
219
251
  binding.definition.fill(0);
220
252
  binding.state = "closed";
221
253
  this.#bindings.delete(id);
222
254
  }
223
- async #runEffect(binding, callback) {
255
+ /**
256
+ * Starts one retained definition effect and tracks its completion.
257
+ *
258
+ * @param binding Retained subscription binding.
259
+ * @param callback Definition or lifecycle callback.
260
+ * @returns Completion after the operation settles.
261
+ */ async #runEffect(binding, callback) {
224
262
  const effect = this.#startEffect(binding, callback);
225
263
  await SubscriptionGatewayValues.withTimeout(effect, this.#limits.operationTimeoutMs, binding.controller);
226
264
  }
227
- async #runActiveEffect(binding, callback) {
265
+ /**
266
+ * Runs a retained binding effect while observing cancellation.
267
+ *
268
+ * @param binding Retained subscription binding.
269
+ * @param callback Definition or lifecycle callback.
270
+ * @returns Completion after the operation settles.
271
+ */ async #runActiveEffect(binding, callback) {
228
272
  let observeAbort = () => undefined;
229
273
  const aborted = new Promise((resolve) => {
230
274
  observeAbort = () => {
@@ -245,7 +289,13 @@ export class InMemorySubscriptionBindings {
245
289
  }
246
290
  throw new Error("subscription operation aborted");
247
291
  }
248
- #startEffect(binding, callback) {
292
+ /**
293
+ * Builds the public definition callback effect for a retained binding.
294
+ *
295
+ * @param binding Retained subscription binding.
296
+ * @param callback Definition or lifecycle callback.
297
+ * @returns Completion after the operation settles.
298
+ */ #startEffect(binding, callback) {
249
299
  const definition = SubscriptionGatewayValues.copyPublic({
250
300
  kind: "public-subscription",
251
301
  bytes: binding.definition,
@@ -265,7 +315,13 @@ export class InMemorySubscriptionBindings {
265
315
  void settled.then(() => definition.bytes.fill(0));
266
316
  return effect;
267
317
  }
268
- #disposeAfterWork(id, binding) {
318
+ /**
319
+ * Waits for active callback work before clearing a binding.
320
+ *
321
+ * @param id The stable identifier for this record.
322
+ * @param binding Retained subscription binding.
323
+ * @returns Completion after the operation settles.
324
+ */ #disposeAfterWork(id, binding) {
269
325
  const cleanup = binding.tail
270
326
  .then(() => binding.effectTail)
271
327
  .then(() => this.#disposeWithCallback(id, binding));
@@ -275,10 +331,21 @@ export class InMemorySubscriptionBindings {
275
331
  void cleanup.catch(() => undefined);
276
332
  return cleanup;
277
333
  }
278
- #waitForShutdown(cleanup) {
334
+ /**
335
+ * Waits for accepted binding effects before disposal.
336
+ *
337
+ * @param cleanup Resource cleanup operation.
338
+ * @returns Completion after the operation settles.
339
+ */ #waitForShutdown(cleanup) {
279
340
  return SubscriptionGatewayValues.withTimeout(cleanup, this.#limits.shutdownTimeoutMs, new AbortController());
280
341
  }
281
- async #disposeWithCallback(id, binding) {
342
+ /**
343
+ * Detaches a retained binding and invokes its cleanup callback.
344
+ *
345
+ * @param id The stable identifier for this record.
346
+ * @param binding Retained subscription binding.
347
+ * @returns Completion after the operation settles.
348
+ */ async #disposeWithCallback(id, binding) {
282
349
  if (this.#bindings.get(id) !== binding)
283
350
  return;
284
351
  const definition = SubscriptionGatewayValues.copyPublic({
@@ -339,6 +406,7 @@ export class SubscriptionGateway {
339
406
  #closed = false;
340
407
  /**
341
408
  * Creates the subscription gateway.
409
+ *
342
410
  * @param options Supplies admission, authorization, binding, and backend collaborators.
343
411
  */
344
412
  constructor(options) {
@@ -349,6 +417,7 @@ export class SubscriptionGateway {
349
417
  }
350
418
  /**
351
419
  * Handles one admitted subscription RPC request.
420
+ *
352
421
  * @param request Supplies the copied request admission facts.
353
422
  * @returns Returns the opaque operation result.
354
423
  */
@@ -367,6 +436,7 @@ export class SubscriptionGateway {
367
436
  }
368
437
  /**
369
438
  * Closes admission and closes retained bindings.
439
+ *
370
440
  * @returns Completes after retained bindings close.
371
441
  */
372
442
  async close() {
@@ -381,6 +451,7 @@ export class SubscriptionGateway {
381
451
  }
382
452
  /**
383
453
  * Schedules finite local expiry cleanup for a recovered durable definition.
454
+ *
384
455
  * @param whenExpires Supplies the retained expiry in epoch milliseconds.
385
456
  */
386
457
  scheduleExpiry(whenExpires) {
@@ -394,7 +465,12 @@ export class SubscriptionGateway {
394
465
  }, Math.max(0, whenExpires - nowMs));
395
466
  this.#expiryTimers.add(timer);
396
467
  }
397
- async #handleOperation(request) {
468
+ /**
469
+ * Rejects stale or malformed gateway operations before binding mutations.
470
+ *
471
+ * @param request The request being processed.
472
+ * @returns Authorized result or a structured rejection.
473
+ */ async #handleOperation(request) {
398
474
  const kind = SubscriptionGatewayValues.operationFor(request);
399
475
  if (kind === undefined)
400
476
  return SubscriptionGatewayValues.rejected("unknown-operation");
@@ -414,7 +490,13 @@ export class SubscriptionGateway {
414
490
  return prepared;
415
491
  return this.#perform(prepared, request.updates, request.signal);
416
492
  }
417
- async #prepareSecurity(kind, request) {
493
+ /**
494
+ * Resolves the session and policy decision for one subscription operation.
495
+ *
496
+ * @param kind Requested subscription operation.
497
+ * @param request The request being processed.
498
+ * @returns Prepared operation or an authentication/policy rejection.
499
+ */ async #prepareSecurity(kind, request) {
418
500
  const source = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
419
501
  if (source === undefined)
420
502
  return SubscriptionGatewayValues.rejected("malformed-request");
@@ -431,119 +513,261 @@ export class SubscriptionGateway {
431
513
  return SubscriptionGatewayValues.rejected("forbidden");
432
514
  return this.#resolveTrusted(kind, request, source, session);
433
515
  }
434
- async #resolveTrusted(kind, request, source, session) {
516
+ /**
517
+ * Builds detached trusted reader and actor facts after policy admission.
518
+ *
519
+ * @param kind Requested subscription operation.
520
+ * @param request The request being processed.
521
+ * @param source Decoded incoming subscription request.
522
+ * @param session Resolved gateway session or explicit public principal.
523
+ * @returns Prepared operation or a structured context rejection.
524
+ */ async #resolveTrusted(kind, request, source, session) {
435
525
  const contextRequest = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
436
526
  if (contextRequest === undefined)
437
527
  return SubscriptionGatewayValues.rejected("malformed-request");
438
- const context = SubscriptionGatewayValues.trustedContext(await this.#options.contexts.resolve(session.principal, contextRequest, this.#options.clock));
528
+ const resolved = await this.#options.contexts.resolve(session.principal, contextRequest, this.#options.clock);
529
+ const context = SubscriptionGatewayValues.trustedContext(resolved);
530
+ const reader = this.#trustedReader(session.principal, resolved);
439
531
  const nowMs = this.#nowMs();
440
- const expiresAtMs = session.expiresAt === undefined
441
- ? undefined
442
- : SubscriptionGatewayValues.timestampMs(session.expiresAt.seconds, session.expiresAt.nanos);
443
- if (nowMs === undefined ||
444
- (this.#options.publicAccess !== true && expiresAtMs === undefined) ||
445
- (expiresAtMs !== undefined && expiresAtMs <= nowMs) ||
446
- !SubscriptionGatewayValues.matches(source.requestedContext, context))
532
+ const expiresAtMs = this.#expiresAtMs(session.expiresAt);
533
+ if (nowMs === undefined || !this.#validContext(source, context, nowMs, expiresAtMs))
447
534
  return SubscriptionGatewayValues.rejected("denied");
448
535
  return {
449
536
  source,
450
537
  context,
538
+ reader,
451
539
  expiresAtMs,
452
540
  nowMs,
453
541
  };
454
542
  }
455
- #nowMs() {
543
+ /**
544
+ * Converts a verified session expiry into the gateway clock's millisecond unit.
545
+ *
546
+ * @param value Verified session expiry timestamp, if present.
547
+ * @returns The expiry in epoch milliseconds.
548
+ */
549
+ #expiresAtMs(value) {
550
+ return value === undefined
551
+ ? undefined
552
+ : SubscriptionGatewayValues.timestampMs(value.seconds, value.nanos);
553
+ }
554
+ /**
555
+ * Detaches the verified principal and current authority facts from the resolver.
556
+ *
557
+ * @param principal Verified principal from session resolution.
558
+ * @param resolved Resolved context and authority facts.
559
+ * @returns The detached verified reader facts.
560
+ */
561
+ #trustedReader(principal, resolved) {
562
+ return snapshotTrustedReader({
563
+ ...(this.#options.publicAccess === true ? {} : { principal }),
564
+ ...(this.#options.publicAccess === true || resolved.authorityFacts === undefined
565
+ ? {}
566
+ : { authorityFacts: resolved.authorityFacts }),
567
+ });
568
+ }
569
+ /**
570
+ * Checks session time and the resolver's trusted actor/tenant against the request.
571
+ *
572
+ * @param source Decoded incoming subscription request.
573
+ * @param context The trusted context for this operation.
574
+ * @param nowMs The current time in milliseconds.
575
+ * @param expiresAtMs The session expiry in milliseconds, when present.
576
+ * @returns The validated actor context.
577
+ */
578
+ #validContext(source, context, nowMs, expiresAtMs) {
579
+ return ((this.#options.publicAccess === true || expiresAtMs !== undefined) &&
580
+ (expiresAtMs === undefined || expiresAtMs > nowMs) &&
581
+ SubscriptionGatewayValues.matches(source.requestedContext, context));
582
+ }
583
+ /**
584
+ * Reads the configured clock as finite epoch milliseconds.
585
+ *
586
+ * @returns Current epoch milliseconds, if valid.
587
+ */ #nowMs() {
456
588
  const now = this.#options.clock.now();
457
589
  return SubscriptionGatewayValues.timestampMs(now.seconds, now.nanos);
458
590
  }
459
- async #perform(prepared, updates, signal) {
460
- const { source, context, expiresAtMs, nowMs } = prepared;
591
+ /**
592
+ * Runs the prepared subscription operation after security checks.
593
+ *
594
+ * @param prepared Policy-admitted subscription operation.
595
+ * @param updates The admitted subscription update sink.
596
+ * @param signal The cancellation signal for this operation.
597
+ * @returns The admitted subscription outcome.
598
+ */ async #perform(prepared, updates, signal) {
599
+ const { source, context, reader, expiresAtMs, nowMs } = prepared;
461
600
  const rewritten = SubscriptionGatewayValues.rewrite(source, context);
462
601
  if (source.kind === "subscribe")
463
- return this.#subscribe(rewritten, context, expiresAtMs);
602
+ return this.#subscribe(rewritten, context, reader, expiresAtMs);
464
603
  const id = source.subscription.id?.value;
465
604
  if (id === undefined || id.length === 0)
466
605
  return SubscriptionGatewayValues.rejected("denied");
467
606
  return source.kind === "activate"
468
- ? this.#activate(id, context, nowMs, expiresAtMs, updates ?? SubscriptionGatewayValues.discardUpdate, signal)
469
- : this.#cancel(id, context, nowMs);
607
+ ? this.#activate(id, context, reader, nowMs, expiresAtMs, updates ?? SubscriptionGatewayValues.discardUpdate, signal)
608
+ : this.#cancel(id, context, nowMs, reader);
470
609
  }
471
- async #activate(id, context, nowMs, expiresAtMs, updates, signal) {
472
- const activeController = new AbortController();
473
- const active = activeController.signal;
474
- const abort = () => {
475
- activeController.abort();
476
- };
610
+ /**
611
+ * Verifies membership and activates a retained subscription.
612
+ *
613
+ * @param id The stable identifier for this record.
614
+ * @param context The trusted context for this operation.
615
+ * @param reader The verified reader facts for this operation.
616
+ * @param nowMs The current time in milliseconds.
617
+ * @param expiresAtMs The session expiry in milliseconds, when present.
618
+ * @param updates The admitted subscription update sink.
619
+ * @param signal The cancellation signal for this operation.
620
+ * @returns The update stream after admission.
621
+ */ async #activate(id, context, reader, nowMs, expiresAtMs, updates, signal) {
477
622
  if (signal?.aborted)
478
623
  return SubscriptionGatewayValues.rejected("denied");
479
624
  if (this.#options.publicAccess === true)
480
625
  this.#clearPublicPending(id);
481
- signal?.addEventListener("abort", abort, { once: true });
482
- const expiry = expiresAtMs === undefined
483
- ? undefined
484
- : setTimeout(() => {
485
- activeController.abort();
486
- }, Math.max(0, expiresAtMs - nowMs));
487
626
  let activationFailure;
488
627
  let outcome = SubscriptionGatewayValues.rejected("denied");
489
628
  try {
490
- const result = await this.#options.bindings.activate({
491
- id,
492
- context,
493
- nowMs,
494
- signal: active,
495
- onDefinition: (definition, effectSignal) => this.#forwardActivate(definition, updates, effectSignal),
629
+ outcome = await this.#activationEffect(id, context, reader, nowMs, expiresAtMs, updates, signal);
630
+ }
631
+ catch (error) {
632
+ activationFailure = this.#activationError(error);
633
+ }
634
+ if (this.#options.publicAccess === true)
635
+ await this.#publicActivationCleanup(id, context, reader, nowMs, activationFailure);
636
+ if (activationFailure !== undefined)
637
+ throw activationFailure;
638
+ return outcome;
639
+ }
640
+ /**
641
+ * Preserves a non-Error activation failure as a throwable cause.
642
+ *
643
+ * @param error The failure being classified or propagated.
644
+ * @returns The structured activation error.
645
+ */
646
+ #activationError(error) {
647
+ return error instanceof Error
648
+ ? error
649
+ : new Error("Public subscription activation failed with a non-Error value.", {
650
+ cause: error,
496
651
  });
497
- outcome =
498
- result.kind === "activated"
499
- ? { kind: "activated" }
500
- : SubscriptionGatewayValues.rejected("denied");
652
+ }
653
+ /**
654
+ * Releases a public definition while retaining both activation and cleanup failures.
655
+ *
656
+ * @param id The stable identifier for this record.
657
+ * @param context The trusted context for this operation.
658
+ * @param reader The verified reader facts for this operation.
659
+ * @param nowMs The current time in milliseconds.
660
+ * @param activationFailure Failure raised while activating the subscription.
661
+ * @returns Completion after the operation settles.
662
+ */
663
+ async #publicActivationCleanup(id, context, reader, nowMs, activationFailure) {
664
+ try {
665
+ await this.#cancel(id, context, this.#nowMs() ?? nowMs, reader);
666
+ }
667
+ catch (error) {
668
+ const cleanupFailure = error instanceof Error
669
+ ? error
670
+ : new Error("Public subscription cleanup failed with a non-Error value.", {
671
+ cause: error,
672
+ });
673
+ if (activationFailure !== undefined)
674
+ throw new AggregateError([activationFailure, cleanupFailure], "public subscription cleanup failed");
675
+ throw cleanupFailure;
676
+ }
677
+ }
678
+ /**
679
+ * Runs one reader-bound binding activation under cancellation and session expiry.
680
+ *
681
+ * @param id The stable identifier for this record.
682
+ * @param context The trusted context for this operation.
683
+ * @param reader The verified reader facts for this operation.
684
+ * @param nowMs The current time in milliseconds.
685
+ * @param expiresAtMs The session expiry in milliseconds, when present.
686
+ * @param updates The admitted subscription update sink.
687
+ * @param signal The cancellation signal for this operation.
688
+ * @returns The tracked activation effect.
689
+ */
690
+ async #activationEffect(id, context, reader, nowMs, expiresAtMs, updates, signal) {
691
+ const activeController = new AbortController();
692
+ const active = activeController.signal;
693
+ /**
694
+ * Cancels this activation when the caller aborts.
695
+ */
696
+ const abort = () => {
697
+ activeController.abort();
698
+ };
699
+ signal?.addEventListener("abort", abort, { once: true });
700
+ const expiry = this.#activationExpiry(expiresAtMs, nowMs, activeController);
701
+ try {
702
+ const result = await this.#activateBinding(id, context, reader, nowMs, active, updates);
703
+ return result.kind === "activated"
704
+ ? { kind: "activated" }
705
+ : SubscriptionGatewayValues.rejected("denied");
501
706
  }
502
707
  catch (error) {
503
708
  if (error instanceof Error && error.message === "binding-busy")
504
- outcome = SubscriptionGatewayValues.rejected("binding-busy");
505
- else
506
- activationFailure =
507
- error instanceof Error
508
- ? error
509
- : new Error("Public subscription activation failed with a non-Error value.", {
510
- cause: error,
511
- });
709
+ return SubscriptionGatewayValues.rejected("binding-busy");
710
+ throw error;
512
711
  }
513
712
  finally {
514
713
  if (expiry !== undefined)
515
714
  clearTimeout(expiry);
516
715
  signal?.removeEventListener("abort", abort);
517
716
  }
518
- let cleanupFailure;
519
- if (this.#options.publicAccess === true)
520
- try {
521
- await this.#cancel(id, context, this.#nowMs() ?? nowMs);
522
- }
523
- catch (error) {
524
- cleanupFailure =
525
- error instanceof Error
526
- ? error
527
- : new Error("Public subscription cleanup failed with a non-Error value.", {
528
- cause: error,
529
- });
530
- }
531
- if (activationFailure !== undefined) {
532
- if (cleanupFailure !== undefined)
533
- throw new AggregateError([activationFailure, cleanupFailure], "public subscription cleanup failed");
534
- throw activationFailure;
535
- }
536
- if (cleanupFailure !== undefined)
537
- throw cleanupFailure;
538
- return outcome;
539
717
  }
540
- #cancel(id, context, nowMs) {
718
+ /**
719
+ * Cancels activation when the admitted reader session reaches its expiry.
720
+ *
721
+ * @param expiresAtMs The session expiry in milliseconds, when present.
722
+ * @param nowMs The current time in milliseconds.
723
+ * @param controller Abort controller for the active operation.
724
+ * @returns The pending activation expiry.
725
+ */
726
+ #activationExpiry(expiresAtMs, nowMs, controller) {
727
+ return expiresAtMs === undefined
728
+ ? undefined
729
+ : setTimeout(() => {
730
+ controller.abort();
731
+ }, Math.max(0, expiresAtMs - nowMs));
732
+ }
733
+ /**
734
+ * Sends one reader-bound activation to the durable binding port.
735
+ *
736
+ * @param id The stable identifier for this record.
737
+ * @param context The trusted context for this operation.
738
+ * @param reader The verified reader facts for this operation.
739
+ * @param nowMs The current time in milliseconds.
740
+ * @param signal The cancellation signal for this operation.
741
+ * @param updates The admitted subscription update sink.
742
+ * @returns The activated reader-bound definition.
743
+ */
744
+ #activateBinding(id, context, reader, nowMs, signal, updates) {
745
+ return this.#options.bindings.activate({
746
+ id,
747
+ context,
748
+ reader: ReaderIdentities.from(reader),
749
+ nowMs,
750
+ signal,
751
+ onDefinition: (definition, effectSignal) => this.#forwardActivate({ ...definition, reader }, updates, effectSignal),
752
+ });
753
+ }
754
+ /**
755
+ * Verifies reader membership and removes a retained subscription.
756
+ *
757
+ * @param id The stable identifier for this record.
758
+ * @param context The trusted context for this operation.
759
+ * @param nowMs The current time in milliseconds.
760
+ * @param reader The verified reader facts for this operation.
761
+ * @returns The cancellation acknowledgment.
762
+ */ #cancel(id, context, nowMs, reader = {}) {
763
+ const identity = ReaderIdentities.from(reader);
541
764
  const active = this.#cancellations.get(id) ?? [];
542
- const matching = active.find((entry) => SubscriptionGatewayValues.contextsEqual(entry.context, context));
765
+ const matching = active.find((entry) => SubscriptionGatewayValues.contextsEqual(entry.context, context) &&
766
+ ReaderIdentities.matches(entry.reader, identity));
543
767
  if (matching !== undefined)
544
768
  return matching.promise;
545
- const promise = this.#cancelOnce(id, context, nowMs);
546
- const entry = { context: clone(ActorContextSchema, context), promise };
769
+ const promise = this.#cancelOnce(id, context, nowMs, reader);
770
+ const entry = { context: clone(ActorContextSchema, context), reader: identity, promise };
547
771
  active.push(entry);
548
772
  this.#cancellations.set(id, active);
549
773
  // spine-log-boundary: auth.subscription_cancel_finally_observer
@@ -561,13 +785,22 @@ export class SubscriptionGateway {
561
785
  .catch(() => undefined);
562
786
  return promise;
563
787
  }
564
- async #cancelOnce(id, context, nowMs) {
788
+ /**
789
+ * Serializes one cancellation against concurrent work for its ID.
790
+ *
791
+ * @param id The stable identifier for this record.
792
+ * @param context The trusted context for this operation.
793
+ * @param nowMs The current time in milliseconds.
794
+ * @param reader The verified reader facts for this operation.
795
+ * @returns The cancellation outcome.
796
+ */ async #cancelOnce(id, context, nowMs, reader) {
565
797
  try {
566
798
  const result = await this.#options.bindings.cancel({
567
799
  id,
568
800
  context,
801
+ reader: ReaderIdentities.from(reader),
569
802
  nowMs,
570
- onDefinition: (definition, effectSignal) => this.#forwardCancel(definition, effectSignal),
803
+ onDefinition: (definition, effectSignal) => this.#forwardCancel({ ...definition, reader }, effectSignal),
571
804
  });
572
805
  if (result.kind === "denied")
573
806
  return SubscriptionGatewayValues.rejected("denied");
@@ -580,43 +813,32 @@ export class SubscriptionGateway {
580
813
  throw error;
581
814
  }
582
815
  }
583
- async #subscribe(bytes, context, expiresAtMs) {
816
+ /**
817
+ * Validates and retains a reader-bound subscription definition.
818
+ *
819
+ * @param bytes The serialized message bytes.
820
+ * @param context The trusted context for this operation.
821
+ * @param reader The verified reader facts for this operation.
822
+ * @param expiresAtMs The session expiry in milliseconds, when present.
823
+ * @returns The new subscription definition.
824
+ */ async #subscribe(bytes, context, reader, expiresAtMs) {
584
825
  const wire = await this.#options.bindings.create({
585
826
  topic: { kind: "subscription-topic", bytes: bytes.slice() },
827
+ reader: ReaderIdentities.from(reader),
586
828
  ...(expiresAtMs === undefined ? {} : { whenExpires: expiresAtMs }),
587
829
  });
830
+ const admittedWire = { ...wire, reader };
588
831
  const id = fromBinary(SubscriptionSchema, wire.bytes).id?.value;
589
832
  if (id === undefined || id.length === 0)
590
833
  throw new Error("retained subscription has no ID");
591
834
  const controller = new AbortController();
592
835
  try {
593
- await this.#receiveBackend(wire, controller);
594
- const nowMs = this.#nowMs();
595
- if (nowMs === undefined || (expiresAtMs !== undefined && expiresAtMs <= nowMs)) {
596
- await this.#compensateDefinition(wire, controller);
597
- await this.#options.bindings.cancel({
598
- id,
599
- context,
600
- nowMs: nowMs ?? expiresAtMs ?? 0,
601
- onDefinition: () => Promise.resolve(),
602
- });
603
- return SubscriptionGatewayValues.rejected("denied");
604
- }
605
- if (expiresAtMs !== undefined)
606
- this.scheduleExpiry(expiresAtMs);
607
- if (this.#options.publicAccess === true)
608
- this.#schedulePublicPending(id, context);
609
- return { kind: "subscribed", wire: SubscriptionGatewayValues.copyPublic(wire) };
836
+ await this.#receiveBackend(admittedWire, controller);
837
+ return await this.#confirmCreated(id, context, reader, expiresAtMs, admittedWire, controller);
610
838
  }
611
839
  catch (error) {
612
840
  try {
613
- await this.#compensateDefinition(wire, controller);
614
- await this.#options.bindings.cancel({
615
- id,
616
- context,
617
- nowMs: this.#nowMs() ?? expiresAtMs ?? 0,
618
- onDefinition: () => Promise.resolve(),
619
- });
841
+ await this.#discardCreated(id, context, reader, this.#nowMs() ?? expiresAtMs ?? 0, admittedWire, controller);
620
842
  // spine-log-boundary: auth.subscription_recovered_cleanup
621
843
  }
622
844
  catch {
@@ -625,7 +847,59 @@ export class SubscriptionGateway {
625
847
  throw error;
626
848
  }
627
849
  }
628
- #schedulePublicPending(id, context) {
850
+ /**
851
+ * Returns an admitted public definition only while its reader session remains valid.
852
+ *
853
+ * @param id The stable identifier for this record.
854
+ * @param context The trusted context for this operation.
855
+ * @param reader The verified reader facts for this operation.
856
+ * @param expiresAtMs The session expiry in milliseconds, when present.
857
+ * @param wire Canonical serialized subscription bytes.
858
+ * @param controller Abort controller for the active operation.
859
+ * @returns The confirmed retained definition.
860
+ */
861
+ async #confirmCreated(id, context, reader, expiresAtMs, wire, controller) {
862
+ const nowMs = this.#nowMs();
863
+ if (nowMs === undefined || (expiresAtMs !== undefined && expiresAtMs <= nowMs)) {
864
+ await this.#discardCreated(id, context, reader, nowMs ?? expiresAtMs ?? 0, wire, controller);
865
+ return SubscriptionGatewayValues.rejected("denied");
866
+ }
867
+ if (expiresAtMs !== undefined)
868
+ this.scheduleExpiry(expiresAtMs);
869
+ if (this.#options.publicAccess === true)
870
+ this.#schedulePublicPending(id, context);
871
+ return {
872
+ kind: "subscribed",
873
+ wire: { kind: "public-subscription", bytes: wire.bytes.slice() },
874
+ };
875
+ }
876
+ /**
877
+ * Compensates a created definition before it can escape admission.
878
+ *
879
+ * @param id The stable identifier for this record.
880
+ * @param context The trusted context for this operation.
881
+ * @param reader The verified reader facts for this operation.
882
+ * @param nowMs The current time in milliseconds.
883
+ * @param wire Canonical serialized subscription bytes.
884
+ * @param controller Abort controller for the active operation.
885
+ * @returns Completion after the operation settles.
886
+ */
887
+ async #discardCreated(id, context, reader, nowMs, wire, controller) {
888
+ await this.#compensateDefinition(wire, controller);
889
+ await this.#options.bindings.cancel({
890
+ id,
891
+ context,
892
+ reader: ReaderIdentities.from(reader),
893
+ nowMs,
894
+ onDefinition: () => Promise.resolve(),
895
+ });
896
+ }
897
+ /**
898
+ * Schedules activation retry for a public subscription.
899
+ *
900
+ * @param id The stable identifier for this record.
901
+ * @param context The trusted context for this operation.
902
+ */ #schedulePublicPending(id, context) {
629
903
  this.#clearPublicPending(id);
630
904
  const timer = setTimeout(() => {
631
905
  if (this.#publicPendingTimers.get(id) !== timer)
@@ -641,24 +915,52 @@ export class SubscriptionGateway {
641
915
  }, SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS);
642
916
  this.#publicPendingTimers.set(id, timer);
643
917
  }
644
- #retryPublicPending(id, context) {
918
+ /**
919
+ * Retries pending public activation after backend availability changes.
920
+ *
921
+ * @param id The stable identifier for this record.
922
+ * @param context The trusted context for this operation.
923
+ */ #retryPublicPending(id, context) {
645
924
  if (!this.#closed && !this.#publicPendingTimers.has(id))
646
925
  this.#schedulePublicPending(id, context);
647
926
  }
648
- #clearPublicPending(id) {
927
+ /**
928
+ * Clears the pending public activation timer for an ID.
929
+ *
930
+ * @param id The stable identifier for this record.
931
+ */ #clearPublicPending(id) {
649
932
  const timer = this.#publicPendingTimers.get(id);
650
933
  if (timer !== undefined)
651
934
  clearTimeout(timer);
652
935
  this.#publicPendingTimers.delete(id);
653
936
  }
654
- async #receiveBackend(wire, controller) {
937
+ /**
938
+ * Attaches the native backend subscription after admission.
939
+ *
940
+ * @param wire Canonical serialized subscription bytes.
941
+ * @param controller Abort controller for the active operation.
942
+ * @returns Completion after the operation settles.
943
+ */ async #receiveBackend(wire, controller) {
655
944
  return SubscriptionGatewayValues.withTimeout(this.#options.creator.subscribe(SubscriptionGatewayValues.copyPublic(wire), controller.signal, this.#limits.maxBackendEnvelopeBytes), this.#limits.operationTimeoutMs, controller);
656
945
  }
657
- async #compensateDefinition(wire, controller) {
946
+ /**
947
+ * Removes a definition after a failed backend subscription step.
948
+ *
949
+ * @param wire Canonical serialized subscription bytes.
950
+ * @param controller Abort controller for the active operation.
951
+ * @returns Completion after the operation settles.
952
+ */ async #compensateDefinition(wire, controller) {
658
953
  const signal = controller.signal.aborted ? new AbortController().signal : controller.signal;
659
954
  await this.#options.creator.cancel({ wire: SubscriptionGatewayValues.copyPublic(wire) }, signal);
660
955
  }
661
- async #forwardActivate(definition, updates, signal) {
956
+ /**
957
+ * Forwards activation with the verified reader assertion.
958
+ *
959
+ * @param definition The serialized subscription definition.
960
+ * @param updates The admitted subscription update sink.
961
+ * @param signal The cancellation signal for this operation.
962
+ * @returns Completion after the operation settles.
963
+ */ async #forwardActivate(definition, updates, signal) {
662
964
  const privateWire = SubscriptionGatewayValues.copyPublic(definition);
663
965
  try {
664
966
  const publicSubscription = fromBinary(SubscriptionSchema, definition.bytes);
@@ -683,7 +985,13 @@ export class SubscriptionGateway {
683
985
  privateWire.bytes.fill(0);
684
986
  }
685
987
  }
686
- async #forwardCancel(definition, signal) {
988
+ /**
989
+ * Forwards cancellation with the verified reader assertion.
990
+ *
991
+ * @param definition The serialized subscription definition.
992
+ * @param signal The cancellation signal for this operation.
993
+ * @returns Completion after the operation settles.
994
+ */ async #forwardCancel(definition, signal) {
687
995
  const privateWire = SubscriptionGatewayValues.copyPublic(definition);
688
996
  try {
689
997
  await this.#options.creator.cancel({ wire: privateWire }, signal);
@@ -715,7 +1023,11 @@ const SubscriptionGatewayValues = Object.freeze({
715
1023
  * @returns Copied public subscription bytes.
716
1024
  */
717
1025
  copyPublic(wire) {
718
- return { kind: wire.kind, bytes: wire.bytes.slice() };
1026
+ return {
1027
+ kind: wire.kind,
1028
+ bytes: wire.bytes.slice(),
1029
+ ...(wire.reader === undefined ? {} : { reader: snapshotTrustedReader(wire.reader) }),
1030
+ };
719
1031
  },
720
1032
  /**
721
1033
  * Builds a successful subscription response.