@fabricorg/experience-gateway 2.1.6 → 2.1.8

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/dist/index.js CHANGED
@@ -1,297 +1,327 @@
1
- // index.ts
2
1
  import { digestParameters } from "@fabricorg/platform-host";
3
- import {
4
- assertActorClaimsCoverScope
5
- } from "@fabricorg/ports";
2
+ import { assertActorClaimsCoverScope } from "@fabricorg/ports";
3
+ //#region index.ts
4
+ /** A request the edge would not admit. `retryable` is true when a later attempt may be admitted. */
6
5
  var ExperienceAdmissionError = class extends Error {
7
- constructor(reason, retryAfterMs) {
8
- super(`Experience request not admitted: ${reason}`);
9
- this.reason = reason;
10
- this.retryAfterMs = retryAfterMs;
11
- this.name = "ExperienceAdmissionError";
12
- }
13
- reason;
14
- retryAfterMs;
15
- code = "EXPERIENCE_ADMISSION_REFUSED";
16
- get retryable() {
17
- return this.retryAfterMs !== void 0;
18
- }
6
+ reason;
7
+ retryAfterMs;
8
+ code = "EXPERIENCE_ADMISSION_REFUSED";
9
+ constructor(reason, retryAfterMs) {
10
+ super(`Experience request not admitted: ${reason}`);
11
+ this.reason = reason;
12
+ this.retryAfterMs = retryAfterMs;
13
+ this.name = "ExperienceAdmissionError";
14
+ }
15
+ get retryable() {
16
+ return this.retryAfterMs !== void 0;
17
+ }
19
18
  };
19
+ /**
20
+ * A reference admission policy: a token bucket per key, refilled over time.
21
+ *
22
+ * Enough to throttle a tenant or an actor at the edge; a deployment with a
23
+ * shared limiter puts it behind the same option. Keys default to the tenant.
24
+ */
20
25
  function createTokenBucketAdmission(options) {
21
- if (!(options.capacity >= 1) || !(options.refillPerSecond > 0)) throw new Error("Token bucket admission needs a capacity of at least one and a positive refill rate.");
22
- const now = options.now ?? (() => Date.now());
23
- const key = options.key ?? ((input) => input.scope.tenantId);
24
- const buckets = /* @__PURE__ */ new Map();
25
- return (input) => {
26
- const at = now();
27
- const id = key(input);
28
- const bucket = buckets.get(id) ?? { tokens: options.capacity, at };
29
- const elapsedMs = Math.max(0, at - bucket.at);
30
- const refilled = Math.min(options.capacity, bucket.tokens + elapsedMs / 1e3 * options.refillPerSecond);
31
- if (refilled >= 1) {
32
- const remaining = refilled - 1;
33
- if (remaining >= options.capacity) buckets.delete(id);
34
- else buckets.set(id, { tokens: remaining, at });
35
- return { allowed: true };
36
- }
37
- buckets.set(id, { tokens: refilled, at: Math.max(at, bucket.at) });
38
- const retryAfterMs = Math.ceil((1 - refilled) / options.refillPerSecond * 1e3);
39
- return { allowed: false, reason: `budget for "${id}" is exhausted`, retryAfterMs };
40
- };
26
+ if (!(options.capacity >= 1) || !(options.refillPerSecond > 0)) throw new Error("Token bucket admission needs a capacity of at least one and a positive refill rate.");
27
+ const now = options.now ?? (() => Date.now());
28
+ const key = options.key ?? ((input) => input.scope.tenantId);
29
+ const buckets = /* @__PURE__ */ new Map();
30
+ return (input) => {
31
+ const at = now();
32
+ const id = key(input);
33
+ const bucket = buckets.get(id) ?? {
34
+ tokens: options.capacity,
35
+ at
36
+ };
37
+ const elapsedMs = Math.max(0, at - bucket.at);
38
+ const refilled = Math.min(options.capacity, bucket.tokens + elapsedMs / 1e3 * options.refillPerSecond);
39
+ if (refilled >= 1) {
40
+ const remaining = refilled - 1;
41
+ if (remaining >= options.capacity) buckets.delete(id);
42
+ else buckets.set(id, {
43
+ tokens: remaining,
44
+ at
45
+ });
46
+ return { allowed: true };
47
+ }
48
+ buckets.set(id, {
49
+ tokens: refilled,
50
+ at: Math.max(at, bucket.at)
51
+ });
52
+ const retryAfterMs = Math.ceil((1 - refilled) / options.refillPerSecond * 1e3);
53
+ return {
54
+ allowed: false,
55
+ reason: `budget for "${id}" is exhausted`,
56
+ retryAfterMs
57
+ };
58
+ };
41
59
  }
42
60
  function experienceGatewayChecks() {
43
- return [
44
- {
45
- id: "fabric.experience.gateway-host-routing.v1",
46
- async run(subject) {
47
- const before = subject.observed.submitActionCount();
48
- const result = await subject.gateway.invoke(subject.validIntent);
49
- if (!result.actionInvocationId) throw new Error("Gateway did not return a governed Host invocation.");
50
- const submitted = subject.observed.submitActionCount() - before;
51
- if (submitted !== 1) {
52
- throw new Error(
53
- `Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`
54
- );
55
- }
56
- return [result.actionInvocationId];
57
- }
58
- },
59
- {
60
- id: "fabric.experience.gateway-projection-routing.v1",
61
- async run(subject) {
62
- const before = subject.observed.projectionCount();
63
- await subject.gateway.read(subject.validRead);
64
- const projected = subject.observed.projectionCount() - before;
65
- if (projected !== 1) {
66
- throw new Error(
67
- `Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`
68
- );
69
- }
70
- return ["read routed through ProjectionHost"];
71
- }
72
- },
73
- {
74
- id: "fabric.experience.gateway-scope-denial.v1",
75
- async run(subject) {
76
- let rejected = false;
77
- try {
78
- await subject.gateway.invoke(subject.deniedIntent);
79
- } catch {
80
- rejected = true;
81
- }
82
- if (!rejected) throw new Error("Gateway accepted an intent outside trusted identity scope.");
83
- return ["out-of-scope intent rejected"];
84
- }
85
- },
86
- {
87
- id: "fabric.experience.gateway-admission-refusal.v1",
88
- async run(subject) {
89
- if (!subject.throttledIntent) return ["skipped: the subject supplies no throttledIntent, so admission is not proven"];
90
- const before = subject.observed.submitActionCount();
91
- let refusal;
92
- try {
93
- await subject.gateway.invoke(subject.throttledIntent);
94
- } catch (error) {
95
- refusal = error;
96
- }
97
- if (!(refusal instanceof ExperienceAdmissionError)) {
98
- throw new Error("Gateway admitted an intent the deployment's admission policy refuses, or refused it as something other than ExperienceAdmissionError.");
99
- }
100
- if (subject.observed.submitActionCount() !== before) {
101
- throw new Error("A refused intent still reached PlatformHost.submitAction; admission must decide before anything governed is touched.");
102
- }
103
- return [`admission refused: ${refusal.reason}${refusal.retryable ? ` (retry after ${refusal.retryAfterMs}ms)` : ""}`];
104
- }
105
- }
106
- ];
61
+ return [
62
+ {
63
+ id: "fabric.experience.gateway-host-routing.v1",
64
+ async run(subject) {
65
+ const before = subject.observed.submitActionCount();
66
+ const result = await subject.gateway.invoke(subject.validIntent);
67
+ if (!result.actionInvocationId) throw new Error("Gateway did not return a governed Host invocation.");
68
+ const submitted = subject.observed.submitActionCount() - before;
69
+ if (submitted !== 1) throw new Error(`Gateway returned an invocation id after ${submitted} calls to PlatformHost.submitAction; a mutation must reach the Host exactly once.`);
70
+ return [result.actionInvocationId];
71
+ }
72
+ },
73
+ {
74
+ id: "fabric.experience.gateway-projection-routing.v1",
75
+ async run(subject) {
76
+ const before = subject.observed.projectionCount();
77
+ await subject.gateway.read(subject.validRead);
78
+ const projected = subject.observed.projectionCount() - before;
79
+ if (projected !== 1) throw new Error(`Gateway served a read after ${projected} calls to ProjectionHost.project; a read must go through the projection host.`);
80
+ return ["read routed through ProjectionHost"];
81
+ }
82
+ },
83
+ {
84
+ id: "fabric.experience.gateway-scope-denial.v1",
85
+ async run(subject) {
86
+ let rejected = false;
87
+ try {
88
+ await subject.gateway.invoke(subject.deniedIntent);
89
+ } catch {
90
+ rejected = true;
91
+ }
92
+ if (!rejected) throw new Error("Gateway accepted an intent outside trusted identity scope.");
93
+ return ["out-of-scope intent rejected"];
94
+ }
95
+ },
96
+ {
97
+ id: "fabric.experience.gateway-admission-refusal.v1",
98
+ async run(subject) {
99
+ if (!subject.throttledIntent) return ["skipped: the subject supplies no throttledIntent, so admission is not proven"];
100
+ const before = subject.observed.submitActionCount();
101
+ let refusal;
102
+ try {
103
+ await subject.gateway.invoke(subject.throttledIntent);
104
+ } catch (error) {
105
+ refusal = error;
106
+ }
107
+ if (!(refusal instanceof ExperienceAdmissionError)) throw new Error("Gateway admitted an intent the deployment's admission policy refuses, or refused it as something other than ExperienceAdmissionError.");
108
+ if (subject.observed.submitActionCount() !== before) throw new Error("A refused intent still reached PlatformHost.submitAction; admission must decide before anything governed is touched.");
109
+ return [`admission refused: ${refusal.reason}${refusal.retryable ? ` (retry after ${refusal.retryAfterMs}ms)` : ""}`];
110
+ }
111
+ }
112
+ ];
107
113
  }
108
114
  async function runExperienceGatewayChecks(subject, checks = experienceGatewayChecks()) {
109
- const results = [];
110
- for (const check of checks) {
111
- try {
112
- const evidence = await check.run(subject);
113
- results.push({ id: check.id, status: evidence[0]?.startsWith("skipped:") ? "skipped" : "passed", evidence });
114
- } catch (error) {
115
- results.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
116
- }
117
- }
118
- return { passed: results.every((result) => result.status !== "failed"), checks: results };
115
+ const results = [];
116
+ for (const check of checks) try {
117
+ const evidence = await check.run(subject);
118
+ results.push({
119
+ id: check.id,
120
+ status: evidence[0]?.startsWith("skipped:") ? "skipped" : "passed",
121
+ evidence
122
+ });
123
+ } catch (error) {
124
+ results.push({
125
+ id: check.id,
126
+ status: "failed",
127
+ evidence: [],
128
+ error: error instanceof Error ? error.message : String(error)
129
+ });
130
+ }
131
+ return {
132
+ passed: results.every((result) => result.status !== "failed"),
133
+ checks: results
134
+ };
119
135
  }
136
+ /**
137
+ * Creates an instance-scoped, non-serializable evidence seam. The Host's
138
+ * `composition.resolveInitiatingReleaseDigest` reads this WeakMap using the
139
+ * exact submission object. A browser field cannot populate or forge it.
140
+ */
120
141
  function createInitiatingReleaseEvidence() {
121
- const evidence = /* @__PURE__ */ new WeakMap();
122
- return {
123
- bind(input, releaseDigest) {
124
- evidence.set(input, releaseDigest);
125
- },
126
- resolve(input) {
127
- return evidence.get(input);
128
- },
129
- release(input) {
130
- evidence.delete(input);
131
- }
132
- };
142
+ const evidence = /* @__PURE__ */ new WeakMap();
143
+ return {
144
+ bind(input, releaseDigest) {
145
+ evidence.set(input, releaseDigest);
146
+ },
147
+ resolve(input) {
148
+ return evidence.get(input);
149
+ },
150
+ release(input) {
151
+ evidence.delete(input);
152
+ }
153
+ };
133
154
  }
134
155
  function createExperienceGateway(options) {
135
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
136
- return {
137
- async invoke(input) {
138
- const claims = await options.identity.verify(input.context.credential);
139
- if (!claims) throw new Error("Experience request credential is not authenticated.");
140
- assertActorClaimsCoverScope(claims, {
141
- tenantId: input.context.tenantId,
142
- spaceId: input.context.spaceId
143
- }, now());
144
- const scope = {
145
- tenantId: input.context.tenantId,
146
- spaceId: input.context.spaceId,
147
- actorId: claims.subject,
148
- actorType: claims.actorType
149
- };
150
- await admit(options.admission, { kind: "invoke", scope, claims, planId: input.request.planId, fragmentId: input.request.fragmentId, name: input.request.eventName });
151
- const resolved = await options.runtime.resolveIntent({
152
- planId: input.request.planId,
153
- fragmentId: input.request.fragmentId,
154
- eventName: input.request.eventName,
155
- scope
156
- });
157
- if (resolved.assemblyDigest !== options.assemblyDigest) {
158
- throw new Error("Experience render plan belongs to a different application assembly.");
159
- }
160
- if (options.actionVersion(resolved.actionId) !== resolved.actionVersion) {
161
- throw new Error(`Experience intent route for "${resolved.actionId}" does not match the registered action version.`);
162
- }
163
- if (resolved.execution?.idempotency === "required" && !input.request.idempotencyKey) {
164
- throw new Error(`Action "${resolved.actionId}" requires an idempotency key.`);
165
- }
166
- if (input.executionReason === "offline_replay" && resolved.execution?.connectivity !== "offline-capturable") {
167
- throw new Error(`Action "${resolved.actionId}" is not eligible for offline replay.`);
168
- }
169
- const correlationId = options.nextCorrelationId();
170
- let capturedAt;
171
- if (input.executionReason === "offline_replay") {
172
- const supplied = input.capturedAt ?? input.request.capturedAt;
173
- if (typeof supplied !== "string") {
174
- throw new Error("Offline replay requires capturedAt; receipt time cannot attest when the operator acted.");
175
- }
176
- capturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);
177
- } else {
178
- capturedAt = now().toISOString();
179
- }
180
- const parameterDigest = digestParameters(input.request.parameters);
181
- const capturedAuthority = resolved.execution?.authorityMoment === "capture" ? await options.captureAuthority?.({
182
- actionId: resolved.actionId,
183
- scope,
184
- parameters: input.request.parameters,
185
- parameterDigest,
186
- capturedAt
187
- }) : void 0;
188
- if (resolved.execution?.authorityMoment === "capture" && !capturedAuthority) {
189
- throw new Error(`Action "${resolved.actionId}" requires a trusted capture-authority adapter.`);
190
- }
191
- const submission = {
192
- actionId: resolved.actionId,
193
- parameters: input.request.parameters,
194
- tenantId: scope.tenantId,
195
- spaceId: scope.spaceId,
196
- actorId: scope.actorId,
197
- actorType: claims.actorType,
198
- correlationId,
199
- provenance: {
200
- source: "ui",
201
- correlationId,
202
- ...resolved.execution?.sensitivity === "restricted" ? {} : { auditAttributes: { "fabric.experience.fragment": input.request.fragmentId } }
203
- },
204
- ...input.request.idempotencyKey === void 0 ? {} : { idempotencyKey: input.request.idempotencyKey },
205
- ...input.executionReason === void 0 ? {} : { executionReason: input.executionReason },
206
- ...capturedAuthority === void 0 ? {} : {
207
- authorizationBindingId: capturedAuthority.id,
208
- authorizationBinding: {
209
- id: capturedAuthority.id,
210
- governingMoment: "capture",
211
- capturedAt,
212
- ...capturedAuthority.expiresAt === void 0 ? {} : { expiresAt: capturedAuthority.expiresAt },
213
- tenantId: scope.tenantId,
214
- actorId: scope.actorId,
215
- actionId: resolved.actionId,
216
- parameterDigest
217
- }
218
- }
219
- };
220
- options.evidence.bind(submission, resolved.initiatingReleaseDigest);
221
- try {
222
- return await options.host.submitAction(submission);
223
- } finally {
224
- options.evidence.release(submission);
225
- }
226
- },
227
- async read(input) {
228
- const claims = await options.identity.verify(input.context.credential);
229
- if (!claims) throw new Error("Experience request credential is not authenticated.");
230
- assertActorClaimsCoverScope(claims, {
231
- tenantId: input.context.tenantId,
232
- spaceId: input.context.spaceId
233
- }, now());
234
- const scope = {
235
- tenantId: input.context.tenantId,
236
- spaceId: input.context.spaceId,
237
- actorId: claims.subject,
238
- actorType: claims.actorType
239
- };
240
- await admit(options.admission, { kind: "read", scope, claims, planId: input.request.planId, fragmentId: input.request.fragmentId, name: input.request.bindingName });
241
- const resolved = await options.runtime.resolveView({
242
- planId: input.request.planId,
243
- fragmentId: input.request.fragmentId,
244
- bindingName: input.request.bindingName,
245
- scope
246
- });
247
- if (resolved.assemblyDigest !== options.assemblyDigest) {
248
- throw new Error("Experience render plan belongs to a different application assembly.");
249
- }
250
- const result = await options.projectionHost.project({
251
- view: { name: resolved.name, version: resolved.version },
252
- scope: { tenantId: scope.tenantId, spaceId: scope.spaceId },
253
- actor: { id: scope.actorId, type: claims.actorType },
254
- parameters: input.request.parameters
255
- });
256
- return {
257
- data: result.state,
258
- evidence: {
259
- releaseDigest: resolved.initiatingReleaseDigest,
260
- assemblyDigest: resolved.assemblyDigest,
261
- eventCursor: result.eventCursor,
262
- source: result.source
263
- }
264
- };
265
- }
266
- };
156
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
157
+ return {
158
+ async invoke(input) {
159
+ const claims = await options.identity.verify(input.context.credential);
160
+ if (!claims) throw new Error("Experience request credential is not authenticated.");
161
+ assertActorClaimsCoverScope(claims, {
162
+ tenantId: input.context.tenantId,
163
+ spaceId: input.context.spaceId
164
+ }, now());
165
+ const scope = {
166
+ tenantId: input.context.tenantId,
167
+ spaceId: input.context.spaceId,
168
+ actorId: claims.subject,
169
+ actorType: claims.actorType
170
+ };
171
+ await admit(options.admission, {
172
+ kind: "invoke",
173
+ scope,
174
+ claims,
175
+ planId: input.request.planId,
176
+ fragmentId: input.request.fragmentId,
177
+ name: input.request.eventName
178
+ });
179
+ const resolved = await options.runtime.resolveIntent({
180
+ planId: input.request.planId,
181
+ fragmentId: input.request.fragmentId,
182
+ eventName: input.request.eventName,
183
+ scope
184
+ });
185
+ if (resolved.assemblyDigest !== options.assemblyDigest) throw new Error("Experience render plan belongs to a different application assembly.");
186
+ if (options.actionVersion(resolved.actionId) !== resolved.actionVersion) throw new Error(`Experience intent route for "${resolved.actionId}" does not match the registered action version.`);
187
+ if (resolved.execution?.idempotency === "required" && !input.request.idempotencyKey) throw new Error(`Action "${resolved.actionId}" requires an idempotency key.`);
188
+ if (input.executionReason === "offline_replay" && resolved.execution?.connectivity !== "offline-capturable") throw new Error(`Action "${resolved.actionId}" is not eligible for offline replay.`);
189
+ const correlationId = options.nextCorrelationId();
190
+ let capturedAt;
191
+ if (input.executionReason === "offline_replay") {
192
+ const supplied = input.capturedAt ?? input.request.capturedAt;
193
+ if (typeof supplied !== "string") throw new Error("Offline replay requires capturedAt; receipt time cannot attest when the operator acted.");
194
+ capturedAt = assertCaptureMoment(supplied, now(), options.maxOfflineReplayAgeMs);
195
+ } else capturedAt = now().toISOString();
196
+ const parameterDigest = digestParameters(input.request.parameters);
197
+ const capturedAuthority = resolved.execution?.authorityMoment === "capture" ? await options.captureAuthority?.({
198
+ actionId: resolved.actionId,
199
+ scope,
200
+ parameters: input.request.parameters,
201
+ parameterDigest,
202
+ capturedAt
203
+ }) : void 0;
204
+ if (resolved.execution?.authorityMoment === "capture" && !capturedAuthority) throw new Error(`Action "${resolved.actionId}" requires a trusted capture-authority adapter.`);
205
+ const submission = {
206
+ actionId: resolved.actionId,
207
+ parameters: input.request.parameters,
208
+ tenantId: scope.tenantId,
209
+ spaceId: scope.spaceId,
210
+ actorId: scope.actorId,
211
+ actorType: claims.actorType,
212
+ correlationId,
213
+ provenance: {
214
+ source: "ui",
215
+ correlationId,
216
+ ...resolved.execution?.sensitivity === "restricted" ? {} : { auditAttributes: { "fabric.experience.fragment": input.request.fragmentId } }
217
+ },
218
+ ...input.request.idempotencyKey === void 0 ? {} : { idempotencyKey: input.request.idempotencyKey },
219
+ ...input.executionReason === void 0 ? {} : { executionReason: input.executionReason },
220
+ ...capturedAuthority === void 0 ? {} : {
221
+ authorizationBindingId: capturedAuthority.id,
222
+ authorizationBinding: {
223
+ id: capturedAuthority.id,
224
+ governingMoment: "capture",
225
+ capturedAt,
226
+ ...capturedAuthority.expiresAt === void 0 ? {} : { expiresAt: capturedAuthority.expiresAt },
227
+ tenantId: scope.tenantId,
228
+ actorId: scope.actorId,
229
+ actionId: resolved.actionId,
230
+ parameterDigest
231
+ }
232
+ }
233
+ };
234
+ options.evidence.bind(submission, resolved.initiatingReleaseDigest);
235
+ try {
236
+ return await options.host.submitAction(submission);
237
+ } finally {
238
+ options.evidence.release(submission);
239
+ }
240
+ },
241
+ async read(input) {
242
+ const claims = await options.identity.verify(input.context.credential);
243
+ if (!claims) throw new Error("Experience request credential is not authenticated.");
244
+ assertActorClaimsCoverScope(claims, {
245
+ tenantId: input.context.tenantId,
246
+ spaceId: input.context.spaceId
247
+ }, now());
248
+ const scope = {
249
+ tenantId: input.context.tenantId,
250
+ spaceId: input.context.spaceId,
251
+ actorId: claims.subject,
252
+ actorType: claims.actorType
253
+ };
254
+ await admit(options.admission, {
255
+ kind: "read",
256
+ scope,
257
+ claims,
258
+ planId: input.request.planId,
259
+ fragmentId: input.request.fragmentId,
260
+ name: input.request.bindingName
261
+ });
262
+ const resolved = await options.runtime.resolveView({
263
+ planId: input.request.planId,
264
+ fragmentId: input.request.fragmentId,
265
+ bindingName: input.request.bindingName,
266
+ scope
267
+ });
268
+ if (resolved.assemblyDigest !== options.assemblyDigest) throw new Error("Experience render plan belongs to a different application assembly.");
269
+ const result = await options.projectionHost.project({
270
+ view: {
271
+ name: resolved.name,
272
+ version: resolved.version
273
+ },
274
+ scope: {
275
+ tenantId: scope.tenantId,
276
+ spaceId: scope.spaceId
277
+ },
278
+ actor: {
279
+ id: scope.actorId,
280
+ type: claims.actorType
281
+ },
282
+ parameters: input.request.parameters
283
+ });
284
+ return {
285
+ data: result.state,
286
+ evidence: {
287
+ releaseDigest: resolved.initiatingReleaseDigest,
288
+ assemblyDigest: resolved.assemblyDigest,
289
+ eventCursor: result.eventCursor,
290
+ source: result.source
291
+ }
292
+ };
293
+ }
294
+ };
267
295
  }
268
- var RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
296
+ /** RFC 3339 with a mandatory offset, so an instant means the same thing everywhere. */
297
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
298
+ /**
299
+ * A supplied capture moment is client-reported, so it is bounded here rather
300
+ * than trusted.
301
+ *
302
+ * Deferring the lower bound to the Host looked safe and is not: the Host checks
303
+ * staleness only when the capture-authority adapter supplied an `expiresAt`,
304
+ * and skips the execution-time expiry check entirely when the action's
305
+ * authority moment is `capture`. With an adapter that omits `expiresAt` there
306
+ * is no lower bound at all, and the value is written into durable evidence and
307
+ * handed to the authority adapter regardless. A gateway that accepts replay
308
+ * must therefore say how old a capture may be.
309
+ */
269
310
  async function admit(admission, input) {
270
- if (!admission) return;
271
- const decision = await admission(input);
272
- if (!decision.allowed) throw new ExperienceAdmissionError(decision.reason, decision.retryAfterMs);
311
+ if (!admission) return;
312
+ const decision = await admission(input);
313
+ if (!decision.allowed) throw new ExperienceAdmissionError(decision.reason, decision.retryAfterMs);
273
314
  }
274
315
  function assertCaptureMoment(capturedAt, now, maxAgeMs) {
275
- if (!RFC3339.test(capturedAt)) {
276
- throw new Error("Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.");
277
- }
278
- const parsed = Date.parse(capturedAt);
279
- if (Number.isNaN(parsed)) throw new Error("Offline replay capturedAt is not a parseable instant.");
280
- if (parsed > now.getTime()) throw new Error("Offline replay capturedAt is in the future.");
281
- if (maxAgeMs === void 0) {
282
- throw new Error("Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.");
283
- }
284
- if (now.getTime() - parsed > maxAgeMs) {
285
- throw new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);
286
- }
287
- return new Date(parsed).toISOString();
316
+ if (!RFC3339.test(capturedAt)) throw new Error("Offline replay capturedAt must be an RFC 3339 instant with an explicit offset.");
317
+ const parsed = Date.parse(capturedAt);
318
+ if (Number.isNaN(parsed)) throw new Error("Offline replay capturedAt is not a parseable instant.");
319
+ if (parsed > now.getTime()) throw new Error("Offline replay capturedAt is in the future.");
320
+ if (maxAgeMs === void 0) throw new Error("Gateway accepts offline replay but declares no maxOfflineReplayAgeMs, so a capture has no lower bound.");
321
+ if (now.getTime() - parsed > maxAgeMs) throw new Error(`Offline replay capturedAt is older than the ${maxAgeMs}ms this gateway accepts.`);
322
+ return new Date(parsed).toISOString();
288
323
  }
289
- export {
290
- ExperienceAdmissionError,
291
- createExperienceGateway,
292
- createInitiatingReleaseEvidence,
293
- createTokenBucketAdmission,
294
- experienceGatewayChecks,
295
- runExperienceGatewayChecks
296
- };
324
+ //#endregion
325
+ export { ExperienceAdmissionError, createExperienceGateway, createInitiatingReleaseEvidence, createTokenBucketAdmission, experienceGatewayChecks, runExperienceGatewayChecks };
326
+
297
327
  //# sourceMappingURL=index.js.map