@emilia-protocol/gate 0.19.0 → 0.20.0
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 +29 -0
- package/README.md +22 -0
- package/action-refusal-statement.js +1 -0
- package/coverage-reconciliation-attestation.js +1 -0
- package/dist/action-refusal-statement.d.ts +109 -0
- package/dist/action-refusal-statement.d.ts.map +1 -0
- package/dist/action-refusal-statement.js +333 -0
- package/dist/action-refusal-statement.js.map +1 -0
- package/dist/coverage-reconciliation-attestation.d.ts +29 -0
- package/dist/coverage-reconciliation-attestation.d.ts.map +1 -0
- package/dist/coverage-reconciliation-attestation.js +111 -0
- package/dist/coverage-reconciliation-attestation.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/loss-allocation-schedule.d.ts +53 -0
- package/dist/loss-allocation-schedule.d.ts.map +1 -0
- package/dist/loss-allocation-schedule.js +351 -0
- package/dist/loss-allocation-schedule.js.map +1 -0
- package/dist/loss-experience-feed.d.ts +82 -0
- package/dist/loss-experience-feed.d.ts.map +1 -0
- package/dist/loss-experience-feed.js +314 -0
- package/dist/loss-experience-feed.js.map +1 -0
- package/dist/open-exposure-ledger-postgres.d.ts +40 -0
- package/dist/open-exposure-ledger-postgres.d.ts.map +1 -0
- package/dist/open-exposure-ledger-postgres.js +577 -0
- package/dist/open-exposure-ledger-postgres.js.map +1 -0
- package/dist/open-exposure-ledger.d.ts +243 -0
- package/dist/open-exposure-ledger.d.ts.map +1 -0
- package/dist/open-exposure-ledger.js +794 -0
- package/dist/open-exposure-ledger.js.map +1 -0
- package/dist/receipt-census.d.ts +21 -0
- package/dist/receipt-census.d.ts.map +1 -0
- package/dist/receipt-census.js +162 -0
- package/dist/receipt-census.js.map +1 -0
- package/dist/reliance-risk-crypto.d.ts +28 -0
- package/dist/reliance-risk-crypto.d.ts.map +1 -0
- package/dist/reliance-risk-crypto.js +110 -0
- package/dist/reliance-risk-crypto.js.map +1 -0
- package/loss-allocation-schedule.js +1 -0
- package/loss-experience-feed.js +1 -0
- package/open-exposure-ledger-postgres.js +1 -0
- package/open-exposure-ledger.js +1 -0
- package/package.json +36 -1
- package/receipt-census.js +1 -0
- package/src/action-refusal-statement.ts +396 -0
- package/src/coverage-reconciliation-attestation.ts +107 -0
- package/src/index.ts +7 -0
- package/src/loss-allocation-schedule.ts +427 -0
- package/src/loss-experience-feed.ts +398 -0
- package/src/open-exposure-ledger-postgres.ts +701 -0
- package/src/open-exposure-ledger.ts +1213 -0
- package/src/receipt-census.ts +163 -0
- package/src/reliance-risk-crypto.ts +114 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Open Exposure Ledger v1.
|
|
5
|
+
*
|
|
6
|
+
* The ledger reserves fixed minor-unit exposure before provider entry. A
|
|
7
|
+
* reservation remains open while RESERVED, INVOKING, or INDETERMINATE and can
|
|
8
|
+
* be closed only by a separately authenticated reconciliation authority. The
|
|
9
|
+
* in-memory implementation is a linearizable conformance/reference store; it
|
|
10
|
+
* is not a durability claim.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { performance } from 'node:perf_hooks';
|
|
14
|
+
export const OPEN_EXPOSURE_LEDGER_VERSION = 'EP-OPEN-EXPOSURE-LEDGER-v1';
|
|
15
|
+
export const OPEN_EXPOSURE_HISTORY_VERSION = 'EP-OPEN-EXPOSURE-HISTORY-v1';
|
|
16
|
+
export class OpenExposureValidationError extends TypeError {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'OpenExposureValidationError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,511}$/;
|
|
25
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
26
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
27
|
+
const OPERATION_TOKEN = /^open-exposure-op:v1:[A-Za-z0-9_-]{32,128}$/;
|
|
28
|
+
const RECONCILIATION_TOKEN = /^open-exposure-reconcile:v1:[A-Za-z0-9_-]{32,128}$/;
|
|
29
|
+
const CAID = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
|
|
30
|
+
const OPEN_STATUSES = new Set([
|
|
31
|
+
'RESERVED', 'INVOKING', 'INDETERMINATE',
|
|
32
|
+
]);
|
|
33
|
+
const SCOPES = [
|
|
34
|
+
'TENANT', 'PROGRAM', 'COUNTERPARTY', 'ACTION_CLASS',
|
|
35
|
+
];
|
|
36
|
+
function fail(code, message) {
|
|
37
|
+
throw new OpenExposureValidationError(code, message);
|
|
38
|
+
}
|
|
39
|
+
function canonical(value) {
|
|
40
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
if (typeof value === 'number') {
|
|
44
|
+
if (!Number.isFinite(value))
|
|
45
|
+
fail('invalid_number', 'numbers must be finite');
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === 'bigint')
|
|
49
|
+
return JSON.stringify({ '@bigint': value.toString(10) });
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
52
|
+
const prototype = Object.getPrototypeOf(value);
|
|
53
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
54
|
+
fail('invalid_object', 'values must be plain objects');
|
|
55
|
+
}
|
|
56
|
+
return `{${Object.keys(value).sort().map((key) => (`${JSON.stringify(key)}:${canonical(value[key])}`)).join(',')}}`;
|
|
57
|
+
}
|
|
58
|
+
function digest(domain, value) {
|
|
59
|
+
return `sha256:${crypto.createHash('sha256')
|
|
60
|
+
.update(domain)
|
|
61
|
+
.update('\0')
|
|
62
|
+
.update(canonical(value))
|
|
63
|
+
.digest('hex')}`;
|
|
64
|
+
}
|
|
65
|
+
function frozenCopy(value) {
|
|
66
|
+
return deepFreeze(structuredClone(value));
|
|
67
|
+
}
|
|
68
|
+
function deepFreeze(value) {
|
|
69
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
70
|
+
for (const child of Object.values(value))
|
|
71
|
+
deepFreeze(child);
|
|
72
|
+
Object.freeze(value);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function identifier(value, field) {
|
|
77
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
|
78
|
+
fail('invalid_identifier', `${field} is invalid`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function sha256(value, field) {
|
|
82
|
+
if (typeof value !== 'string' || !SHA256.test(value)) {
|
|
83
|
+
fail('invalid_digest', `${field} must be a sha256 digest`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function instant(value, field) {
|
|
87
|
+
if (typeof value !== 'string')
|
|
88
|
+
fail('invalid_time', `${field} must be an RFC3339 instant`);
|
|
89
|
+
const parsed = Date.parse(value);
|
|
90
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) {
|
|
91
|
+
fail('invalid_time', `${field} must be a canonical millisecond UTC instant`);
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
function positiveBigInt(value, field) {
|
|
96
|
+
if (typeof value !== 'bigint' || value <= 0n || value > 9223372036854775807n) {
|
|
97
|
+
fail('invalid_amount', `${field} must be a positive signed 64-bit bigint`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function nonNegativeBigInt(value, field) {
|
|
101
|
+
if (typeof value !== 'bigint' || value < 0n || value > 9223372036854775807n) {
|
|
102
|
+
fail('invalid_amount', `${field} must be a non-negative signed 64-bit bigint`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function validateAuth(auth) {
|
|
106
|
+
if (!auth || !['POLICY_ADMIN', 'ORIGIN', 'EXECUTOR', 'RECONCILER', 'READER'].includes(auth.role)) {
|
|
107
|
+
fail('invalid_auth', 'auth role is invalid');
|
|
108
|
+
}
|
|
109
|
+
identifier(auth.authorityId, 'auth.authorityId');
|
|
110
|
+
if (typeof auth.credential !== 'string' || auth.credential.length < 1 || auth.credential.length > 4096) {
|
|
111
|
+
fail('invalid_auth', 'auth credential is invalid');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function validateWindow(windowStart, windowEnd) {
|
|
115
|
+
if (instant(windowStart, 'windowStart') >= instant(windowEnd, 'windowEnd')) {
|
|
116
|
+
fail('invalid_window', 'windowStart must precede windowEnd');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validateCeiling(input) {
|
|
120
|
+
identifier(input.tenantId, 'tenantId');
|
|
121
|
+
identifier(input.ceilingId, 'ceilingId');
|
|
122
|
+
if (!SCOPES.includes(input.scope))
|
|
123
|
+
fail('invalid_scope', 'ceiling scope is invalid');
|
|
124
|
+
if (!(input.scope === 'TENANT' && input.scopeValue === '*')) {
|
|
125
|
+
identifier(input.scopeValue, 'scopeValue');
|
|
126
|
+
}
|
|
127
|
+
if (input.scope === 'TENANT' && input.scopeValue !== '*') {
|
|
128
|
+
fail('invalid_scope', 'TENANT scopeValue must be *');
|
|
129
|
+
}
|
|
130
|
+
if (input.scope !== 'TENANT' && input.scopeValue === '*') {
|
|
131
|
+
fail('invalid_scope', 'non-tenant scopeValue cannot be *');
|
|
132
|
+
}
|
|
133
|
+
if (!CURRENCY.test(input.currency))
|
|
134
|
+
fail('invalid_currency', 'currency must be ISO-like uppercase ASCII');
|
|
135
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
136
|
+
nonNegativeBigInt(input.limitMinor, 'limitMinor');
|
|
137
|
+
sha256(input.policyDigest, 'policyDigest');
|
|
138
|
+
}
|
|
139
|
+
function validateReservation(input) {
|
|
140
|
+
for (const [field, value] of Object.entries({
|
|
141
|
+
tenantId: input.tenantId,
|
|
142
|
+
exposureId: input.exposureId,
|
|
143
|
+
programId: input.programId,
|
|
144
|
+
programVersion: input.programVersion,
|
|
145
|
+
counterpartyId: input.counterpartyId,
|
|
146
|
+
actionClass: input.actionClass,
|
|
147
|
+
originAuthorityId: input.originAuthorityId,
|
|
148
|
+
executorAuthorityId: input.executorAuthorityId,
|
|
149
|
+
reconciliationAuthorityId: input.reconciliationAuthorityId,
|
|
150
|
+
}))
|
|
151
|
+
identifier(value, field);
|
|
152
|
+
if (typeof input.caid !== 'string' || !CAID.test(input.caid)) {
|
|
153
|
+
fail('invalid_caid', 'caid is invalid');
|
|
154
|
+
}
|
|
155
|
+
for (const [field, value] of Object.entries({
|
|
156
|
+
programSourceDigest: input.programSourceDigest,
|
|
157
|
+
programDigest: input.programDigest,
|
|
158
|
+
actionDigest: input.actionDigest,
|
|
159
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
160
|
+
authorizationDigest: input.authorizationDigest,
|
|
161
|
+
}))
|
|
162
|
+
sha256(value, field);
|
|
163
|
+
if (!OPERATION_TOKEN.test(input.operationToken))
|
|
164
|
+
fail('invalid_operation_token', 'operationToken is invalid');
|
|
165
|
+
positiveBigInt(input.amountMinor, 'amountMinor');
|
|
166
|
+
if (!CURRENCY.test(input.currency))
|
|
167
|
+
fail('invalid_currency', 'currency must be ISO-like uppercase ASCII');
|
|
168
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
169
|
+
const reservedAt = instant(input.reservedAt, 'reservedAt');
|
|
170
|
+
const invokeBy = instant(input.invokeBy, 'invokeBy');
|
|
171
|
+
const reconcileBy = instant(input.reconcileBy, 'reconcileBy');
|
|
172
|
+
const authorizationExpiresAt = instant(input.authorizationExpiresAt, 'authorizationExpiresAt');
|
|
173
|
+
if (reservedAt < instant(input.windowStart, 'windowStart')
|
|
174
|
+
|| reservedAt >= instant(input.windowEnd, 'windowEnd')) {
|
|
175
|
+
fail('invalid_time', 'reservedAt must be inside the ceiling window');
|
|
176
|
+
}
|
|
177
|
+
if (invokeBy < reservedAt || reconcileBy < invokeBy
|
|
178
|
+
|| invokeBy > instant(input.windowEnd, 'windowEnd')
|
|
179
|
+
|| invokeBy > authorizationExpiresAt
|
|
180
|
+
|| authorizationExpiresAt < reservedAt) {
|
|
181
|
+
fail('invalid_time', 'deadlines must be monotonic');
|
|
182
|
+
}
|
|
183
|
+
sha256(input.reservationEvidenceDigest, 'reservationEvidenceDigest');
|
|
184
|
+
}
|
|
185
|
+
function validateInvocationBinding(input) {
|
|
186
|
+
identifier(input.programVersion, 'programVersion');
|
|
187
|
+
if (typeof input.caid !== 'string' || !CAID.test(input.caid)) {
|
|
188
|
+
fail('invalid_caid', 'caid is invalid');
|
|
189
|
+
}
|
|
190
|
+
for (const [field, value] of Object.entries({
|
|
191
|
+
programSourceDigest: input.programSourceDigest,
|
|
192
|
+
programDigest: input.programDigest,
|
|
193
|
+
actionDigest: input.actionDigest,
|
|
194
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
195
|
+
authorizationDigest: input.authorizationDigest,
|
|
196
|
+
}))
|
|
197
|
+
sha256(value, field);
|
|
198
|
+
instant(input.authorizationExpiresAt, 'authorizationExpiresAt');
|
|
199
|
+
}
|
|
200
|
+
function invocationBindingMatches(record, input) {
|
|
201
|
+
return record.programVersion === input.programVersion
|
|
202
|
+
&& record.programSourceDigest === input.programSourceDigest
|
|
203
|
+
&& record.programDigest === input.programDigest
|
|
204
|
+
&& record.caid === input.caid
|
|
205
|
+
&& record.actionDigest === input.actionDigest
|
|
206
|
+
&& record.admissionSnapshotDigest === input.admissionSnapshotDigest
|
|
207
|
+
&& record.authorizationDigest === input.authorizationDigest
|
|
208
|
+
&& record.authorizationExpiresAt === input.authorizationExpiresAt;
|
|
209
|
+
}
|
|
210
|
+
function validateReference(input) {
|
|
211
|
+
identifier(input.tenantId, 'tenantId');
|
|
212
|
+
identifier(input.exposureId, 'exposureId');
|
|
213
|
+
}
|
|
214
|
+
function tokenDigest(token) {
|
|
215
|
+
return digest('EP-OPEN-EXPOSURE-TOKEN-v1', token);
|
|
216
|
+
}
|
|
217
|
+
function permitDigest(record, permit) {
|
|
218
|
+
return digest('EP-OPEN-EXPOSURE-INVOCATION-PERMIT-v1', {
|
|
219
|
+
permit,
|
|
220
|
+
tenantId: record.tenantId,
|
|
221
|
+
exposureId: record.exposureId,
|
|
222
|
+
operationTokenDigest: record.operationTokenDigest,
|
|
223
|
+
reservationDigest: record.reservationDigest,
|
|
224
|
+
programVersion: record.programVersion,
|
|
225
|
+
programSourceDigest: record.programSourceDigest,
|
|
226
|
+
programDigest: record.programDigest,
|
|
227
|
+
caid: record.caid,
|
|
228
|
+
actionDigest: record.actionDigest,
|
|
229
|
+
admissionSnapshotDigest: record.admissionSnapshotDigest,
|
|
230
|
+
authorizationDigest: record.authorizationDigest,
|
|
231
|
+
authorizationExpiresAt: record.authorizationExpiresAt,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function key(...parts) {
|
|
235
|
+
return parts.join('\u0000');
|
|
236
|
+
}
|
|
237
|
+
function ceilingScopeValue(input, scope) {
|
|
238
|
+
if (scope === 'TENANT')
|
|
239
|
+
return '*';
|
|
240
|
+
if (scope === 'PROGRAM')
|
|
241
|
+
return input.programId;
|
|
242
|
+
if (scope === 'COUNTERPARTY')
|
|
243
|
+
return input.counterpartyId;
|
|
244
|
+
return input.actionClass;
|
|
245
|
+
}
|
|
246
|
+
function ceilingScopeKey(input) {
|
|
247
|
+
return key(input.tenantId, input.scope, input.scopeValue, input.currency, input.windowStart, input.windowEnd);
|
|
248
|
+
}
|
|
249
|
+
function recordBody(record) {
|
|
250
|
+
return record;
|
|
251
|
+
}
|
|
252
|
+
function makeRecord(body) {
|
|
253
|
+
return frozenCopy({
|
|
254
|
+
...body,
|
|
255
|
+
recordDigest: digest('EP-OPEN-EXPOSURE-RECORD-v1', recordBody(body)),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function isOpen(record) {
|
|
259
|
+
return OPEN_STATUSES.has(record.status);
|
|
260
|
+
}
|
|
261
|
+
function compareText(left, right) {
|
|
262
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
263
|
+
}
|
|
264
|
+
function addBreakdown(source) {
|
|
265
|
+
return frozenCopy([...source.entries()]
|
|
266
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
267
|
+
.map(([breakdownKey, amountMinor]) => ({ key: breakdownKey, amountMinor })));
|
|
268
|
+
}
|
|
269
|
+
export function createMemoryOpenExposureLedger(options) {
|
|
270
|
+
if (!options || typeof options.authenticate !== 'function') {
|
|
271
|
+
fail('authenticator_required', 'authenticate is required');
|
|
272
|
+
}
|
|
273
|
+
if (options.clock !== undefined && typeof options.clock !== 'function') {
|
|
274
|
+
fail('clock_invalid', 'clock must be a function');
|
|
275
|
+
}
|
|
276
|
+
const readClock = options.clock ?? (() => (new Date(performance.timeOrigin + performance.now()).toISOString()));
|
|
277
|
+
let lastClockMs = null;
|
|
278
|
+
function trustedNow() {
|
|
279
|
+
let value;
|
|
280
|
+
try {
|
|
281
|
+
value = readClock();
|
|
282
|
+
}
|
|
283
|
+
catch (cause) {
|
|
284
|
+
throw new OpenExposureValidationError('clock_failed', `trusted clock failed${cause instanceof Error ? `: ${cause.message}` : ''}`);
|
|
285
|
+
}
|
|
286
|
+
const parsed = instant(value, 'clock');
|
|
287
|
+
if (lastClockMs !== null && parsed < lastClockMs) {
|
|
288
|
+
fail('clock_regressed', 'trusted clock regressed');
|
|
289
|
+
}
|
|
290
|
+
lastClockMs = parsed;
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
const ceilingsById = new Map();
|
|
294
|
+
const ceilingsByScope = new Map();
|
|
295
|
+
const records = new Map();
|
|
296
|
+
const operations = new Map();
|
|
297
|
+
const histories = new Map();
|
|
298
|
+
const reconciliationTokens = new Map();
|
|
299
|
+
let mutexTail = Promise.resolve();
|
|
300
|
+
async function exclusive(operation) {
|
|
301
|
+
let release;
|
|
302
|
+
const mine = new Promise((resolve) => { release = resolve; });
|
|
303
|
+
const previous = mutexTail;
|
|
304
|
+
mutexTail = mine;
|
|
305
|
+
await previous;
|
|
306
|
+
try {
|
|
307
|
+
return await operation();
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
release();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function authenticated(tenantId, auth, expectedRole, expectedAuthority) {
|
|
314
|
+
validateAuth(auth);
|
|
315
|
+
let accepted = false;
|
|
316
|
+
try {
|
|
317
|
+
accepted = await options.authenticate(frozenCopy({ tenantId, auth }));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
accepted = false;
|
|
321
|
+
}
|
|
322
|
+
if (!accepted)
|
|
323
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
324
|
+
if ((expectedRole && auth.role !== expectedRole)
|
|
325
|
+
|| (expectedAuthority && auth.authorityId !== expectedAuthority)) {
|
|
326
|
+
return { ok: false, reason: 'wrong_authority' };
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
function appendHistory(record, event, evidenceDigest, recordedAt) {
|
|
331
|
+
const recordKey = key(record.tenantId, record.exposureId);
|
|
332
|
+
const existing = histories.get(recordKey) ?? [];
|
|
333
|
+
const predecessor = existing.at(-1)?.entryDigest ?? null;
|
|
334
|
+
const body = {
|
|
335
|
+
'@version': OPEN_EXPOSURE_HISTORY_VERSION,
|
|
336
|
+
tenantId: record.tenantId,
|
|
337
|
+
exposureId: record.exposureId,
|
|
338
|
+
sequence: existing.length,
|
|
339
|
+
event,
|
|
340
|
+
programVersion: record.programVersion,
|
|
341
|
+
programSourceDigest: record.programSourceDigest,
|
|
342
|
+
programDigest: record.programDigest,
|
|
343
|
+
caid: record.caid,
|
|
344
|
+
actionDigest: record.actionDigest,
|
|
345
|
+
admissionSnapshotDigest: record.admissionSnapshotDigest,
|
|
346
|
+
authorizationDigest: record.authorizationDigest,
|
|
347
|
+
authorizationExpiresAt: record.authorizationExpiresAt,
|
|
348
|
+
invocationPermitDigest: record.invocationPermitDigest,
|
|
349
|
+
recordDigest: record.recordDigest,
|
|
350
|
+
evidenceDigest,
|
|
351
|
+
recordedAt,
|
|
352
|
+
predecessorEntryDigest: predecessor,
|
|
353
|
+
};
|
|
354
|
+
const entry = frozenCopy({
|
|
355
|
+
...body,
|
|
356
|
+
entryDigest: digest('EP-OPEN-EXPOSURE-HISTORY-ENTRY-v1', body),
|
|
357
|
+
});
|
|
358
|
+
histories.set(recordKey, [...existing, entry]);
|
|
359
|
+
}
|
|
360
|
+
function replaceRecord(current, update) {
|
|
361
|
+
const { recordDigest: predecessorRecordDigest, ...body } = current;
|
|
362
|
+
return makeRecord({
|
|
363
|
+
...body,
|
|
364
|
+
...update,
|
|
365
|
+
revision: current.revision + 1,
|
|
366
|
+
predecessorRecordDigest,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function recordFor(input) {
|
|
370
|
+
return records.get(key(input.tenantId, input.exposureId)) ?? null;
|
|
371
|
+
}
|
|
372
|
+
function operationMatches(record, operationToken) {
|
|
373
|
+
return record.operationTokenDigest === tokenDigest(operationToken);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
durable: false,
|
|
377
|
+
atomicReserve: true,
|
|
378
|
+
appendOnlyHistory: true,
|
|
379
|
+
blindRelease: false,
|
|
380
|
+
reconciliationOnlyCloseout: true,
|
|
381
|
+
testOnly: true,
|
|
382
|
+
async registerCeiling(input, auth) {
|
|
383
|
+
validateCeiling(input);
|
|
384
|
+
const denied = await authenticated(input.tenantId, auth, 'POLICY_ADMIN');
|
|
385
|
+
if (denied)
|
|
386
|
+
return denied;
|
|
387
|
+
return exclusive(() => {
|
|
388
|
+
const idKey = key(input.tenantId, input.ceilingId);
|
|
389
|
+
const scopeKey = ceilingScopeKey(input);
|
|
390
|
+
const body = {
|
|
391
|
+
'@version': OPEN_EXPOSURE_LEDGER_VERSION,
|
|
392
|
+
...structuredClone(input),
|
|
393
|
+
};
|
|
394
|
+
const candidate = frozenCopy({
|
|
395
|
+
...body,
|
|
396
|
+
ceilingDigest: digest('EP-OPEN-EXPOSURE-CEILING-v1', body),
|
|
397
|
+
});
|
|
398
|
+
const byId = ceilingsById.get(idKey);
|
|
399
|
+
if (byId) {
|
|
400
|
+
return byId.ceilingDigest === candidate.ceilingDigest
|
|
401
|
+
? { ok: true, ceiling: byId, replayed: true }
|
|
402
|
+
: { ok: false, reason: 'ceiling_id_conflict' };
|
|
403
|
+
}
|
|
404
|
+
const byScope = ceilingsByScope.get(scopeKey);
|
|
405
|
+
if (byScope) {
|
|
406
|
+
return byScope.ceilingDigest === candidate.ceilingDigest
|
|
407
|
+
? { ok: true, ceiling: byScope, replayed: true }
|
|
408
|
+
: { ok: false, reason: 'ceiling_scope_conflict' };
|
|
409
|
+
}
|
|
410
|
+
ceilingsById.set(idKey, candidate);
|
|
411
|
+
ceilingsByScope.set(scopeKey, candidate);
|
|
412
|
+
return { ok: true, ceiling: candidate, replayed: false };
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
async reserve(input, auth) {
|
|
416
|
+
validateReservation(input);
|
|
417
|
+
const denied = await authenticated(input.tenantId, auth, 'ORIGIN', input.originAuthorityId);
|
|
418
|
+
if (denied)
|
|
419
|
+
return denied;
|
|
420
|
+
if (new Set([
|
|
421
|
+
input.originAuthorityId,
|
|
422
|
+
input.executorAuthorityId,
|
|
423
|
+
input.reconciliationAuthorityId,
|
|
424
|
+
]).size !== 3) {
|
|
425
|
+
return { ok: false, reason: 'authority_separation_required' };
|
|
426
|
+
}
|
|
427
|
+
const operationTokenDigest = tokenDigest(input.operationToken);
|
|
428
|
+
const { operationToken: _operationToken, ...reservationInput } = structuredClone(input);
|
|
429
|
+
const reservationBody = { ...reservationInput, operationTokenDigest };
|
|
430
|
+
const reservationDigest = digest('EP-OPEN-EXPOSURE-RESERVATION-v1', reservationBody);
|
|
431
|
+
return exclusive(() => {
|
|
432
|
+
const operationKey = key(input.tenantId, operationTokenDigest);
|
|
433
|
+
const existingOperation = operations.get(operationKey);
|
|
434
|
+
if (existingOperation) {
|
|
435
|
+
return existingOperation.reservationDigest === reservationDigest
|
|
436
|
+
? { ok: true, record: existingOperation.record, replayed: true }
|
|
437
|
+
: { ok: false, reason: 'operation_token_conflict' };
|
|
438
|
+
}
|
|
439
|
+
const recordKey = key(input.tenantId, input.exposureId);
|
|
440
|
+
if (records.has(recordKey))
|
|
441
|
+
return { ok: false, reason: 'exposure_exists' };
|
|
442
|
+
const applicable = [];
|
|
443
|
+
for (const scope of SCOPES) {
|
|
444
|
+
const configured = ceilingsByScope.get(key(input.tenantId, scope, ceilingScopeValue(input, scope), input.currency, input.windowStart, input.windowEnd));
|
|
445
|
+
if (!configured)
|
|
446
|
+
return { ok: false, reason: 'ceiling_not_configured' };
|
|
447
|
+
applicable.push(configured);
|
|
448
|
+
}
|
|
449
|
+
const open = [...records.values()].filter((record) => (record.tenantId === input.tenantId
|
|
450
|
+
&& record.currency === input.currency
|
|
451
|
+
&& record.windowStart === input.windowStart
|
|
452
|
+
&& record.windowEnd === input.windowEnd
|
|
453
|
+
&& isOpen(record)));
|
|
454
|
+
for (const configured of applicable) {
|
|
455
|
+
const used = open
|
|
456
|
+
.filter((record) => {
|
|
457
|
+
if (configured.scope === 'TENANT')
|
|
458
|
+
return true;
|
|
459
|
+
if (configured.scope === 'PROGRAM')
|
|
460
|
+
return record.programId === configured.scopeValue;
|
|
461
|
+
if (configured.scope === 'COUNTERPARTY')
|
|
462
|
+
return record.counterpartyId === configured.scopeValue;
|
|
463
|
+
return record.actionClass === configured.scopeValue;
|
|
464
|
+
})
|
|
465
|
+
.reduce((sum, record) => sum + record.amountMinor, 0n);
|
|
466
|
+
if (used > configured.limitMinor
|
|
467
|
+
|| input.amountMinor > configured.limitMinor - used) {
|
|
468
|
+
return { ok: false, reason: 'ceiling_exceeded' };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const record = makeRecord({
|
|
472
|
+
'@version': OPEN_EXPOSURE_LEDGER_VERSION,
|
|
473
|
+
tenantId: input.tenantId,
|
|
474
|
+
exposureId: input.exposureId,
|
|
475
|
+
operationTokenDigest,
|
|
476
|
+
reservationDigest,
|
|
477
|
+
programId: input.programId,
|
|
478
|
+
programVersion: input.programVersion,
|
|
479
|
+
programSourceDigest: input.programSourceDigest,
|
|
480
|
+
programDigest: input.programDigest,
|
|
481
|
+
caid: input.caid,
|
|
482
|
+
actionDigest: input.actionDigest,
|
|
483
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
484
|
+
authorizationDigest: input.authorizationDigest,
|
|
485
|
+
authorizationExpiresAt: input.authorizationExpiresAt,
|
|
486
|
+
counterpartyId: input.counterpartyId,
|
|
487
|
+
actionClass: input.actionClass,
|
|
488
|
+
amountMinor: input.amountMinor,
|
|
489
|
+
currency: input.currency,
|
|
490
|
+
windowStart: input.windowStart,
|
|
491
|
+
windowEnd: input.windowEnd,
|
|
492
|
+
reservedAt: input.reservedAt,
|
|
493
|
+
invokeBy: input.invokeBy,
|
|
494
|
+
reconcileBy: input.reconcileBy,
|
|
495
|
+
originAuthorityId: input.originAuthorityId,
|
|
496
|
+
executorAuthorityId: input.executorAuthorityId,
|
|
497
|
+
reconciliationAuthorityId: input.reconciliationAuthorityId,
|
|
498
|
+
reservationEvidenceDigest: input.reservationEvidenceDigest,
|
|
499
|
+
ceilingDigests: applicable.map((entry) => entry.ceilingDigest).sort(compareText),
|
|
500
|
+
revision: 0,
|
|
501
|
+
status: 'RESERVED',
|
|
502
|
+
invokedAt: null,
|
|
503
|
+
invocationPermitDigest: null,
|
|
504
|
+
indeterminateEvidenceDigest: null,
|
|
505
|
+
reconciliationOutcome: null,
|
|
506
|
+
reconciliationEvidenceDigest: null,
|
|
507
|
+
lastChangedAt: input.reservedAt,
|
|
508
|
+
predecessorRecordDigest: null,
|
|
509
|
+
});
|
|
510
|
+
records.set(recordKey, record);
|
|
511
|
+
operations.set(operationKey, { reservationDigest, record });
|
|
512
|
+
appendHistory(record, 'RESERVED', input.reservationEvidenceDigest, input.reservedAt);
|
|
513
|
+
return { ok: true, record, replayed: false };
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
async beginInvocation(input, auth) {
|
|
517
|
+
validateReference(input);
|
|
518
|
+
if (!OPERATION_TOKEN.test(input.operationToken))
|
|
519
|
+
fail('invalid_operation_token', 'operationToken is invalid');
|
|
520
|
+
validateInvocationBinding(input);
|
|
521
|
+
const initial = recordFor(input);
|
|
522
|
+
if (!initial) {
|
|
523
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR');
|
|
524
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
525
|
+
}
|
|
526
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR', initial.executorAuthorityId);
|
|
527
|
+
if (denied)
|
|
528
|
+
return denied;
|
|
529
|
+
return exclusive(() => {
|
|
530
|
+
const current = recordFor(input);
|
|
531
|
+
if (!current)
|
|
532
|
+
return { ok: false, reason: 'exposure_not_found' };
|
|
533
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
534
|
+
return { ok: false, reason: 'operation_token_conflict' };
|
|
535
|
+
}
|
|
536
|
+
if (!invocationBindingMatches(current, input)) {
|
|
537
|
+
return { ok: false, reason: 'immutable_binding_conflict' };
|
|
538
|
+
}
|
|
539
|
+
if (current.status === 'INVOKING' || current.status === 'INDETERMINATE') {
|
|
540
|
+
return { ok: false, reason: 'reconciliation_required' };
|
|
541
|
+
}
|
|
542
|
+
if (!isOpen(current))
|
|
543
|
+
return { ok: false, reason: 'already_closed' };
|
|
544
|
+
if (current.status !== 'RESERVED')
|
|
545
|
+
return { ok: false, reason: 'state_conflict' };
|
|
546
|
+
const invokedAt = trustedNow();
|
|
547
|
+
if (instant(invokedAt, 'clock') > instant(current.invokeBy, 'invokeBy')
|
|
548
|
+
|| instant(invokedAt, 'clock') > instant(current.authorizationExpiresAt, 'authorizationExpiresAt')) {
|
|
549
|
+
return { ok: false, reason: 'invocation_expired' };
|
|
550
|
+
}
|
|
551
|
+
if (instant(invokedAt, 'clock') < instant(current.reservedAt, 'reservedAt')) {
|
|
552
|
+
return { ok: false, reason: 'state_conflict' };
|
|
553
|
+
}
|
|
554
|
+
const invocationPermit = `open-exposure-invoke:v1:${crypto.randomBytes(32).toString('hex')}`;
|
|
555
|
+
const next = replaceRecord(current, {
|
|
556
|
+
status: 'INVOKING',
|
|
557
|
+
invokedAt,
|
|
558
|
+
invocationPermitDigest: permitDigest(current, invocationPermit),
|
|
559
|
+
lastChangedAt: invokedAt,
|
|
560
|
+
});
|
|
561
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
562
|
+
appendHistory(next, 'INVOKING', current.reservationEvidenceDigest, invokedAt);
|
|
563
|
+
return {
|
|
564
|
+
ok: true,
|
|
565
|
+
record: next,
|
|
566
|
+
replayed: false,
|
|
567
|
+
invocationPermit,
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
async markIndeterminate(input, auth) {
|
|
572
|
+
validateReference(input);
|
|
573
|
+
if (!OPERATION_TOKEN.test(input.operationToken))
|
|
574
|
+
fail('invalid_operation_token', 'operationToken is invalid');
|
|
575
|
+
sha256(input.evidenceDigest, 'evidenceDigest');
|
|
576
|
+
instant(input.observedAt, 'observedAt');
|
|
577
|
+
const initial = recordFor(input);
|
|
578
|
+
if (!initial) {
|
|
579
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR');
|
|
580
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
581
|
+
}
|
|
582
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR', initial.executorAuthorityId);
|
|
583
|
+
if (denied)
|
|
584
|
+
return denied;
|
|
585
|
+
return exclusive(() => {
|
|
586
|
+
const current = recordFor(input);
|
|
587
|
+
if (!current)
|
|
588
|
+
return { ok: false, reason: 'exposure_not_found' };
|
|
589
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
590
|
+
return { ok: false, reason: 'operation_token_conflict' };
|
|
591
|
+
}
|
|
592
|
+
if (current.status === 'INDETERMINATE') {
|
|
593
|
+
return current.indeterminateEvidenceDigest === input.evidenceDigest
|
|
594
|
+
&& current.lastChangedAt === input.observedAt
|
|
595
|
+
? { ok: true, record: current, replayed: true }
|
|
596
|
+
: { ok: false, reason: 'state_conflict' };
|
|
597
|
+
}
|
|
598
|
+
if (!isOpen(current))
|
|
599
|
+
return { ok: false, reason: 'already_closed' };
|
|
600
|
+
if (current.status !== 'INVOKING')
|
|
601
|
+
return { ok: false, reason: 'state_conflict' };
|
|
602
|
+
if (current.invokedAt && instant(input.observedAt, 'observedAt') < instant(current.invokedAt, 'invokedAt')) {
|
|
603
|
+
return { ok: false, reason: 'state_conflict' };
|
|
604
|
+
}
|
|
605
|
+
const next = replaceRecord(current, {
|
|
606
|
+
status: 'INDETERMINATE',
|
|
607
|
+
indeterminateEvidenceDigest: input.evidenceDigest,
|
|
608
|
+
lastChangedAt: input.observedAt,
|
|
609
|
+
});
|
|
610
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
611
|
+
appendHistory(next, 'INDETERMINATE', input.evidenceDigest, input.observedAt);
|
|
612
|
+
return { ok: true, record: next, replayed: false };
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
async reconcile(input, auth) {
|
|
616
|
+
validateReference(input);
|
|
617
|
+
if (!OPERATION_TOKEN.test(input.operationToken))
|
|
618
|
+
fail('invalid_operation_token', 'operationToken is invalid');
|
|
619
|
+
if (!RECONCILIATION_TOKEN.test(input.reconciliationToken)) {
|
|
620
|
+
fail('invalid_reconciliation_token', 'reconciliationToken is invalid');
|
|
621
|
+
}
|
|
622
|
+
if (!['COMMITTED', 'PROVEN_NOT_COMMITTED', 'INDETERMINATE'].includes(input.outcome)) {
|
|
623
|
+
fail('invalid_outcome', 'reconciliation outcome is invalid');
|
|
624
|
+
}
|
|
625
|
+
sha256(input.evidenceDigest, 'evidenceDigest');
|
|
626
|
+
instant(input.observedAt, 'observedAt');
|
|
627
|
+
const initial = recordFor(input);
|
|
628
|
+
if (!initial) {
|
|
629
|
+
const denied = await authenticated(input.tenantId, auth, 'RECONCILER');
|
|
630
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
631
|
+
}
|
|
632
|
+
const denied = await authenticated(input.tenantId, auth, 'RECONCILER', initial.reconciliationAuthorityId);
|
|
633
|
+
if (denied)
|
|
634
|
+
return denied;
|
|
635
|
+
const reconciliationTokenDigest = tokenDigest(input.reconciliationToken);
|
|
636
|
+
const { operationToken: _operationToken, reconciliationToken: _reconciliationToken, ...reconciliationInput } = structuredClone(input);
|
|
637
|
+
const requestDigest = digest('EP-OPEN-EXPOSURE-RECONCILIATION-v1', {
|
|
638
|
+
...reconciliationInput,
|
|
639
|
+
operationTokenDigest: tokenDigest(input.operationToken),
|
|
640
|
+
reconciliationTokenDigest,
|
|
641
|
+
});
|
|
642
|
+
return exclusive(() => {
|
|
643
|
+
const reconciliationKey = key(input.tenantId, reconciliationTokenDigest);
|
|
644
|
+
const prior = reconciliationTokens.get(reconciliationKey);
|
|
645
|
+
if (prior) {
|
|
646
|
+
return prior.requestDigest === requestDigest
|
|
647
|
+
? { ok: true, record: prior.record, replayed: true }
|
|
648
|
+
: { ok: false, reason: 'reconciliation_token_conflict' };
|
|
649
|
+
}
|
|
650
|
+
const current = recordFor(input);
|
|
651
|
+
if (!current)
|
|
652
|
+
return { ok: false, reason: 'exposure_not_found' };
|
|
653
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
654
|
+
return { ok: false, reason: 'operation_token_conflict' };
|
|
655
|
+
}
|
|
656
|
+
if (!isOpen(current))
|
|
657
|
+
return { ok: false, reason: 'already_closed' };
|
|
658
|
+
if (instant(input.observedAt, 'observedAt') < instant(current.lastChangedAt, 'lastChangedAt')) {
|
|
659
|
+
return { ok: false, reason: 'state_conflict' };
|
|
660
|
+
}
|
|
661
|
+
if (input.outcome === 'COMMITTED' && current.status === 'RESERVED') {
|
|
662
|
+
return { ok: false, reason: 'state_conflict' };
|
|
663
|
+
}
|
|
664
|
+
if (input.outcome === 'INDETERMINATE' && current.status === 'RESERVED') {
|
|
665
|
+
return { ok: false, reason: 'state_conflict' };
|
|
666
|
+
}
|
|
667
|
+
const status = input.outcome === 'COMMITTED'
|
|
668
|
+
? 'CLOSED_COMMITTED'
|
|
669
|
+
: input.outcome === 'PROVEN_NOT_COMMITTED'
|
|
670
|
+
? 'CLOSED_PROVEN_NOT_COMMITTED'
|
|
671
|
+
: 'INDETERMINATE';
|
|
672
|
+
const next = replaceRecord(current, {
|
|
673
|
+
status,
|
|
674
|
+
indeterminateEvidenceDigest: input.outcome === 'INDETERMINATE'
|
|
675
|
+
? input.evidenceDigest
|
|
676
|
+
: current.indeterminateEvidenceDigest,
|
|
677
|
+
reconciliationOutcome: input.outcome,
|
|
678
|
+
reconciliationEvidenceDigest: input.evidenceDigest,
|
|
679
|
+
lastChangedAt: input.observedAt,
|
|
680
|
+
});
|
|
681
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
682
|
+
const event = input.outcome === 'COMMITTED'
|
|
683
|
+
? 'CLOSED_COMMITTED'
|
|
684
|
+
: input.outcome === 'PROVEN_NOT_COMMITTED'
|
|
685
|
+
? 'CLOSED_PROVEN_NOT_COMMITTED'
|
|
686
|
+
: 'RECONCILED_INDETERMINATE';
|
|
687
|
+
appendHistory(next, event, input.evidenceDigest, input.observedAt);
|
|
688
|
+
reconciliationTokens.set(reconciliationKey, { requestDigest, record: next });
|
|
689
|
+
return { ok: true, record: next, replayed: false };
|
|
690
|
+
});
|
|
691
|
+
},
|
|
692
|
+
async read(input, auth) {
|
|
693
|
+
validateReference(input);
|
|
694
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
695
|
+
if (denied)
|
|
696
|
+
return denied;
|
|
697
|
+
return { ok: true, record: recordFor(input) };
|
|
698
|
+
},
|
|
699
|
+
async history(input, auth) {
|
|
700
|
+
validateReference(input);
|
|
701
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
702
|
+
if (denied)
|
|
703
|
+
return denied;
|
|
704
|
+
return {
|
|
705
|
+
ok: true,
|
|
706
|
+
entries: frozenCopy(histories.get(key(input.tenantId, input.exposureId)) ?? []),
|
|
707
|
+
};
|
|
708
|
+
},
|
|
709
|
+
async sumOpen(input, auth) {
|
|
710
|
+
identifier(input.tenantId, 'tenantId');
|
|
711
|
+
if (!CURRENCY.test(input.currency))
|
|
712
|
+
fail('invalid_currency', 'currency is invalid');
|
|
713
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
714
|
+
if (input.programId !== undefined)
|
|
715
|
+
identifier(input.programId, 'programId');
|
|
716
|
+
if (input.counterpartyId !== undefined)
|
|
717
|
+
identifier(input.counterpartyId, 'counterpartyId');
|
|
718
|
+
if (input.actionClass !== undefined)
|
|
719
|
+
identifier(input.actionClass, 'actionClass');
|
|
720
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
721
|
+
if (denied)
|
|
722
|
+
return denied;
|
|
723
|
+
const selected = [...records.values()].filter((record) => (record.tenantId === input.tenantId
|
|
724
|
+
&& record.currency === input.currency
|
|
725
|
+
&& record.windowStart === input.windowStart
|
|
726
|
+
&& record.windowEnd === input.windowEnd
|
|
727
|
+
&& (input.programId === undefined || record.programId === input.programId)
|
|
728
|
+
&& (input.counterpartyId === undefined || record.counterpartyId === input.counterpartyId)
|
|
729
|
+
&& (input.actionClass === undefined || record.actionClass === input.actionClass)
|
|
730
|
+
&& isOpen(record)));
|
|
731
|
+
const maps = {
|
|
732
|
+
program: new Map(),
|
|
733
|
+
counterparty: new Map(),
|
|
734
|
+
actionClass: new Map(),
|
|
735
|
+
status: new Map(),
|
|
736
|
+
};
|
|
737
|
+
for (const record of selected) {
|
|
738
|
+
maps.program.set(record.programId, (maps.program.get(record.programId) ?? 0n) + record.amountMinor);
|
|
739
|
+
maps.counterparty.set(record.counterpartyId, (maps.counterparty.get(record.counterpartyId) ?? 0n) + record.amountMinor);
|
|
740
|
+
maps.actionClass.set(record.actionClass, (maps.actionClass.get(record.actionClass) ?? 0n) + record.amountMinor);
|
|
741
|
+
maps.status.set(record.status, (maps.status.get(record.status) ?? 0n) + record.amountMinor);
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
totalMinor: selected.reduce((sum, record) => sum + record.amountMinor, 0n),
|
|
746
|
+
byProgram: addBreakdown(maps.program),
|
|
747
|
+
byCounterparty: addBreakdown(maps.counterparty),
|
|
748
|
+
byActionClass: addBreakdown(maps.actionClass),
|
|
749
|
+
byStatus: addBreakdown(maps.status),
|
|
750
|
+
};
|
|
751
|
+
},
|
|
752
|
+
async listAging(input, auth) {
|
|
753
|
+
identifier(input.tenantId, 'tenantId');
|
|
754
|
+
const asOf = instant(input.asOf, 'asOf');
|
|
755
|
+
if (!Number.isSafeInteger(input.minimumAgeMs) || input.minimumAgeMs < 0) {
|
|
756
|
+
fail('invalid_age', 'minimumAgeMs must be a non-negative safe integer');
|
|
757
|
+
}
|
|
758
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 10_000) {
|
|
759
|
+
fail('invalid_limit', 'limit is invalid');
|
|
760
|
+
}
|
|
761
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
762
|
+
if (denied)
|
|
763
|
+
return denied;
|
|
764
|
+
const selected = [...records.values()]
|
|
765
|
+
.filter((record) => record.tenantId === input.tenantId
|
|
766
|
+
&& isOpen(record)
|
|
767
|
+
&& asOf - instant(record.reservedAt, 'reservedAt') >= input.minimumAgeMs)
|
|
768
|
+
.sort((left, right) => compareText(left.reservedAt, right.reservedAt)
|
|
769
|
+
|| compareText(left.exposureId, right.exposureId))
|
|
770
|
+
.slice(0, input.limit);
|
|
771
|
+
return { ok: true, records: frozenCopy(selected) };
|
|
772
|
+
},
|
|
773
|
+
async listDeadlines(input, auth) {
|
|
774
|
+
identifier(input.tenantId, 'tenantId');
|
|
775
|
+
const dueAtOrBefore = instant(input.dueAtOrBefore, 'dueAtOrBefore');
|
|
776
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 10_000) {
|
|
777
|
+
fail('invalid_limit', 'limit is invalid');
|
|
778
|
+
}
|
|
779
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
780
|
+
if (denied)
|
|
781
|
+
return denied;
|
|
782
|
+
const deadline = (record) => (record.status === 'RESERVED' ? record.invokeBy : record.reconcileBy);
|
|
783
|
+
const selected = [...records.values()]
|
|
784
|
+
.filter((record) => record.tenantId === input.tenantId
|
|
785
|
+
&& isOpen(record)
|
|
786
|
+
&& instant(deadline(record), 'deadline') <= dueAtOrBefore)
|
|
787
|
+
.sort((left, right) => compareText(deadline(left), deadline(right))
|
|
788
|
+
|| compareText(left.exposureId, right.exposureId))
|
|
789
|
+
.slice(0, input.limit);
|
|
790
|
+
return { ok: true, records: frozenCopy(selected) };
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
//# sourceMappingURL=open-exposure-ledger.js.map
|