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

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.
Files changed (82) hide show
  1. package/README.md +128 -11
  2. package/REFERENCE.md +166 -12
  3. package/dist/gateway/dynamic-unary-forwarder.d.ts +62 -3
  4. package/dist/gateway/dynamic-unary-forwarder.d.ts.map +1 -1
  5. package/dist/gateway/dynamic-unary-forwarder.js +189 -10
  6. package/dist/gateway/dynamic-unary-forwarder.js.map +1 -1
  7. package/dist/gateway/index.d.ts +5 -0
  8. package/dist/gateway/index.d.ts.map +1 -1
  9. package/dist/gateway/index.js +65 -7
  10. package/dist/gateway/index.js.map +1 -1
  11. package/dist/index.d.ts +60 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/native/index.d.ts +43 -3
  16. package/dist/native/index.d.ts.map +1 -1
  17. package/dist/native/index.js +178 -32
  18. package/dist/native/index.js.map +1 -1
  19. package/dist/oidc/contracts.d.ts +18 -4
  20. package/dist/oidc/contracts.d.ts.map +1 -1
  21. package/dist/oidc/flow-store.d.ts +143 -0
  22. package/dist/oidc/flow-store.d.ts.map +1 -0
  23. package/dist/oidc/flow-store.js +184 -0
  24. package/dist/oidc/flow-store.js.map +1 -0
  25. package/dist/oidc/index.d.ts +29 -9
  26. package/dist/oidc/index.d.ts.map +1 -1
  27. package/dist/oidc/index.js +611 -152
  28. package/dist/oidc/index.js.map +1 -1
  29. package/dist/oidc/persistent-flow-store.d.ts +117 -0
  30. package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
  31. package/dist/oidc/persistent-flow-store.js +413 -0
  32. package/dist/oidc/persistent-flow-store.js.map +1 -0
  33. package/dist/oidc/records.d.ts +15 -0
  34. package/dist/oidc/records.d.ts.map +1 -0
  35. package/dist/oidc/records.js +91 -0
  36. package/dist/oidc/records.js.map +1 -0
  37. package/dist/providers/index.d.ts +10 -5
  38. package/dist/providers/index.d.ts.map +1 -1
  39. package/dist/providers/index.js +103 -30
  40. package/dist/providers/index.js.map +1 -1
  41. package/dist/request/index.d.ts +3 -0
  42. package/dist/request/index.d.ts.map +1 -1
  43. package/dist/request/index.js +17 -1
  44. package/dist/request/index.js.map +1 -1
  45. package/dist/sessions/cookies.d.ts +46 -8
  46. package/dist/sessions/cookies.d.ts.map +1 -1
  47. package/dist/sessions/cookies.js +269 -47
  48. package/dist/sessions/cookies.js.map +1 -1
  49. package/dist/sessions/opaque-session-store.d.ts +143 -0
  50. package/dist/sessions/opaque-session-store.d.ts.map +1 -0
  51. package/dist/sessions/opaque-session-store.js +185 -0
  52. package/dist/sessions/opaque-session-store.js.map +1 -0
  53. package/dist/sessions/opaque.d.ts +93 -106
  54. package/dist/sessions/opaque.d.ts.map +1 -1
  55. package/dist/sessions/opaque.js +501 -232
  56. package/dist/sessions/opaque.js.map +1 -1
  57. package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
  58. package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
  59. package/dist/sessions/persistent-opaque-session-store.js +333 -0
  60. package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
  61. package/dist/sessions/persistent-token-revocations.d.ts +90 -0
  62. package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
  63. package/dist/sessions/persistent-token-revocations.js +422 -0
  64. package/dist/sessions/persistent-token-revocations.js.map +1 -0
  65. package/dist/sessions/session-lifetime.d.ts +67 -0
  66. package/dist/sessions/session-lifetime.d.ts.map +1 -0
  67. package/dist/sessions/session-lifetime.js +165 -0
  68. package/dist/sessions/session-lifetime.js.map +1 -0
  69. package/dist/sessions/session-record.d.ts +15 -0
  70. package/dist/sessions/session-record.d.ts.map +1 -0
  71. package/dist/sessions/session-record.js +109 -0
  72. package/dist/sessions/session-record.js.map +1 -0
  73. package/dist/sessions/signed.d.ts +5 -4
  74. package/dist/sessions/signed.d.ts.map +1 -1
  75. package/dist/sessions/signed.js +212 -74
  76. package/dist/sessions/signed.js.map +1 -1
  77. package/dist/subscriptions/index.d.ts +35 -3
  78. package/dist/subscriptions/index.d.ts.map +1 -1
  79. package/dist/subscriptions/index.js +680 -134
  80. package/dist/subscriptions/index.js.map +1 -1
  81. package/dist/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +20 -6
@@ -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({
@@ -337,8 +404,10 @@ export class SubscriptionGateway {
337
404
  #publicPendingTimers = new Map();
338
405
  #cancellations = new Map();
339
406
  #closed = false;
407
+ #operations = new Set();
340
408
  /**
341
409
  * Creates the subscription gateway.
410
+ *
342
411
  * @param options Supplies admission, authorization, binding, and backend collaborators.
343
412
  */
344
413
  constructor(options) {
@@ -349,6 +418,7 @@ export class SubscriptionGateway {
349
418
  }
350
419
  /**
351
420
  * Handles one admitted subscription RPC request.
421
+ *
352
422
  * @param request Supplies the copied request admission facts.
353
423
  * @returns Returns the opaque operation result.
354
424
  */
@@ -367,10 +437,13 @@ export class SubscriptionGateway {
367
437
  }
368
438
  /**
369
439
  * Closes admission and closes retained bindings.
440
+ *
370
441
  * @returns Completes after retained bindings close.
371
442
  */
372
443
  async close() {
373
444
  this.#closed = true;
445
+ for (const operation of this.#operations)
446
+ operation.abort();
374
447
  for (const timer of this.#expiryTimers)
375
448
  clearTimeout(timer);
376
449
  this.#expiryTimers.clear();
@@ -381,9 +454,19 @@ export class SubscriptionGateway {
381
454
  }
382
455
  /**
383
456
  * Schedules finite local expiry cleanup for a recovered durable definition.
457
+ *
384
458
  * @param whenExpires Supplies the retained expiry in epoch milliseconds.
385
459
  */
386
460
  scheduleExpiry(whenExpires) {
461
+ this.#scheduleExpiry(whenExpires);
462
+ }
463
+ /**
464
+ * Retains one finite cleanup timer and exposes its handle for admission rollback.
465
+ *
466
+ * @param whenExpires Retained definition expiry in epoch milliseconds.
467
+ * @returns Scheduled timer, or nothing when scheduling is unavailable.
468
+ */
469
+ #scheduleExpiry(whenExpires) {
387
470
  const nowMs = this.#nowMs();
388
471
  if (nowMs === undefined || this.#closed)
389
472
  return;
@@ -393,8 +476,136 @@ export class SubscriptionGateway {
393
476
  void this.#options.bindings.purgeExpired(whenExpires).catch(() => undefined);
394
477
  }, Math.max(0, whenExpires - nowMs));
395
478
  this.#expiryTimers.add(timer);
479
+ return () => {
480
+ clearTimeout(timer);
481
+ this.#expiryTimers.delete(timer);
482
+ };
483
+ }
484
+ /**
485
+ * Rejects stale or malformed gateway operations before binding mutations.
486
+ *
487
+ * @param request The request being processed.
488
+ * @returns Authorized result or a structured rejection.
489
+ */ async #handleOperation(request) {
490
+ const operation = new AbortController();
491
+ const onAbort = () => {
492
+ operation.abort();
493
+ };
494
+ request.signal?.addEventListener("abort", onAbort, { once: true });
495
+ if (request.signal?.aborted)
496
+ onAbort();
497
+ this.#operations.add(operation);
498
+ let invalidated;
499
+ try {
500
+ const prepared = await this.#prepareOperation(request, operation.signal);
501
+ if ("kind" in prepared)
502
+ return prepared;
503
+ invalidated = prepared.invalidated;
504
+ invalidated?.addEventListener("abort", onAbort, { once: true });
505
+ if (invalidated?.aborted)
506
+ onAbort();
507
+ if (operation.signal.aborted)
508
+ return SubscriptionGatewayValues.rejected("denied");
509
+ const result = await this.#perform(prepared, request.updates, operation.signal);
510
+ // The admitted operation can end while #perform settles.
511
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
512
+ if (operation.signal.aborted)
513
+ return await this.#rejectLate(prepared, result);
514
+ if (result.kind === "subscribed") {
515
+ const finished = this.#finishCreated(prepared, result, operation.signal);
516
+ return finished instanceof Promise ? await finished : finished;
517
+ }
518
+ return result;
519
+ }
520
+ finally {
521
+ operation.abort();
522
+ this.#operations.delete(operation);
523
+ request.signal?.removeEventListener("abort", onAbort);
524
+ invalidated?.removeEventListener("abort", onAbort);
525
+ }
526
+ }
527
+ /**
528
+ * Schedules a confirmed result and returns it without an additional success await.
529
+ *
530
+ * @param prepared Captured actor, reader and expiry facts.
531
+ * @param result Confirmed created definition.
532
+ * @param signal Effective operation lifetime.
533
+ * @returns Result, denial or failure after any required cleanup.
534
+ */
535
+ #finishCreated(prepared, result, signal) {
536
+ try {
537
+ this.#scheduleCreated(prepared, result, signal);
538
+ }
539
+ catch (error) {
540
+ return this.#failCreated(prepared, result, error);
541
+ }
542
+ if (signal.aborted)
543
+ return this.#rejectLate(prepared, result);
544
+ return result;
545
+ }
546
+ /**
547
+ * Starts local maintenance only after a Subscribe result passes final admission.
548
+ *
549
+ * @param prepared Captured actor, reader and expiry facts.
550
+ * @param result Confirmed created definition.
551
+ * @param signal Effective operation lifetime.
552
+ */
553
+ #scheduleCreated(prepared, result, signal) {
554
+ const id = fromBinary(SubscriptionSchema, result.wire.bytes).id?.value;
555
+ if (id === undefined || id.length === 0)
556
+ throw new Error("retained subscription has no ID");
557
+ if (this.#options.publicAccess === true)
558
+ this.#schedulePublicPending(id, prepared.context);
559
+ if (prepared.expiresAtMs !== undefined) {
560
+ const cancelTimer = this.#scheduleExpiry(prepared.expiresAtMs);
561
+ if (signal.aborted)
562
+ cancelTimer?.();
563
+ }
564
+ if (signal.aborted)
565
+ this.#clearPublicPending(id);
396
566
  }
397
- async #handleOperation(request) {
567
+ /**
568
+ * Preserves a scheduling failure and any subsequent definition cleanup failure.
569
+ *
570
+ * @param prepared Captured actor and reader facts.
571
+ * @param result Confirmed created definition.
572
+ * @param error Scheduling failure.
573
+ * @returns Rejected completion after cleanup is attempted.
574
+ */
575
+ async #failCreated(prepared, result, error) {
576
+ try {
577
+ await this.#rejectLate(prepared, result);
578
+ }
579
+ catch (cleanup) {
580
+ throw new AggregateError([error, cleanup], "Subscription scheduling cleanup failed.");
581
+ }
582
+ throw error;
583
+ }
584
+ /**
585
+ * Denies an outcome completed after operation invalidation and clears confirmed creation.
586
+ *
587
+ * @param prepared Captured actor and reader facts.
588
+ * @param result Completed operation outcome.
589
+ * @returns Denial after any confirmed definition is removed.
590
+ */
591
+ async #rejectLate(prepared, result) {
592
+ if (result.kind === "subscribed") {
593
+ const id = fromBinary(SubscriptionSchema, result.wire.bytes).id?.value;
594
+ if (id === undefined || id.length === 0)
595
+ throw new Error("retained subscription has no ID");
596
+ await this.#discardCreated(id, prepared.context, prepared.reader, this.#nowMs() ?? prepared.nowMs, { ...result.wire, reader: prepared.reader }, new AbortController());
597
+ this.#clearPublicPending(id);
598
+ }
599
+ return SubscriptionGatewayValues.rejected("denied");
600
+ }
601
+ /**
602
+ * Validates request and security before the selected operation mutates state.
603
+ *
604
+ * @param request Copied admission facts.
605
+ * @param signal Host-managed operation cancellation.
606
+ * @returns Prepared operation or rejection.
607
+ */
608
+ async #prepareOperation(request, signal) {
398
609
  const kind = SubscriptionGatewayValues.operationFor(request);
399
610
  if (kind === undefined)
400
611
  return SubscriptionGatewayValues.rejected("unknown-operation");
@@ -409,12 +620,18 @@ export class SubscriptionGateway {
409
620
  return SubscriptionGatewayValues.rejected("binding-busy");
410
621
  throw error;
411
622
  }
412
- const prepared = await this.#prepareSecurity(kind, request);
413
- if ("kind" in prepared)
414
- return prepared;
415
- return this.#perform(prepared, request.updates, request.signal);
623
+ if (signal.aborted)
624
+ return SubscriptionGatewayValues.rejected("denied");
625
+ return this.#prepareSecurity(kind, request, signal);
416
626
  }
417
- async #prepareSecurity(kind, request) {
627
+ /**
628
+ * Resolves the session and policy decision for one subscription operation.
629
+ *
630
+ * @param kind Requested subscription operation.
631
+ * @param request The request being processed.
632
+ * @param signal Host-managed operation cancellation.
633
+ * @returns Prepared operation or an authentication/policy rejection.
634
+ */ async #prepareSecurity(kind, request, signal) {
418
635
  const source = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
419
636
  if (source === undefined)
420
637
  return SubscriptionGatewayValues.rejected("malformed-request");
@@ -422,128 +639,304 @@ export class SubscriptionGateway {
422
639
  ? { principal: SubscriptionGatewayValues.publicPrincipal }
423
640
  : request.credential === undefined
424
641
  ? undefined
425
- : await this.#options.sessions.resolve(request.credential);
642
+ : await this.#options.sessions.resolve(request.credential, signal);
426
643
  if (session === undefined)
427
644
  return SubscriptionGatewayValues.rejected("unauthenticated");
645
+ const verified = this.#snapshotSession(session);
646
+ if (signal.aborted || verified.invalidated?.aborted)
647
+ return SubscriptionGatewayValues.rejected("denied");
428
648
  const authorization = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
429
649
  if (authorization === undefined ||
430
- !(await this.#options.authorize(session.principal, authorization)))
650
+ !(await this.#options.authorize(verified.principal, authorization)))
431
651
  return SubscriptionGatewayValues.rejected("forbidden");
432
- return this.#resolveTrusted(kind, request, source, session);
652
+ // Authorization can suspend while either signal changes.
653
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
654
+ if (signal.aborted || verified.invalidated?.aborted)
655
+ return SubscriptionGatewayValues.rejected("denied");
656
+ return this.#resolveTrusted(kind, request, source, verified);
657
+ }
658
+ /**
659
+ * Captures session identity, expiry and invalidation before downstream awaits.
660
+ *
661
+ * @param session Resolver result or explicit public session.
662
+ * @returns Detached immutable admission facts with the original signal.
663
+ */
664
+ #snapshotSession(session) {
665
+ const principal = snapshotTrustedReader({ principal: session.principal }).principal;
666
+ if (principal === undefined)
667
+ throw new Error("Resolved session has no principal.");
668
+ const expiresAt = session.expiresAt;
669
+ const invalidated = session.invalidated;
670
+ return Object.freeze({
671
+ principal,
672
+ ...(expiresAt === undefined
673
+ ? {}
674
+ : { expiresAt: Object.freeze({ seconds: expiresAt.seconds, nanos: expiresAt.nanos }) }),
675
+ ...(invalidated === undefined ? {} : { invalidated }),
676
+ });
433
677
  }
434
- async #resolveTrusted(kind, request, source, session) {
678
+ /**
679
+ * Builds detached trusted reader and actor facts after policy admission.
680
+ *
681
+ * @param kind Requested subscription operation.
682
+ * @param request The request being processed.
683
+ * @param source Decoded incoming subscription request.
684
+ * @param session Resolved gateway session or explicit public principal.
685
+ * @returns Prepared operation or a structured context rejection.
686
+ */ async #resolveTrusted(kind, request, source, session) {
435
687
  const contextRequest = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
436
688
  if (contextRequest === undefined)
437
689
  return SubscriptionGatewayValues.rejected("malformed-request");
438
- const context = SubscriptionGatewayValues.trustedContext(await this.#options.contexts.resolve(session.principal, contextRequest, this.#options.clock));
690
+ const resolved = await this.#options.contexts.resolve(session.principal, contextRequest, this.#options.clock);
691
+ const context = SubscriptionGatewayValues.trustedContext(resolved);
692
+ const reader = this.#trustedReader(session.principal, resolved);
439
693
  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))
694
+ const expiresAtMs = this.#expiresAtMs(session.expiresAt);
695
+ if (nowMs === undefined || !this.#validContext(source, context, nowMs, expiresAtMs))
447
696
  return SubscriptionGatewayValues.rejected("denied");
448
697
  return {
449
698
  source,
450
699
  context,
700
+ reader,
451
701
  expiresAtMs,
452
702
  nowMs,
703
+ ...(session.invalidated === undefined ? {} : { invalidated: session.invalidated }),
453
704
  };
454
705
  }
455
- #nowMs() {
706
+ /**
707
+ * Converts a verified session expiry into the gateway clock's millisecond unit.
708
+ *
709
+ * @param value Verified session expiry timestamp, if present.
710
+ * @returns The expiry in epoch milliseconds.
711
+ */
712
+ #expiresAtMs(value) {
713
+ return value === undefined
714
+ ? undefined
715
+ : SubscriptionGatewayValues.timestampMs(value.seconds, value.nanos);
716
+ }
717
+ /**
718
+ * Detaches the verified principal and current authority facts from the resolver.
719
+ *
720
+ * @param principal Verified principal from session resolution.
721
+ * @param resolved Resolved context and authority facts.
722
+ * @returns The detached verified reader facts.
723
+ */
724
+ #trustedReader(principal, resolved) {
725
+ return snapshotTrustedReader({
726
+ ...(this.#options.publicAccess === true ? {} : { principal }),
727
+ ...(this.#options.publicAccess === true || resolved.authorityFacts === undefined
728
+ ? {}
729
+ : { authorityFacts: resolved.authorityFacts }),
730
+ });
731
+ }
732
+ /**
733
+ * Checks session time and the resolver's trusted actor/tenant against the request.
734
+ *
735
+ * @param source Decoded incoming subscription request.
736
+ * @param context The trusted context for this operation.
737
+ * @param nowMs The current time in milliseconds.
738
+ * @param expiresAtMs The session expiry in milliseconds, when present.
739
+ * @returns The validated actor context.
740
+ */
741
+ #validContext(source, context, nowMs, expiresAtMs) {
742
+ return ((this.#options.publicAccess === true || expiresAtMs !== undefined) &&
743
+ (expiresAtMs === undefined || expiresAtMs > nowMs) &&
744
+ SubscriptionGatewayValues.matches(source.requestedContext, context));
745
+ }
746
+ /**
747
+ * Reads the configured clock as finite epoch milliseconds.
748
+ *
749
+ * @returns Current epoch milliseconds, if valid.
750
+ */ #nowMs() {
456
751
  const now = this.#options.clock.now();
457
752
  return SubscriptionGatewayValues.timestampMs(now.seconds, now.nanos);
458
753
  }
459
- async #perform(prepared, updates, signal) {
460
- const { source, context, expiresAtMs, nowMs } = prepared;
754
+ /**
755
+ * Runs the prepared subscription operation after security checks.
756
+ *
757
+ * @param prepared Policy-admitted subscription operation.
758
+ * @param updates The admitted subscription update sink.
759
+ * @param signal The cancellation signal for this operation.
760
+ * @returns The admitted subscription outcome.
761
+ */ async #perform(prepared, updates, signal) {
762
+ const { source, context, reader, expiresAtMs, nowMs } = prepared;
461
763
  const rewritten = SubscriptionGatewayValues.rewrite(source, context);
764
+ if (signal.aborted)
765
+ return SubscriptionGatewayValues.rejected("denied");
462
766
  if (source.kind === "subscribe")
463
- return this.#subscribe(rewritten, context, expiresAtMs);
767
+ return this.#subscribe(rewritten, context, reader, expiresAtMs, signal);
464
768
  const id = source.subscription.id?.value;
465
769
  if (id === undefined || id.length === 0)
466
770
  return SubscriptionGatewayValues.rejected("denied");
467
- return source.kind === "activate"
468
- ? this.#activate(id, context, nowMs, expiresAtMs, updates ?? SubscriptionGatewayValues.discardUpdate, signal)
469
- : this.#cancel(id, context, nowMs);
771
+ if (source.kind === "cancel") {
772
+ const result = await this.#cancel(id, context, nowMs, reader);
773
+ // Binding cleanup can finish after this invocation is invalidated.
774
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
775
+ return signal.aborted ? SubscriptionGatewayValues.rejected("denied") : result;
776
+ }
777
+ return this.#activate(id, context, reader, nowMs, expiresAtMs, updates ?? SubscriptionGatewayValues.discardUpdate, signal);
470
778
  }
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
- };
779
+ /**
780
+ * Verifies membership and activates a retained subscription.
781
+ *
782
+ * @param id The stable identifier for this record.
783
+ * @param context The trusted context for this operation.
784
+ * @param reader The verified reader facts for this operation.
785
+ * @param nowMs The current time in milliseconds.
786
+ * @param expiresAtMs The session expiry in milliseconds, when present.
787
+ * @param updates The admitted subscription update sink.
788
+ * @param signal The cancellation signal for this operation.
789
+ * @returns The update stream after admission.
790
+ */ async #activate(id, context, reader, nowMs, expiresAtMs, updates, signal) {
477
791
  if (signal?.aborted)
478
792
  return SubscriptionGatewayValues.rejected("denied");
479
793
  if (this.#options.publicAccess === true)
480
794
  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
795
  let activationFailure;
488
796
  let outcome = SubscriptionGatewayValues.rejected("denied");
489
797
  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),
798
+ outcome = await this.#activationEffect(id, context, reader, nowMs, expiresAtMs, updates, signal);
799
+ }
800
+ catch (error) {
801
+ activationFailure = this.#activationError(error);
802
+ }
803
+ if (this.#options.publicAccess === true)
804
+ await this.#publicActivationCleanup(id, context, reader, nowMs, activationFailure);
805
+ if (activationFailure !== undefined)
806
+ throw activationFailure;
807
+ return outcome;
808
+ }
809
+ /**
810
+ * Preserves a non-Error activation failure as a throwable cause.
811
+ *
812
+ * @param error The failure being classified or propagated.
813
+ * @returns The structured activation error.
814
+ */
815
+ #activationError(error) {
816
+ return error instanceof Error
817
+ ? error
818
+ : new Error("Public subscription activation failed with a non-Error value.", {
819
+ cause: error,
496
820
  });
497
- outcome =
498
- result.kind === "activated"
499
- ? { kind: "activated" }
500
- : SubscriptionGatewayValues.rejected("denied");
821
+ }
822
+ /**
823
+ * Releases a public definition while retaining both activation and cleanup failures.
824
+ *
825
+ * @param id The stable identifier for this record.
826
+ * @param context The trusted context for this operation.
827
+ * @param reader The verified reader facts for this operation.
828
+ * @param nowMs The current time in milliseconds.
829
+ * @param activationFailure Failure raised while activating the subscription.
830
+ * @returns Completion after the operation settles.
831
+ */
832
+ async #publicActivationCleanup(id, context, reader, nowMs, activationFailure) {
833
+ try {
834
+ await this.#cancel(id, context, this.#nowMs() ?? nowMs, reader);
835
+ }
836
+ catch (error) {
837
+ const cleanupFailure = error instanceof Error
838
+ ? error
839
+ : new Error("Public subscription cleanup failed with a non-Error value.", {
840
+ cause: error,
841
+ });
842
+ if (activationFailure !== undefined)
843
+ throw new AggregateError([activationFailure, cleanupFailure], "public subscription cleanup failed");
844
+ throw cleanupFailure;
845
+ }
846
+ }
847
+ /**
848
+ * Runs one reader-bound binding activation under cancellation and session expiry.
849
+ *
850
+ * @param id The stable identifier for this record.
851
+ * @param context The trusted context for this operation.
852
+ * @param reader The verified reader facts for this operation.
853
+ * @param nowMs The current time in milliseconds.
854
+ * @param expiresAtMs The session expiry in milliseconds, when present.
855
+ * @param updates The admitted subscription update sink.
856
+ * @param signal The cancellation signal for this operation.
857
+ * @returns The tracked activation effect.
858
+ */
859
+ async #activationEffect(id, context, reader, nowMs, expiresAtMs, updates, signal) {
860
+ const activeController = new AbortController();
861
+ const active = activeController.signal;
862
+ /**
863
+ * Cancels this activation when the caller aborts.
864
+ */
865
+ const abort = () => {
866
+ activeController.abort();
867
+ };
868
+ signal?.addEventListener("abort", abort, { once: true });
869
+ const expiry = this.#activationExpiry(expiresAtMs, nowMs, activeController);
870
+ try {
871
+ const result = await this.#activateBinding(id, context, reader, nowMs, active, updates);
872
+ return result.kind === "activated"
873
+ ? { kind: "activated" }
874
+ : SubscriptionGatewayValues.rejected("denied");
501
875
  }
502
876
  catch (error) {
503
877
  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
- });
878
+ return SubscriptionGatewayValues.rejected("binding-busy");
879
+ throw error;
512
880
  }
513
881
  finally {
514
882
  if (expiry !== undefined)
515
883
  clearTimeout(expiry);
516
884
  signal?.removeEventListener("abort", abort);
517
885
  }
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
886
  }
540
- #cancel(id, context, nowMs) {
887
+ /**
888
+ * Cancels activation when the admitted reader session reaches its expiry.
889
+ *
890
+ * @param expiresAtMs The session expiry in milliseconds, when present.
891
+ * @param nowMs The current time in milliseconds.
892
+ * @param controller Abort controller for the active operation.
893
+ * @returns The pending activation expiry.
894
+ */
895
+ #activationExpiry(expiresAtMs, nowMs, controller) {
896
+ return expiresAtMs === undefined
897
+ ? undefined
898
+ : setTimeout(() => {
899
+ controller.abort();
900
+ }, Math.max(0, expiresAtMs - nowMs));
901
+ }
902
+ /**
903
+ * Sends one reader-bound activation to the durable binding port.
904
+ *
905
+ * @param id The stable identifier for this record.
906
+ * @param context The trusted context for this operation.
907
+ * @param reader The verified reader facts for this operation.
908
+ * @param nowMs The current time in milliseconds.
909
+ * @param signal The cancellation signal for this operation.
910
+ * @param updates The admitted subscription update sink.
911
+ * @returns The activated reader-bound definition.
912
+ */
913
+ #activateBinding(id, context, reader, nowMs, signal, updates) {
914
+ return this.#options.bindings.activate({
915
+ id,
916
+ context,
917
+ reader: ReaderIdentities.from(reader),
918
+ nowMs,
919
+ signal,
920
+ onDefinition: (definition, effectSignal) => this.#forwardActivate({ ...definition, reader }, updates, effectSignal),
921
+ });
922
+ }
923
+ /**
924
+ * Verifies reader membership and removes a retained subscription.
925
+ *
926
+ * @param id The stable identifier for this record.
927
+ * @param context The trusted context for this operation.
928
+ * @param nowMs The current time in milliseconds.
929
+ * @param reader The verified reader facts for this operation.
930
+ * @returns The cancellation acknowledgment.
931
+ */ #cancel(id, context, nowMs, reader = {}) {
932
+ const identity = ReaderIdentities.from(reader);
541
933
  const active = this.#cancellations.get(id) ?? [];
542
- const matching = active.find((entry) => SubscriptionGatewayValues.contextsEqual(entry.context, context));
934
+ const matching = active.find((entry) => SubscriptionGatewayValues.contextsEqual(entry.context, context) &&
935
+ ReaderIdentities.matches(entry.reader, identity));
543
936
  if (matching !== undefined)
544
937
  return matching.promise;
545
- const promise = this.#cancelOnce(id, context, nowMs);
546
- const entry = { context: clone(ActorContextSchema, context), promise };
938
+ const promise = this.#cancelOnce(id, context, nowMs, reader);
939
+ const entry = { context: clone(ActorContextSchema, context), reader: identity, promise };
547
940
  active.push(entry);
548
941
  this.#cancellations.set(id, active);
549
942
  // spine-log-boundary: auth.subscription_cancel_finally_observer
@@ -561,13 +954,22 @@ export class SubscriptionGateway {
561
954
  .catch(() => undefined);
562
955
  return promise;
563
956
  }
564
- async #cancelOnce(id, context, nowMs) {
957
+ /**
958
+ * Serializes one cancellation against concurrent work for its ID.
959
+ *
960
+ * @param id The stable identifier for this record.
961
+ * @param context The trusted context for this operation.
962
+ * @param nowMs The current time in milliseconds.
963
+ * @param reader The verified reader facts for this operation.
964
+ * @returns The cancellation outcome.
965
+ */ async #cancelOnce(id, context, nowMs, reader) {
565
966
  try {
566
967
  const result = await this.#options.bindings.cancel({
567
968
  id,
568
969
  context,
970
+ reader: ReaderIdentities.from(reader),
569
971
  nowMs,
570
- onDefinition: (definition, effectSignal) => this.#forwardCancel(definition, effectSignal),
972
+ onDefinition: (definition, effectSignal) => this.#forwardCancel({ ...definition, reader }, effectSignal),
571
973
  });
572
974
  if (result.kind === "denied")
573
975
  return SubscriptionGatewayValues.rejected("denied");
@@ -580,52 +982,156 @@ export class SubscriptionGateway {
580
982
  throw error;
581
983
  }
582
984
  }
583
- async #subscribe(bytes, context, expiresAtMs) {
985
+ /**
986
+ * Validates and retains a reader-bound subscription definition.
987
+ *
988
+ * @param bytes The serialized message bytes.
989
+ * @param context The trusted context for this operation.
990
+ * @param reader The verified reader facts for this operation.
991
+ * @param expiresAtMs The session expiry in milliseconds, when present.
992
+ * @param signal The admitted operation lifetime.
993
+ * @returns The new subscription definition.
994
+ */ async #subscribe(bytes, context, reader, expiresAtMs, signal) {
584
995
  const wire = await this.#options.bindings.create({
585
996
  topic: { kind: "subscription-topic", bytes: bytes.slice() },
997
+ reader: ReaderIdentities.from(reader),
586
998
  ...(expiresAtMs === undefined ? {} : { whenExpires: expiresAtMs }),
587
999
  });
1000
+ const admittedWire = { ...wire, reader };
588
1001
  const id = fromBinary(SubscriptionSchema, wire.bytes).id?.value;
589
1002
  if (id === undefined || id.length === 0)
590
1003
  throw new Error("retained subscription has no ID");
591
1004
  const controller = new AbortController();
1005
+ if (signal.aborted) {
1006
+ await this.#discardBinding(id, context, reader, this.#nowMs() ?? 0);
1007
+ return SubscriptionGatewayValues.rejected("denied");
1008
+ }
1009
+ return this.#deliverCreated(id, context, reader, expiresAtMs, admittedWire, controller, signal);
1010
+ }
1011
+ /**
1012
+ * Completes backend Subscribe and checks the same operation after each await.
1013
+ *
1014
+ * @param id Stable created definition ID.
1015
+ * @param context Trusted actor context.
1016
+ * @param reader Verified reader facts.
1017
+ * @param expiresAtMs Session expiry, when present.
1018
+ * @param wire Created definition bytes.
1019
+ * @param controller Backend operation controller.
1020
+ * @param signal Admitted operation lifetime.
1021
+ * @returns Definition only while its reader remains admitted.
1022
+ */
1023
+ async #deliverCreated(id, context, reader, expiresAtMs, wire, controller, signal) {
592
1024
  try {
593
1025
  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) };
610
1026
  }
611
1027
  catch (error) {
612
- 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
- });
620
- // spine-log-boundary: auth.subscription_recovered_cleanup
621
- }
622
- catch {
623
- // Retain the row: a later request can retry backend cleanup.
624
- }
1028
+ await this.#discardFailed(id, context, reader, expiresAtMs, wire, controller);
625
1029
  throw error;
626
1030
  }
1031
+ if (signal.aborted)
1032
+ return this.#rejectCreated(id, context, reader, wire, controller);
1033
+ const result = await this.#confirmCreated(id, context, reader, expiresAtMs, wire, controller);
1034
+ // Confirmation can suspend in expiry cleanup while admission ends.
1035
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1036
+ if (!signal.aborted || result.kind !== "subscribed")
1037
+ return result;
1038
+ return this.#rejectCreated(id, context, reader, wire, controller);
627
1039
  }
628
- #schedulePublicPending(id, context) {
1040
+ /**
1041
+ * Clears a created definition after admission ends without returning a credential.
1042
+ *
1043
+ * @param id Created definition ID.
1044
+ * @param context Trusted actor context.
1045
+ * @param reader Verified reader facts.
1046
+ * @param wire Created definition bytes.
1047
+ * @param controller Backend operation controller.
1048
+ * @returns Rejection after required cleanup completes.
1049
+ */
1050
+ async #rejectCreated(id, context, reader, wire, controller) {
1051
+ await this.#discardCreated(id, context, reader, this.#nowMs() ?? 0, wire, controller);
1052
+ return SubscriptionGatewayValues.rejected("denied");
1053
+ }
1054
+ /**
1055
+ * Retains the original backend failure while attempting conditional cleanup.
1056
+ *
1057
+ * @param id Created definition ID.
1058
+ * @param context Trusted actor context.
1059
+ * @param reader Verified reader facts.
1060
+ * @param expiresAtMs Session expiry, when present.
1061
+ * @param wire Created definition bytes.
1062
+ * @param controller Backend operation controller.
1063
+ * @returns Completion after cleanup is attempted.
1064
+ */
1065
+ async #discardFailed(id, context, reader, expiresAtMs, wire, controller) {
1066
+ try {
1067
+ await this.#discardCreated(id, context, reader, this.#nowMs() ?? expiresAtMs ?? 0, wire, controller);
1068
+ // spine-log-boundary: auth.subscription_recovered_cleanup
1069
+ }
1070
+ catch {
1071
+ // Retain the row: a later request can retry backend cleanup.
1072
+ }
1073
+ }
1074
+ /**
1075
+ * Returns an admitted public definition only while its reader session remains valid.
1076
+ *
1077
+ * @param id The stable identifier for this record.
1078
+ * @param context The trusted context for this operation.
1079
+ * @param reader The verified reader facts for this operation.
1080
+ * @param expiresAtMs The session expiry in milliseconds, when present.
1081
+ * @param wire Canonical serialized subscription bytes.
1082
+ * @param controller Abort controller for the active operation.
1083
+ * @returns The confirmed retained definition.
1084
+ */
1085
+ async #confirmCreated(id, context, reader, expiresAtMs, wire, controller) {
1086
+ const nowMs = this.#nowMs();
1087
+ if (nowMs === undefined || (expiresAtMs !== undefined && expiresAtMs <= nowMs)) {
1088
+ await this.#discardCreated(id, context, reader, nowMs ?? expiresAtMs ?? 0, wire, controller);
1089
+ return SubscriptionGatewayValues.rejected("denied");
1090
+ }
1091
+ return {
1092
+ kind: "subscribed",
1093
+ wire: { kind: "public-subscription", bytes: wire.bytes.slice() },
1094
+ };
1095
+ }
1096
+ /**
1097
+ * Compensates a created definition before it can escape admission.
1098
+ *
1099
+ * @param id The stable identifier for this record.
1100
+ * @param context The trusted context for this operation.
1101
+ * @param reader The verified reader facts for this operation.
1102
+ * @param nowMs The current time in milliseconds.
1103
+ * @param wire Canonical serialized subscription bytes.
1104
+ * @param controller Abort controller for the active operation.
1105
+ * @returns Completion after the operation settles.
1106
+ */
1107
+ async #discardCreated(id, context, reader, nowMs, wire, controller) {
1108
+ await this.#compensateDefinition(wire, controller);
1109
+ await this.#discardBinding(id, context, reader, nowMs);
1110
+ }
1111
+ /**
1112
+ * Clears a created local binding without assuming backend Subscribe began.
1113
+ *
1114
+ * @param id Created definition ID.
1115
+ * @param context Trusted actor context.
1116
+ * @param reader Verified reader facts.
1117
+ * @param nowMs Trusted cleanup time.
1118
+ * @returns Completion after local removal.
1119
+ */
1120
+ async #discardBinding(id, context, reader, nowMs) {
1121
+ await this.#options.bindings.cancel({
1122
+ id,
1123
+ context,
1124
+ reader: ReaderIdentities.from(reader),
1125
+ nowMs,
1126
+ onDefinition: () => Promise.resolve(),
1127
+ });
1128
+ }
1129
+ /**
1130
+ * Schedules activation retry for a public subscription.
1131
+ *
1132
+ * @param id The stable identifier for this record.
1133
+ * @param context The trusted context for this operation.
1134
+ */ #schedulePublicPending(id, context) {
629
1135
  this.#clearPublicPending(id);
630
1136
  const timer = setTimeout(() => {
631
1137
  if (this.#publicPendingTimers.get(id) !== timer)
@@ -641,24 +1147,52 @@ export class SubscriptionGateway {
641
1147
  }, SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS);
642
1148
  this.#publicPendingTimers.set(id, timer);
643
1149
  }
644
- #retryPublicPending(id, context) {
1150
+ /**
1151
+ * Retries pending public activation after backend availability changes.
1152
+ *
1153
+ * @param id The stable identifier for this record.
1154
+ * @param context The trusted context for this operation.
1155
+ */ #retryPublicPending(id, context) {
645
1156
  if (!this.#closed && !this.#publicPendingTimers.has(id))
646
1157
  this.#schedulePublicPending(id, context);
647
1158
  }
648
- #clearPublicPending(id) {
1159
+ /**
1160
+ * Clears the pending public activation timer for an ID.
1161
+ *
1162
+ * @param id The stable identifier for this record.
1163
+ */ #clearPublicPending(id) {
649
1164
  const timer = this.#publicPendingTimers.get(id);
650
1165
  if (timer !== undefined)
651
1166
  clearTimeout(timer);
652
1167
  this.#publicPendingTimers.delete(id);
653
1168
  }
654
- async #receiveBackend(wire, controller) {
1169
+ /**
1170
+ * Attaches the native backend subscription after admission.
1171
+ *
1172
+ * @param wire Canonical serialized subscription bytes.
1173
+ * @param controller Abort controller for the active operation.
1174
+ * @returns Completion after the operation settles.
1175
+ */ async #receiveBackend(wire, controller) {
655
1176
  return SubscriptionGatewayValues.withTimeout(this.#options.creator.subscribe(SubscriptionGatewayValues.copyPublic(wire), controller.signal, this.#limits.maxBackendEnvelopeBytes), this.#limits.operationTimeoutMs, controller);
656
1177
  }
657
- async #compensateDefinition(wire, controller) {
1178
+ /**
1179
+ * Removes a definition after a failed backend subscription step.
1180
+ *
1181
+ * @param wire Canonical serialized subscription bytes.
1182
+ * @param controller Abort controller for the active operation.
1183
+ * @returns Completion after the operation settles.
1184
+ */ async #compensateDefinition(wire, controller) {
658
1185
  const signal = controller.signal.aborted ? new AbortController().signal : controller.signal;
659
1186
  await this.#options.creator.cancel({ wire: SubscriptionGatewayValues.copyPublic(wire) }, signal);
660
1187
  }
661
- async #forwardActivate(definition, updates, signal) {
1188
+ /**
1189
+ * Forwards activation with the verified reader assertion.
1190
+ *
1191
+ * @param definition The serialized subscription definition.
1192
+ * @param updates The admitted subscription update sink.
1193
+ * @param signal The cancellation signal for this operation.
1194
+ * @returns Completion after the operation settles.
1195
+ */ async #forwardActivate(definition, updates, signal) {
662
1196
  const privateWire = SubscriptionGatewayValues.copyPublic(definition);
663
1197
  try {
664
1198
  const publicSubscription = fromBinary(SubscriptionSchema, definition.bytes);
@@ -666,6 +1200,8 @@ export class SubscriptionGateway {
666
1200
  wire: privateWire,
667
1201
  updates: async (update) => {
668
1202
  try {
1203
+ if (signal.aborted)
1204
+ return;
669
1205
  const decoded = fromBinary(SubscriptionUpdateSchema, update.bytes);
670
1206
  decoded.subscription = clone(SubscriptionSchema, publicSubscription);
671
1207
  await updates({
@@ -683,7 +1219,13 @@ export class SubscriptionGateway {
683
1219
  privateWire.bytes.fill(0);
684
1220
  }
685
1221
  }
686
- async #forwardCancel(definition, signal) {
1222
+ /**
1223
+ * Forwards cancellation with the verified reader assertion.
1224
+ *
1225
+ * @param definition The serialized subscription definition.
1226
+ * @param signal The cancellation signal for this operation.
1227
+ * @returns Completion after the operation settles.
1228
+ */ async #forwardCancel(definition, signal) {
687
1229
  const privateWire = SubscriptionGatewayValues.copyPublic(definition);
688
1230
  try {
689
1231
  await this.#options.creator.cancel({ wire: privateWire }, signal);
@@ -715,7 +1257,11 @@ const SubscriptionGatewayValues = Object.freeze({
715
1257
  * @returns Copied public subscription bytes.
716
1258
  */
717
1259
  copyPublic(wire) {
718
- return { kind: wire.kind, bytes: wire.bytes.slice() };
1260
+ return {
1261
+ kind: wire.kind,
1262
+ bytes: wire.bytes.slice(),
1263
+ ...(wire.reader === undefined ? {} : { reader: snapshotTrustedReader(wire.reader) }),
1264
+ };
719
1265
  },
720
1266
  /**
721
1267
  * Builds a successful subscription response.