@fabricorg/experience-gateway 2.1.6 → 2.1.7
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/CHANGELOG.md +26 -0
- package/dist/index.cjs +316 -308
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +141 -142
- package/dist/index.d.ts +141 -142
- package/dist/index.js +309 -279
- package/dist/index.js.map +1 -1
- package/package.json +12 -11
package/dist/index.js
CHANGED
|
@@ -1,297 +1,327 @@
|
|
|
1
|
-
// index.ts
|
|
2
1
|
import { digestParameters } from "@fabricorg/platform-host";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|