@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.
- package/README.md +128 -11
- package/REFERENCE.md +166 -12
- package/dist/gateway/dynamic-unary-forwarder.d.ts +62 -3
- package/dist/gateway/dynamic-unary-forwarder.d.ts.map +1 -1
- package/dist/gateway/dynamic-unary-forwarder.js +189 -10
- package/dist/gateway/dynamic-unary-forwarder.js.map +1 -1
- package/dist/gateway/index.d.ts +5 -0
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js +65 -7
- package/dist/gateway/index.js.map +1 -1
- package/dist/index.d.ts +60 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +43 -3
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +178 -32
- package/dist/native/index.js.map +1 -1
- package/dist/oidc/contracts.d.ts +18 -4
- package/dist/oidc/contracts.d.ts.map +1 -1
- package/dist/oidc/flow-store.d.ts +143 -0
- package/dist/oidc/flow-store.d.ts.map +1 -0
- package/dist/oidc/flow-store.js +184 -0
- package/dist/oidc/flow-store.js.map +1 -0
- package/dist/oidc/index.d.ts +29 -9
- package/dist/oidc/index.d.ts.map +1 -1
- package/dist/oidc/index.js +611 -152
- package/dist/oidc/index.js.map +1 -1
- package/dist/oidc/persistent-flow-store.d.ts +117 -0
- package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
- package/dist/oidc/persistent-flow-store.js +413 -0
- package/dist/oidc/persistent-flow-store.js.map +1 -0
- package/dist/oidc/records.d.ts +15 -0
- package/dist/oidc/records.d.ts.map +1 -0
- package/dist/oidc/records.js +91 -0
- package/dist/oidc/records.js.map +1 -0
- package/dist/providers/index.d.ts +10 -5
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +103 -30
- package/dist/providers/index.js.map +1 -1
- package/dist/request/index.d.ts +3 -0
- package/dist/request/index.d.ts.map +1 -1
- package/dist/request/index.js +17 -1
- package/dist/request/index.js.map +1 -1
- package/dist/sessions/cookies.d.ts +46 -8
- package/dist/sessions/cookies.d.ts.map +1 -1
- package/dist/sessions/cookies.js +269 -47
- package/dist/sessions/cookies.js.map +1 -1
- package/dist/sessions/opaque-session-store.d.ts +143 -0
- package/dist/sessions/opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/opaque-session-store.js +185 -0
- package/dist/sessions/opaque-session-store.js.map +1 -0
- package/dist/sessions/opaque.d.ts +93 -106
- package/dist/sessions/opaque.d.ts.map +1 -1
- package/dist/sessions/opaque.js +501 -232
- package/dist/sessions/opaque.js.map +1 -1
- package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
- package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/persistent-opaque-session-store.js +333 -0
- package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
- package/dist/sessions/persistent-token-revocations.d.ts +90 -0
- package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
- package/dist/sessions/persistent-token-revocations.js +422 -0
- package/dist/sessions/persistent-token-revocations.js.map +1 -0
- package/dist/sessions/session-lifetime.d.ts +67 -0
- package/dist/sessions/session-lifetime.d.ts.map +1 -0
- package/dist/sessions/session-lifetime.js +165 -0
- package/dist/sessions/session-lifetime.js.map +1 -0
- package/dist/sessions/session-record.d.ts +15 -0
- package/dist/sessions/session-record.d.ts.map +1 -0
- package/dist/sessions/session-record.js +109 -0
- package/dist/sessions/session-record.js.map +1 -0
- package/dist/sessions/signed.d.ts +5 -4
- package/dist/sessions/signed.d.ts.map +1 -1
- package/dist/sessions/signed.js +212 -74
- package/dist/sessions/signed.js.map +1 -1
- package/dist/subscriptions/index.d.ts +35 -3
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +680 -134
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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(
|
|
650
|
+
!(await this.#options.authorize(verified.principal, authorization)))
|
|
431
651
|
return SubscriptionGatewayValues.rejected("forbidden");
|
|
432
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|