@spine-event-engine/auth 2.0.0-snapshot.2

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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +112 -0
  3. package/REFERENCE.md +94 -0
  4. package/dist/gateway/dynamic-subscription-creator.d.ts +52 -0
  5. package/dist/gateway/dynamic-subscription-creator.d.ts.map +1 -0
  6. package/dist/gateway/dynamic-subscription-creator.js +71 -0
  7. package/dist/gateway/dynamic-subscription-creator.js.map +1 -0
  8. package/dist/gateway/dynamic-unary-forwarder.d.ts +130 -0
  9. package/dist/gateway/dynamic-unary-forwarder.d.ts.map +1 -0
  10. package/dist/gateway/dynamic-unary-forwarder.js +185 -0
  11. package/dist/gateway/dynamic-unary-forwarder.js.map +1 -0
  12. package/dist/gateway/index.d.ts +164 -0
  13. package/dist/gateway/index.d.ts.map +1 -0
  14. package/dist/gateway/index.js +195 -0
  15. package/dist/gateway/index.js.map +1 -0
  16. package/dist/index.d.ts +433 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +56 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/native/index.d.ts +182 -0
  21. package/dist/native/index.d.ts.map +1 -0
  22. package/dist/native/index.js +463 -0
  23. package/dist/native/index.js.map +1 -0
  24. package/dist/oidc/contracts.d.ts +334 -0
  25. package/dist/oidc/contracts.d.ts.map +1 -0
  26. package/dist/oidc/contracts.js +15 -0
  27. package/dist/oidc/contracts.js.map +1 -0
  28. package/dist/oidc/index.d.ts +41 -0
  29. package/dist/oidc/index.d.ts.map +1 -0
  30. package/dist/oidc/index.js +825 -0
  31. package/dist/oidc/index.js.map +1 -0
  32. package/dist/providers/index.d.ts +154 -0
  33. package/dist/providers/index.d.ts.map +1 -0
  34. package/dist/providers/index.js +574 -0
  35. package/dist/providers/index.js.map +1 -0
  36. package/dist/request/index.d.ts +10 -0
  37. package/dist/request/index.d.ts.map +1 -0
  38. package/dist/request/index.js +81 -0
  39. package/dist/request/index.js.map +1 -0
  40. package/dist/sessions/cookies.d.ts +104 -0
  41. package/dist/sessions/cookies.d.ts.map +1 -0
  42. package/dist/sessions/cookies.js +295 -0
  43. package/dist/sessions/cookies.js.map +1 -0
  44. package/dist/sessions/opaque.d.ts +163 -0
  45. package/dist/sessions/opaque.d.ts.map +1 -0
  46. package/dist/sessions/opaque.js +268 -0
  47. package/dist/sessions/opaque.js.map +1 -0
  48. package/dist/sessions/signed.d.ts +245 -0
  49. package/dist/sessions/signed.d.ts.map +1 -0
  50. package/dist/sessions/signed.js +534 -0
  51. package/dist/sessions/signed.js.map +1 -0
  52. package/dist/subscriptions/index.d.ts +463 -0
  53. package/dist/subscriptions/index.d.ts.map +1 -0
  54. package/dist/subscriptions/index.js +814 -0
  55. package/dist/subscriptions/index.js.map +1 -0
  56. package/dist/tsconfig.tsbuildinfo +1 -0
  57. package/package.json +37 -0
@@ -0,0 +1,814 @@
1
+ /*
2
+ * Copyright 2026, CodeMatters. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5
+ * in compliance with the License. You may obtain a copy of the License at
6
+ *
7
+ * https://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
10
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11
+ * or implied. See the License for the specific language governing permissions and limitations under
12
+ * the License.
13
+ */
14
+ import { clone, create, fromBinary, toBinary } from "@bufbuild/protobuf";
15
+ import { SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS } from "@spine-event-engine/core/internal/subscription-lifecycle";
16
+ import { ActorContextSchema, TenantIdSchema } from "@spine-event-engine/proto";
17
+ import { SubscriptionSchema, SubscriptionUpdateSchema, TopicSchema, } from "@spine-event-engine/proto/client";
18
+ import { IncomingRequests } from "../request/index.js";
19
+ const defaultLimits = {
20
+ maxRequestBytes: 1_048_576,
21
+ maxBackendEnvelopeBytes: 1_048_576,
22
+ pendingOperationLimit: 1,
23
+ operationTimeoutMs: 30_000,
24
+ shutdownTimeoutMs: 1_000,
25
+ };
26
+ /**
27
+ * In-memory reference store. It serializes transitions, copies all ingress/egress bytes, and makes close terminal.
28
+ */
29
+ export class InMemorySubscriptionBindings {
30
+ #bindings = new Map();
31
+ #nextId;
32
+ #disposeCallback;
33
+ #closed = false;
34
+ #limits;
35
+ /**
36
+ * Creates the in-memory binding store.
37
+ * @param options Supplies identifiers, process limits, and disposal behavior.
38
+ */
39
+ constructor(options) {
40
+ this.#nextId = options.nextId;
41
+ this.#limits = SubscriptionGatewayValues.limits(options.limits);
42
+ this.#disposeCallback = options.dispose;
43
+ }
44
+ /**
45
+ * Returns the number of retained private bindings for lifecycle observability.
46
+ * @returns Returns the retained binding count.
47
+ */
48
+ get size() {
49
+ return this.#bindings.size;
50
+ }
51
+ /**
52
+ * Removes already-expired logical bindings without a background timer.
53
+ * @param nowMs Supplies the current time in milliseconds.
54
+ * @returns Completes after expired envelopes are removed.
55
+ */
56
+ purgeExpired(nowMs) {
57
+ for (const [id, binding] of this.#bindings)
58
+ if (binding.expiresAtMs !== undefined && binding.expiresAtMs <= nowMs)
59
+ this.#expire(id, binding);
60
+ return Promise.resolve();
61
+ }
62
+ /**
63
+ * Closes the store by aborting work and waiting for bounded cleanup.
64
+ *
65
+ * Cooperative callbacks allow cleanup to complete before this method resolves.
66
+ * Non-cooperative raw callbacks cause an `AggregateError` after
67
+ * `shutdownTimeoutMs`; their exactly-once disposal remains queued until they
68
+ * settle.
69
+ *
70
+ * @returns Completes after bounded cooperative cleanup, or rejects with an
71
+ * `AggregateError` when cleanup exceeds its shutdown limit.
72
+ */
73
+ async close() {
74
+ this.#closed = true;
75
+ const closing = [...this.#bindings.entries()];
76
+ for (const [, binding] of closing)
77
+ binding.controller.abort();
78
+ const results = await Promise.allSettled(closing.map(([id, binding]) => this.#waitForShutdown(this.#disposeAfterWork(id, binding))));
79
+ const failures = results
80
+ .filter((result) => result.status === "rejected")
81
+ .map((result) => result.reason);
82
+ if (failures.length > 0)
83
+ throw new AggregateError(failures, "subscription shutdown cleanup failed");
84
+ }
85
+ /**
86
+ * Creates an inactive binding from a trusted rewritten topic.
87
+ * @param input Supplies the topic and expiry.
88
+ * @returns Returns the retained public subscription wire.
89
+ */
90
+ create(input) {
91
+ try {
92
+ if (this.#closed)
93
+ throw new Error("subscription bindings are closed");
94
+ const id = this.#nextId();
95
+ if (id.length === 0 || this.#bindings.has(id))
96
+ throw new Error("subscription ID must be unique");
97
+ const topic = fromBinary(TopicSchema, input.topic.bytes);
98
+ if (topic.context === undefined)
99
+ throw new Error("subscription topic has no trusted context");
100
+ const wire = SubscriptionGatewayValues.subscribed(id, input.topic.bytes);
101
+ if (wire.kind !== "subscribed")
102
+ throw new Error("subscription wire creation failed");
103
+ this.#bindings.set(id, {
104
+ definition: wire.wire.bytes.slice(),
105
+ context: clone(ActorContextSchema, topic.context),
106
+ expiresAtMs: input.whenExpires,
107
+ state: "inactive",
108
+ controller: new AbortController(),
109
+ tail: Promise.resolve(),
110
+ effectTail: Promise.resolve(),
111
+ pending: 0,
112
+ expiring: false,
113
+ cancelRequested: false,
114
+ });
115
+ return Promise.resolve(SubscriptionGatewayValues.copyPublic(wire.wire));
116
+ }
117
+ catch (error) {
118
+ return Promise.reject(error instanceof Error ? error : new Error("subscription binding creation failed"));
119
+ }
120
+ }
121
+ /**
122
+ * Activates only an owned inactive binding and restores retry state after callback failure.
123
+ * @param input Supplies ownership facts and the backend callback.
124
+ * @returns Returns the ownership transition outcome.
125
+ */
126
+ async activate(input) {
127
+ if (input.signal.aborted)
128
+ return { kind: "denied" };
129
+ if (this.#precheck(input) !== "owned")
130
+ return { kind: "denied" };
131
+ return this.#coordinate(input.id, async () => {
132
+ const binding = await this.#owned(input);
133
+ if (input.signal.aborted || binding?.state !== "inactive")
134
+ return { kind: "denied" };
135
+ binding.state = "active";
136
+ binding.controller = new AbortController();
137
+ const abort = () => {
138
+ binding.controller.abort();
139
+ };
140
+ input.signal.addEventListener("abort", abort, { once: true });
141
+ try {
142
+ await this.#runActiveEffect(binding, input.onDefinition);
143
+ }
144
+ catch (error) {
145
+ if (binding.cancelRequested)
146
+ return { kind: "activated" };
147
+ binding.state = "inactive";
148
+ throw error;
149
+ }
150
+ finally {
151
+ input.signal.removeEventListener("abort", abort);
152
+ }
153
+ return { kind: "activated" };
154
+ });
155
+ }
156
+ /**
157
+ * Cancels an owned binding and retains retry state after failed cleanup.
158
+ * @param input Supplies ownership facts and the backend callback.
159
+ * @returns Returns the ownership transition outcome.
160
+ */
161
+ async cancel(input) {
162
+ const admission = this.#precheck(input);
163
+ if (admission === "absent")
164
+ return { kind: "closed" };
165
+ if (admission !== "owned")
166
+ return { kind: "denied" };
167
+ const active = this.#bindings.get(input.id);
168
+ if (active?.state === "active") {
169
+ active.cancelRequested = true;
170
+ active.controller.abort();
171
+ }
172
+ return this.#coordinate(input.id, async () => {
173
+ const binding = await this.#owned(input);
174
+ if (binding === undefined)
175
+ return { kind: "closed" };
176
+ if (binding.state === "closed")
177
+ return { kind: "closed" };
178
+ binding.state = "cancelling";
179
+ binding.controller = new AbortController();
180
+ await this.#runEffect(binding, input.onDefinition);
181
+ this.#dispose(input.id, binding);
182
+ return { kind: "closed" };
183
+ });
184
+ }
185
+ #owned(input) {
186
+ const binding = this.#bindings.get(input.id);
187
+ if (binding === undefined)
188
+ return Promise.resolve(undefined);
189
+ if (binding.expiresAtMs !== undefined && binding.expiresAtMs <= input.nowMs) {
190
+ this.#expire(input.id, binding);
191
+ return Promise.resolve(undefined);
192
+ }
193
+ return SubscriptionGatewayValues.contextsEqual(binding.context, input.context)
194
+ ? Promise.resolve(binding)
195
+ : Promise.resolve(undefined);
196
+ }
197
+ #precheck(input) {
198
+ const binding = this.#bindings.get(input.id);
199
+ if (binding === undefined)
200
+ return "absent";
201
+ if (!SubscriptionGatewayValues.contextsEqual(binding.context, input.context))
202
+ return "denied";
203
+ if (binding.expiring ||
204
+ (binding.expiresAtMs !== undefined && binding.expiresAtMs <= input.nowMs)) {
205
+ this.#expire(input.id, binding);
206
+ return "denied";
207
+ }
208
+ return "owned";
209
+ }
210
+ #expire(id, binding) {
211
+ if (binding.expiring || this.#bindings.get(id) !== binding)
212
+ return;
213
+ binding.expiring = true;
214
+ binding.controller.abort();
215
+ // spine-log-boundary: auth.subscription_expiry_cleanup
216
+ void this.#disposeAfterWork(id, binding).catch(() => undefined);
217
+ }
218
+ #dispose(id, binding) {
219
+ binding.definition.fill(0);
220
+ binding.state = "closed";
221
+ this.#bindings.delete(id);
222
+ }
223
+ async #runEffect(binding, callback) {
224
+ const effect = this.#startEffect(binding, callback);
225
+ await SubscriptionGatewayValues.withTimeout(effect, this.#limits.operationTimeoutMs, binding.controller);
226
+ }
227
+ async #runActiveEffect(binding, callback) {
228
+ let observeAbort = () => undefined;
229
+ const aborted = new Promise((resolve) => {
230
+ observeAbort = () => {
231
+ resolve("aborted");
232
+ };
233
+ binding.controller.signal.addEventListener("abort", observeAbort, { once: true });
234
+ });
235
+ try {
236
+ if (binding.controller.signal.aborted)
237
+ throw new Error("subscription operation aborted");
238
+ const effect = this.#startEffect(binding, callback);
239
+ const result = await Promise.race([effect.then(() => "settled"), aborted]);
240
+ if (result === "settled" || binding.cancelRequested)
241
+ return;
242
+ }
243
+ finally {
244
+ binding.controller.signal.removeEventListener("abort", observeAbort);
245
+ }
246
+ throw new Error("subscription operation aborted");
247
+ }
248
+ #startEffect(binding, callback) {
249
+ const definition = SubscriptionGatewayValues.copyPublic({
250
+ kind: "public-subscription",
251
+ bytes: binding.definition,
252
+ });
253
+ let effect;
254
+ try {
255
+ effect = Promise.resolve(callback(definition, binding.controller.signal));
256
+ }
257
+ catch (error) {
258
+ effect = Promise.reject(error instanceof Error
259
+ ? error
260
+ : new Error("Subscription backend callback threw a non-Error value.", { cause: error }));
261
+ }
262
+ // spine-log-boundary: auth.subscription_effect_settlement
263
+ const settled = effect.then(() => undefined, () => undefined);
264
+ binding.effectTail = binding.effectTail.then(() => settled);
265
+ void settled.then(() => definition.bytes.fill(0));
266
+ return effect;
267
+ }
268
+ #disposeAfterWork(id, binding) {
269
+ const cleanup = binding.tail
270
+ .then(() => binding.effectTail)
271
+ .then(() => this.#disposeWithCallback(id, binding));
272
+ // spine-log-boundary: auth.subscription_cleanup_tail
273
+ binding.tail = cleanup.catch(() => undefined);
274
+ // spine-log-boundary: auth.subscription_cleanup_observer
275
+ void cleanup.catch(() => undefined);
276
+ return cleanup;
277
+ }
278
+ #waitForShutdown(cleanup) {
279
+ return SubscriptionGatewayValues.withTimeout(cleanup, this.#limits.shutdownTimeoutMs, new AbortController());
280
+ }
281
+ async #disposeWithCallback(id, binding) {
282
+ if (this.#bindings.get(id) !== binding)
283
+ return;
284
+ const definition = SubscriptionGatewayValues.copyPublic({
285
+ kind: "public-subscription",
286
+ bytes: binding.definition,
287
+ });
288
+ this.#dispose(id, binding);
289
+ const controller = new AbortController();
290
+ try {
291
+ await SubscriptionGatewayValues.withTimeout(this.#disposeCallback(definition, controller.signal), this.#limits.shutdownTimeoutMs, controller);
292
+ }
293
+ finally {
294
+ definition.bytes.fill(0);
295
+ }
296
+ }
297
+ async #coordinate(id, operation) {
298
+ const binding = this.#bindings.get(id);
299
+ if (binding === undefined)
300
+ return operation();
301
+ if (binding.pending > this.#limits.pendingOperationLimit)
302
+ throw new Error("binding-busy");
303
+ binding.pending++;
304
+ const previous = binding.tail;
305
+ let release = () => undefined;
306
+ binding.tail = new Promise((resolve) => {
307
+ release = resolve;
308
+ });
309
+ await previous;
310
+ try {
311
+ if (this.#closed)
312
+ throw new Error("subscription bindings are closed");
313
+ return await operation();
314
+ }
315
+ finally {
316
+ binding.pending--;
317
+ release();
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * B3 gateway.
323
+ * It serializes operations, admits one transport snapshot, and never reveals backend envelopes.
324
+ */
325
+ export class SubscriptionGateway {
326
+ #options;
327
+ #limits;
328
+ #expiryTimers = new Set();
329
+ #publicPendingTimers = new Map();
330
+ #closed = false;
331
+ /**
332
+ * Creates the subscription gateway.
333
+ * @param options Supplies admission, authorization, binding, and backend collaborators.
334
+ */
335
+ constructor(options) {
336
+ if ((options.sessions === undefined) === (options.publicAccess !== true))
337
+ throw new Error("Subscription gateway requires exactly one of sessions or publicAccess.");
338
+ this.#options = options;
339
+ this.#limits = SubscriptionGatewayValues.limits(options.limits);
340
+ }
341
+ /**
342
+ * Handles one admitted subscription RPC request.
343
+ * @param request Supplies the copied request admission facts.
344
+ * @returns Returns the opaque operation result.
345
+ */
346
+ async handle(request) {
347
+ if (this.#closed)
348
+ return SubscriptionGatewayValues.rejected("denied");
349
+ const admitted = SubscriptionGatewayValues.admit(request, this.#limits);
350
+ if (admitted === undefined)
351
+ return SubscriptionGatewayValues.rejected("request-too-large");
352
+ try {
353
+ return await this.#handleOperation(admitted);
354
+ }
355
+ finally {
356
+ admitted.wire.bytes.fill(0);
357
+ }
358
+ }
359
+ /**
360
+ * Closes admission and closes retained bindings.
361
+ * @returns Completes after retained bindings close.
362
+ */
363
+ async close() {
364
+ this.#closed = true;
365
+ for (const timer of this.#expiryTimers)
366
+ clearTimeout(timer);
367
+ this.#expiryTimers.clear();
368
+ for (const timer of this.#publicPendingTimers.values())
369
+ clearTimeout(timer);
370
+ this.#publicPendingTimers.clear();
371
+ await this.#options.bindings.close();
372
+ }
373
+ /**
374
+ * Schedules finite local expiry cleanup for a recovered durable definition.
375
+ * @param whenExpires Supplies the retained expiry in epoch milliseconds.
376
+ */
377
+ scheduleExpiry(whenExpires) {
378
+ const nowMs = this.#nowMs();
379
+ if (nowMs === undefined || this.#closed)
380
+ return;
381
+ const timer = setTimeout(() => {
382
+ this.#expiryTimers.delete(timer);
383
+ // spine-log-boundary: auth.subscription_timer_purge
384
+ void this.#options.bindings.purgeExpired(whenExpires).catch(() => undefined);
385
+ }, Math.max(0, whenExpires - nowMs));
386
+ this.#expiryTimers.add(timer);
387
+ }
388
+ async #handleOperation(request) {
389
+ const kind = SubscriptionGatewayValues.operationFor(request);
390
+ if (kind === undefined)
391
+ return SubscriptionGatewayValues.rejected("unknown-operation");
392
+ const nowMs = this.#nowMs();
393
+ if (nowMs === undefined)
394
+ return SubscriptionGatewayValues.rejected("denied");
395
+ try {
396
+ await this.#options.bindings.purgeExpired(nowMs);
397
+ }
398
+ catch (error) {
399
+ if (error instanceof Error && error.message === "binding-busy")
400
+ return SubscriptionGatewayValues.rejected("binding-busy");
401
+ throw error;
402
+ }
403
+ const prepared = await this.#prepareSecurity(kind, request);
404
+ if ("kind" in prepared)
405
+ return prepared;
406
+ return this.#perform(prepared, request.updates, request.signal);
407
+ }
408
+ async #prepareSecurity(kind, request) {
409
+ const source = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
410
+ if (source === undefined)
411
+ return SubscriptionGatewayValues.rejected("malformed-request");
412
+ const session = this.#options.publicAccess === true
413
+ ? { principal: SubscriptionGatewayValues.publicPrincipal }
414
+ : request.credential === undefined
415
+ ? undefined
416
+ : await this.#options.sessions.resolve(request.credential);
417
+ if (session === undefined)
418
+ return SubscriptionGatewayValues.rejected("unauthenticated");
419
+ const authorization = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
420
+ if (authorization === undefined ||
421
+ !(await this.#options.authorize(session.principal, authorization)))
422
+ return SubscriptionGatewayValues.rejected("forbidden");
423
+ return this.#resolveTrusted(kind, request, source, session);
424
+ }
425
+ async #resolveTrusted(kind, request, source, session) {
426
+ const contextRequest = SubscriptionGatewayValues.decode(kind, request.wire.bytes, request.transport);
427
+ if (contextRequest === undefined)
428
+ return SubscriptionGatewayValues.rejected("malformed-request");
429
+ const context = SubscriptionGatewayValues.trustedContext(await this.#options.contexts.resolve(session.principal, contextRequest, this.#options.clock));
430
+ const nowMs = this.#nowMs();
431
+ const expiresAtMs = session.expiresAt === undefined
432
+ ? undefined
433
+ : SubscriptionGatewayValues.timestampMs(session.expiresAt.seconds, session.expiresAt.nanos);
434
+ if (nowMs === undefined ||
435
+ (this.#options.publicAccess !== true && expiresAtMs === undefined) ||
436
+ (expiresAtMs !== undefined && expiresAtMs <= nowMs) ||
437
+ !SubscriptionGatewayValues.matches(source.requestedContext, context))
438
+ return SubscriptionGatewayValues.rejected("denied");
439
+ return {
440
+ source,
441
+ context,
442
+ expiresAtMs,
443
+ nowMs,
444
+ };
445
+ }
446
+ #nowMs() {
447
+ const now = this.#options.clock.now();
448
+ return SubscriptionGatewayValues.timestampMs(now.seconds, now.nanos);
449
+ }
450
+ async #perform(prepared, updates, signal) {
451
+ const { source, context, expiresAtMs, nowMs } = prepared;
452
+ const rewritten = SubscriptionGatewayValues.rewrite(source, context);
453
+ if (source.kind === "subscribe")
454
+ return this.#subscribe(rewritten, context, expiresAtMs);
455
+ const id = source.subscription.id?.value;
456
+ if (id === undefined || id.length === 0)
457
+ return SubscriptionGatewayValues.rejected("denied");
458
+ return source.kind === "activate"
459
+ ? this.#activate(id, context, nowMs, expiresAtMs, updates ?? SubscriptionGatewayValues.discardUpdate, signal)
460
+ : this.#cancel(id, context, nowMs);
461
+ }
462
+ async #activate(id, context, nowMs, expiresAtMs, updates, signal) {
463
+ const activeController = new AbortController();
464
+ const active = activeController.signal;
465
+ const abort = () => {
466
+ activeController.abort();
467
+ };
468
+ if (signal?.aborted)
469
+ return SubscriptionGatewayValues.rejected("denied");
470
+ if (this.#options.publicAccess === true)
471
+ this.#clearPublicPending(id);
472
+ signal?.addEventListener("abort", abort, { once: true });
473
+ const expiry = expiresAtMs === undefined
474
+ ? undefined
475
+ : setTimeout(() => {
476
+ activeController.abort();
477
+ }, Math.max(0, expiresAtMs - nowMs));
478
+ let activationFailure;
479
+ let outcome = SubscriptionGatewayValues.rejected("denied");
480
+ try {
481
+ const result = await this.#options.bindings.activate({
482
+ id,
483
+ context,
484
+ nowMs,
485
+ signal: active,
486
+ onDefinition: (definition, effectSignal) => this.#forwardActivate(definition, updates, effectSignal),
487
+ });
488
+ outcome =
489
+ result.kind === "activated"
490
+ ? { kind: "activated" }
491
+ : SubscriptionGatewayValues.rejected("denied");
492
+ }
493
+ catch (error) {
494
+ if (error instanceof Error && error.message === "binding-busy")
495
+ outcome = SubscriptionGatewayValues.rejected("binding-busy");
496
+ else
497
+ activationFailure =
498
+ error instanceof Error
499
+ ? error
500
+ : new Error("Public subscription activation failed with a non-Error value.", {
501
+ cause: error,
502
+ });
503
+ }
504
+ finally {
505
+ if (expiry !== undefined)
506
+ clearTimeout(expiry);
507
+ signal?.removeEventListener("abort", abort);
508
+ }
509
+ let cleanupFailure;
510
+ if (this.#options.publicAccess === true)
511
+ try {
512
+ await this.#cancel(id, context, this.#nowMs() ?? nowMs);
513
+ }
514
+ catch (error) {
515
+ cleanupFailure =
516
+ error instanceof Error
517
+ ? error
518
+ : new Error("Public subscription cleanup failed with a non-Error value.", {
519
+ cause: error,
520
+ });
521
+ }
522
+ if (activationFailure !== undefined) {
523
+ if (cleanupFailure !== undefined)
524
+ throw new AggregateError([activationFailure, cleanupFailure], "public subscription cleanup failed");
525
+ throw activationFailure;
526
+ }
527
+ if (cleanupFailure !== undefined)
528
+ throw cleanupFailure;
529
+ return outcome;
530
+ }
531
+ async #cancel(id, context, nowMs) {
532
+ this.#clearPublicPending(id);
533
+ try {
534
+ const result = await this.#options.bindings.cancel({
535
+ id,
536
+ context,
537
+ nowMs,
538
+ onDefinition: (definition, effectSignal) => this.#forwardCancel(definition, effectSignal),
539
+ });
540
+ return result.kind === "denied"
541
+ ? SubscriptionGatewayValues.rejected("denied")
542
+ : { kind: "cancelled" };
543
+ }
544
+ catch (error) {
545
+ if (error instanceof Error && error.message === "binding-busy")
546
+ return SubscriptionGatewayValues.rejected("binding-busy");
547
+ throw error;
548
+ }
549
+ }
550
+ async #subscribe(bytes, context, expiresAtMs) {
551
+ const wire = await this.#options.bindings.create({
552
+ topic: { kind: "subscription-topic", bytes: bytes.slice() },
553
+ ...(expiresAtMs === undefined ? {} : { whenExpires: expiresAtMs }),
554
+ });
555
+ const id = fromBinary(SubscriptionSchema, wire.bytes).id?.value;
556
+ if (id === undefined || id.length === 0)
557
+ throw new Error("retained subscription has no ID");
558
+ const controller = new AbortController();
559
+ try {
560
+ await this.#receiveBackend(wire, controller);
561
+ const nowMs = this.#nowMs();
562
+ if (nowMs === undefined || (expiresAtMs !== undefined && expiresAtMs <= nowMs)) {
563
+ await this.#compensateDefinition(wire, controller);
564
+ await this.#options.bindings.cancel({
565
+ id,
566
+ context,
567
+ nowMs: nowMs ?? expiresAtMs ?? 0,
568
+ onDefinition: () => Promise.resolve(),
569
+ });
570
+ return SubscriptionGatewayValues.rejected("denied");
571
+ }
572
+ if (expiresAtMs !== undefined)
573
+ this.scheduleExpiry(expiresAtMs);
574
+ if (this.#options.publicAccess === true)
575
+ this.#schedulePublicPending(id, context);
576
+ return { kind: "subscribed", wire: SubscriptionGatewayValues.copyPublic(wire) };
577
+ }
578
+ catch (error) {
579
+ try {
580
+ await this.#compensateDefinition(wire, controller);
581
+ await this.#options.bindings.cancel({
582
+ id,
583
+ context,
584
+ nowMs: this.#nowMs() ?? expiresAtMs ?? 0,
585
+ onDefinition: () => Promise.resolve(),
586
+ });
587
+ // spine-log-boundary: auth.subscription_recovered_cleanup
588
+ }
589
+ catch {
590
+ // Retain the row: a later request can retry backend cleanup.
591
+ }
592
+ throw error;
593
+ }
594
+ }
595
+ #schedulePublicPending(id, context) {
596
+ this.#clearPublicPending(id);
597
+ const timer = setTimeout(() => {
598
+ if (this.#publicPendingTimers.get(id) !== timer)
599
+ return;
600
+ this.#publicPendingTimers.delete(id);
601
+ // spine-log-boundary: auth.public_pending_subscription_cleanup
602
+ void this.#cancel(id, context, this.#nowMs() ?? 0).then((result) => {
603
+ if (result.kind === "rejected")
604
+ this.#retryPublicPending(id, context);
605
+ }, () => {
606
+ this.#retryPublicPending(id, context);
607
+ });
608
+ }, SUBSCRIPTION_ACTIVATION_HANDSHAKE_MS);
609
+ this.#publicPendingTimers.set(id, timer);
610
+ }
611
+ #retryPublicPending(id, context) {
612
+ if (!this.#closed && !this.#publicPendingTimers.has(id))
613
+ this.#schedulePublicPending(id, context);
614
+ }
615
+ #clearPublicPending(id) {
616
+ const timer = this.#publicPendingTimers.get(id);
617
+ if (timer !== undefined)
618
+ clearTimeout(timer);
619
+ this.#publicPendingTimers.delete(id);
620
+ }
621
+ async #receiveBackend(wire, controller) {
622
+ return SubscriptionGatewayValues.withTimeout(this.#options.creator.subscribe(SubscriptionGatewayValues.copyPublic(wire), controller.signal, this.#limits.maxBackendEnvelopeBytes), this.#limits.operationTimeoutMs, controller);
623
+ }
624
+ async #compensateDefinition(wire, controller) {
625
+ const signal = controller.signal.aborted ? new AbortController().signal : controller.signal;
626
+ await this.#options.creator.cancel({ wire: SubscriptionGatewayValues.copyPublic(wire) }, signal);
627
+ }
628
+ async #forwardActivate(definition, updates, signal) {
629
+ const privateWire = SubscriptionGatewayValues.copyPublic(definition);
630
+ try {
631
+ const publicSubscription = fromBinary(SubscriptionSchema, definition.bytes);
632
+ await this.#options.creator.activate({
633
+ wire: privateWire,
634
+ updates: async (update) => {
635
+ try {
636
+ const decoded = fromBinary(SubscriptionUpdateSchema, update.bytes);
637
+ decoded.subscription = clone(SubscriptionSchema, publicSubscription);
638
+ await updates({
639
+ kind: "subscription-update",
640
+ bytes: toBinary(SubscriptionUpdateSchema, decoded),
641
+ });
642
+ }
643
+ finally {
644
+ update.bytes.fill(0);
645
+ }
646
+ },
647
+ }, signal);
648
+ }
649
+ finally {
650
+ privateWire.bytes.fill(0);
651
+ }
652
+ }
653
+ async #forwardCancel(definition, signal) {
654
+ const privateWire = SubscriptionGatewayValues.copyPublic(definition);
655
+ try {
656
+ await this.#options.creator.cancel({ wire: privateWire }, signal);
657
+ }
658
+ finally {
659
+ privateWire.bytes.fill(0);
660
+ }
661
+ }
662
+ }
663
+ /**
664
+ * Builds validated subscription gateway inputs and isolated wire values.
665
+ */
666
+ const SubscriptionGatewayValues = Object.freeze({
667
+ publicPrincipal: Object.freeze({ id: "spine-gateway-public" }),
668
+ discardUpdate(update) {
669
+ update.bytes.fill(0);
670
+ return Promise.resolve();
671
+ },
672
+ copyPublic(wire) {
673
+ return { kind: wire.kind, bytes: wire.bytes.slice() };
674
+ },
675
+ subscribed(id, topicBytes) {
676
+ return {
677
+ kind: "subscribed",
678
+ wire: {
679
+ kind: "public-subscription",
680
+ bytes: toBinary(SubscriptionSchema, create(SubscriptionSchema, {
681
+ id: { value: id },
682
+ topic: fromBinary(TopicSchema, topicBytes),
683
+ })),
684
+ },
685
+ };
686
+ },
687
+ decode(kind, bytes, transport) {
688
+ const result = IncomingRequests.decode({ kind, value: bytes.slice(), transport });
689
+ return result?.kind === kind ? result : undefined;
690
+ },
691
+ trustedContext(context) {
692
+ return create(ActorContextSchema, {
693
+ actor: context.actor,
694
+ timestamp: context.timestamp,
695
+ ...(context.tenant === undefined ? {} : { tenantId: context.tenant }),
696
+ ...(context.zoneId === undefined ? {} : { zoneId: context.zoneId }),
697
+ ...(context.language === undefined ? {} : { language: context.language }),
698
+ });
699
+ },
700
+ matches(requested, trusted) {
701
+ return SubscriptionGatewayValues.contextsEqual(requested, trusted);
702
+ },
703
+ contextsEqual(left, right) {
704
+ if (left.actor?.value !== right.actor?.value)
705
+ return false;
706
+ if (left.tenantId === undefined || right.tenantId === undefined)
707
+ return left.tenantId === right.tenantId;
708
+ const first = toBinary(TenantIdSchema, left.tenantId);
709
+ const second = toBinary(TenantIdSchema, right.tenantId);
710
+ return (first.byteLength === second.byteLength && first.every((byte, index) => byte === second[index]));
711
+ },
712
+ rewrite(incoming, context) {
713
+ if (incoming.kind === "subscribe") {
714
+ const topic = clone(TopicSchema, incoming.topic);
715
+ topic.context = clone(ActorContextSchema, context);
716
+ return toBinary(TopicSchema, topic);
717
+ }
718
+ const subscription = clone(SubscriptionSchema, incoming.subscription);
719
+ const topic = subscription.topic === undefined
720
+ ? create(TopicSchema)
721
+ : clone(TopicSchema, subscription.topic);
722
+ topic.context = clone(ActorContextSchema, context);
723
+ subscription.topic = topic;
724
+ return toBinary(SubscriptionSchema, subscription);
725
+ },
726
+ operationFor(request) {
727
+ if (request.service !== "spine.client.SubscriptionService")
728
+ return undefined;
729
+ if (request.method === "Subscribe" && request.wire.kind === "subscription-topic")
730
+ return "subscribe";
731
+ if (request.method === "Activate" && request.wire.kind === "public-subscription")
732
+ return "activate";
733
+ if (request.method === "Cancel" && request.wire.kind === "public-subscription")
734
+ return "cancel";
735
+ return undefined;
736
+ },
737
+ snapshotTransport(request) {
738
+ return Object.freeze({
739
+ service: request.service,
740
+ method: request.method,
741
+ ...(request.transport.origin === undefined ? {} : { origin: request.transport.origin }),
742
+ ...(request.transport.requestId === undefined
743
+ ? {}
744
+ : { requestId: request.transport.requestId }),
745
+ ...(request.transport.correlationId === undefined
746
+ ? {}
747
+ : { correlationId: request.transport.correlationId }),
748
+ ...(request.transport.peerAddress === undefined
749
+ ? {}
750
+ : { peerAddress: request.transport.peerAddress }),
751
+ ...(request.transport.userAgent === undefined
752
+ ? {}
753
+ : { userAgent: request.transport.userAgent }),
754
+ });
755
+ },
756
+ rejected(reason) {
757
+ return { kind: "rejected", reason };
758
+ },
759
+ timestampMs(seconds, nanos) {
760
+ const value = Number(seconds);
761
+ if (!Number.isSafeInteger(value) ||
762
+ !Number.isInteger(nanos) ||
763
+ nanos < 0 ||
764
+ nanos >= 1_000_000_000)
765
+ return undefined;
766
+ const result = value * 1_000 + Math.floor(nanos / 1_000_000);
767
+ return Number.isSafeInteger(result) ? result : undefined;
768
+ },
769
+ limits(input) {
770
+ const value = { ...defaultLimits, ...input };
771
+ for (const limit of Object.values(value))
772
+ if (!Number.isSafeInteger(limit) || limit <= 0)
773
+ throw new Error("subscription limits must be positive safe integers");
774
+ return value;
775
+ },
776
+ admit(request, limit) {
777
+ if (request.wire.bytes.byteLength > limit.maxRequestBytes)
778
+ return undefined;
779
+ return {
780
+ service: request.service,
781
+ method: request.method,
782
+ wire: { kind: request.wire.kind, bytes: request.wire.bytes.slice() },
783
+ ...(request.credential === undefined
784
+ ? {}
785
+ : { credential: { kind: request.credential.kind, value: request.credential.value } }),
786
+ transport: SubscriptionGatewayValues.snapshotTransport(request),
787
+ ...(request.updates === undefined ? {} : { updates: request.updates }),
788
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
789
+ };
790
+ },
791
+ async withTimeout(effect, milliseconds, controller) {
792
+ if (controller.signal.aborted)
793
+ throw new Error("subscription operation aborted");
794
+ let handle;
795
+ const expiry = new Promise((_, reject) => {
796
+ handle = setTimeout(() => {
797
+ controller.abort();
798
+ reject(new Error("subscription operation timed out"));
799
+ }, milliseconds);
800
+ });
801
+ const aborted = new Promise((_, reject) => {
802
+ controller.signal.addEventListener("abort", () => {
803
+ reject(new Error("subscription operation aborted"));
804
+ }, { once: true });
805
+ });
806
+ try {
807
+ return await Promise.race([effect, expiry, aborted]);
808
+ }
809
+ finally {
810
+ clearTimeout(handle);
811
+ }
812
+ },
813
+ });
814
+ //# sourceMappingURL=index.js.map