@emilia-protocol/gate 0.18.2 → 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 +56 -0
- package/README.md +64 -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/gate-qualification-v2.d.ts +4 -0
- package/dist/gate-qualification-v2.d.ts.map +1 -1
- package/dist/gate-qualification-v2.js +39 -47
- package/dist/gate-qualification-v2.js.map +1 -1
- 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-program.d.ts +88 -0
- package/dist/reliance-program.d.ts.map +1 -0
- package/dist/reliance-program.js +437 -0
- package/dist/reliance-program.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/dist/remedy-program.d.ts.map +1 -1
- package/dist/remedy-program.js +28 -18
- package/dist/remedy-program.js.map +1 -1
- 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 +42 -2
- package/receipt-census.js +1 -0
- package/reliance-program.js +3 -0
- package/src/action-refusal-statement.ts +396 -0
- package/src/coverage-reconciliation-attestation.ts +107 -0
- package/src/gate-qualification-v2.ts +56 -46
- 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-program.ts +499 -0
- package/src/reliance-risk-crypto.ts +114 -0
- package/src/remedy-program.ts +28 -17
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Open Exposure Ledger v1.
|
|
4
|
+
*
|
|
5
|
+
* The ledger reserves fixed minor-unit exposure before provider entry. A
|
|
6
|
+
* reservation remains open while RESERVED, INVOKING, or INDETERMINATE and can
|
|
7
|
+
* be closed only by a separately authenticated reconciliation authority. The
|
|
8
|
+
* in-memory implementation is a linearizable conformance/reference store; it
|
|
9
|
+
* is not a durability claim.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { performance } from 'node:perf_hooks';
|
|
14
|
+
|
|
15
|
+
export const OPEN_EXPOSURE_LEDGER_VERSION = 'EP-OPEN-EXPOSURE-LEDGER-v1';
|
|
16
|
+
export const OPEN_EXPOSURE_HISTORY_VERSION = 'EP-OPEN-EXPOSURE-HISTORY-v1';
|
|
17
|
+
|
|
18
|
+
export type OpenExposureRole =
|
|
19
|
+
| 'POLICY_ADMIN'
|
|
20
|
+
| 'ORIGIN'
|
|
21
|
+
| 'EXECUTOR'
|
|
22
|
+
| 'RECONCILER'
|
|
23
|
+
| 'READER';
|
|
24
|
+
|
|
25
|
+
export interface OpenExposureAuth {
|
|
26
|
+
role: OpenExposureRole;
|
|
27
|
+
authorityId: string;
|
|
28
|
+
credential: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenExposureCeilingScope =
|
|
32
|
+
| 'TENANT'
|
|
33
|
+
| 'PROGRAM'
|
|
34
|
+
| 'COUNTERPARTY'
|
|
35
|
+
| 'ACTION_CLASS';
|
|
36
|
+
|
|
37
|
+
export interface OpenExposureCeilingInput {
|
|
38
|
+
tenantId: string;
|
|
39
|
+
ceilingId: string;
|
|
40
|
+
scope: OpenExposureCeilingScope;
|
|
41
|
+
scopeValue: string;
|
|
42
|
+
currency: string;
|
|
43
|
+
windowStart: string;
|
|
44
|
+
windowEnd: string;
|
|
45
|
+
limitMinor: bigint;
|
|
46
|
+
policyDigest: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface OpenExposureCeiling extends OpenExposureCeilingInput {
|
|
50
|
+
'@version': typeof OPEN_EXPOSURE_LEDGER_VERSION;
|
|
51
|
+
ceilingDigest: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OpenExposureInvocationBinding {
|
|
55
|
+
programVersion: string;
|
|
56
|
+
programSourceDigest: string;
|
|
57
|
+
programDigest: string;
|
|
58
|
+
caid: string;
|
|
59
|
+
actionDigest: string;
|
|
60
|
+
admissionSnapshotDigest: string;
|
|
61
|
+
authorizationDigest: string;
|
|
62
|
+
authorizationExpiresAt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OpenExposureReserveInput extends OpenExposureInvocationBinding {
|
|
66
|
+
tenantId: string;
|
|
67
|
+
exposureId: string;
|
|
68
|
+
operationToken: string;
|
|
69
|
+
programId: string;
|
|
70
|
+
counterpartyId: string;
|
|
71
|
+
actionClass: string;
|
|
72
|
+
amountMinor: bigint;
|
|
73
|
+
currency: string;
|
|
74
|
+
windowStart: string;
|
|
75
|
+
windowEnd: string;
|
|
76
|
+
reservedAt: string;
|
|
77
|
+
invokeBy: string;
|
|
78
|
+
reconcileBy: string;
|
|
79
|
+
originAuthorityId: string;
|
|
80
|
+
executorAuthorityId: string;
|
|
81
|
+
reconciliationAuthorityId: string;
|
|
82
|
+
reservationEvidenceDigest: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type OpenExposureStatus =
|
|
86
|
+
| 'RESERVED'
|
|
87
|
+
| 'INVOKING'
|
|
88
|
+
| 'INDETERMINATE'
|
|
89
|
+
| 'CLOSED_COMMITTED'
|
|
90
|
+
| 'CLOSED_PROVEN_NOT_COMMITTED';
|
|
91
|
+
|
|
92
|
+
export type OpenExposureReconciliationOutcome =
|
|
93
|
+
| 'COMMITTED'
|
|
94
|
+
| 'PROVEN_NOT_COMMITTED'
|
|
95
|
+
| 'INDETERMINATE';
|
|
96
|
+
|
|
97
|
+
export interface OpenExposureRecord {
|
|
98
|
+
'@version': typeof OPEN_EXPOSURE_LEDGER_VERSION;
|
|
99
|
+
tenantId: string;
|
|
100
|
+
exposureId: string;
|
|
101
|
+
operationTokenDigest: string;
|
|
102
|
+
reservationDigest: string;
|
|
103
|
+
programId: string;
|
|
104
|
+
programVersion: string;
|
|
105
|
+
programSourceDigest: string;
|
|
106
|
+
programDigest: string;
|
|
107
|
+
caid: string;
|
|
108
|
+
actionDigest: string;
|
|
109
|
+
admissionSnapshotDigest: string;
|
|
110
|
+
authorizationDigest: string;
|
|
111
|
+
authorizationExpiresAt: string;
|
|
112
|
+
counterpartyId: string;
|
|
113
|
+
actionClass: string;
|
|
114
|
+
amountMinor: bigint;
|
|
115
|
+
currency: string;
|
|
116
|
+
windowStart: string;
|
|
117
|
+
windowEnd: string;
|
|
118
|
+
reservedAt: string;
|
|
119
|
+
invokeBy: string;
|
|
120
|
+
reconcileBy: string;
|
|
121
|
+
originAuthorityId: string;
|
|
122
|
+
executorAuthorityId: string;
|
|
123
|
+
reconciliationAuthorityId: string;
|
|
124
|
+
reservationEvidenceDigest: string;
|
|
125
|
+
ceilingDigests: readonly string[];
|
|
126
|
+
revision: number;
|
|
127
|
+
status: OpenExposureStatus;
|
|
128
|
+
invokedAt: string | null;
|
|
129
|
+
invocationPermitDigest: string | null;
|
|
130
|
+
indeterminateEvidenceDigest: string | null;
|
|
131
|
+
reconciliationOutcome: OpenExposureReconciliationOutcome | null;
|
|
132
|
+
reconciliationEvidenceDigest: string | null;
|
|
133
|
+
lastChangedAt: string;
|
|
134
|
+
predecessorRecordDigest: string | null;
|
|
135
|
+
recordDigest: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type OpenExposureHistoryEvent =
|
|
139
|
+
| 'RESERVED'
|
|
140
|
+
| 'INVOKING'
|
|
141
|
+
| 'INDETERMINATE'
|
|
142
|
+
| 'RECONCILED_INDETERMINATE'
|
|
143
|
+
| 'CLOSED_COMMITTED'
|
|
144
|
+
| 'CLOSED_PROVEN_NOT_COMMITTED';
|
|
145
|
+
|
|
146
|
+
export interface OpenExposureHistoryEntry {
|
|
147
|
+
'@version': typeof OPEN_EXPOSURE_HISTORY_VERSION;
|
|
148
|
+
tenantId: string;
|
|
149
|
+
exposureId: string;
|
|
150
|
+
sequence: number;
|
|
151
|
+
event: OpenExposureHistoryEvent;
|
|
152
|
+
programVersion: string;
|
|
153
|
+
programSourceDigest: string;
|
|
154
|
+
programDigest: string;
|
|
155
|
+
caid: string;
|
|
156
|
+
actionDigest: string;
|
|
157
|
+
admissionSnapshotDigest: string;
|
|
158
|
+
authorizationDigest: string;
|
|
159
|
+
authorizationExpiresAt: string;
|
|
160
|
+
invocationPermitDigest: string | null;
|
|
161
|
+
recordDigest: string;
|
|
162
|
+
evidenceDigest: string;
|
|
163
|
+
recordedAt: string;
|
|
164
|
+
predecessorEntryDigest: string | null;
|
|
165
|
+
entryDigest: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type OpenExposureRefusalReason =
|
|
169
|
+
| 'unauthenticated'
|
|
170
|
+
| 'wrong_authority'
|
|
171
|
+
| 'authority_separation_required'
|
|
172
|
+
| 'ceiling_not_configured'
|
|
173
|
+
| 'ceiling_exceeded'
|
|
174
|
+
| 'ceiling_id_conflict'
|
|
175
|
+
| 'ceiling_scope_conflict'
|
|
176
|
+
| 'exposure_exists'
|
|
177
|
+
| 'exposure_not_found'
|
|
178
|
+
| 'operation_token_conflict'
|
|
179
|
+
| 'immutable_binding_conflict'
|
|
180
|
+
| 'invocation_expired'
|
|
181
|
+
| 'reconciliation_token_conflict'
|
|
182
|
+
| 'state_conflict'
|
|
183
|
+
| 'reconciliation_required'
|
|
184
|
+
| 'already_closed';
|
|
185
|
+
|
|
186
|
+
export type OpenExposureRefusal = {
|
|
187
|
+
ok: false;
|
|
188
|
+
reason: OpenExposureRefusalReason;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type OpenExposureCeilingResult =
|
|
192
|
+
| { ok: true; ceiling: Readonly<OpenExposureCeiling>; replayed: boolean }
|
|
193
|
+
| OpenExposureRefusal;
|
|
194
|
+
|
|
195
|
+
export type OpenExposureRecordResult =
|
|
196
|
+
| { ok: true; record: Readonly<OpenExposureRecord>; replayed: boolean }
|
|
197
|
+
| OpenExposureRefusal;
|
|
198
|
+
|
|
199
|
+
export type OpenExposureBeginResult =
|
|
200
|
+
| {
|
|
201
|
+
ok: true;
|
|
202
|
+
record: Readonly<OpenExposureRecord>;
|
|
203
|
+
replayed: false;
|
|
204
|
+
invocationPermit: string;
|
|
205
|
+
}
|
|
206
|
+
| OpenExposureRefusal;
|
|
207
|
+
|
|
208
|
+
export interface OpenExposureReference {
|
|
209
|
+
tenantId: string;
|
|
210
|
+
exposureId: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface OpenExposureBeginInput
|
|
214
|
+
extends OpenExposureReference, OpenExposureInvocationBinding {
|
|
215
|
+
operationToken: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface OpenExposureIndeterminateInput extends OpenExposureReference {
|
|
219
|
+
operationToken: string;
|
|
220
|
+
evidenceDigest: string;
|
|
221
|
+
observedAt: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface OpenExposureReconciliationInput extends OpenExposureReference {
|
|
225
|
+
operationToken: string;
|
|
226
|
+
reconciliationToken: string;
|
|
227
|
+
outcome: OpenExposureReconciliationOutcome;
|
|
228
|
+
evidenceDigest: string;
|
|
229
|
+
observedAt: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface OpenExposureSumInput {
|
|
233
|
+
tenantId: string;
|
|
234
|
+
currency: string;
|
|
235
|
+
windowStart: string;
|
|
236
|
+
windowEnd: string;
|
|
237
|
+
programId?: string;
|
|
238
|
+
counterpartyId?: string;
|
|
239
|
+
actionClass?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface OpenExposureBreakdown {
|
|
243
|
+
key: string;
|
|
244
|
+
amountMinor: bigint;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type OpenExposureReadResult =
|
|
248
|
+
| { ok: true; record: Readonly<OpenExposureRecord> | null }
|
|
249
|
+
| OpenExposureRefusal;
|
|
250
|
+
|
|
251
|
+
export type OpenExposureHistoryResult =
|
|
252
|
+
| { ok: true; entries: readonly Readonly<OpenExposureHistoryEntry>[] }
|
|
253
|
+
| OpenExposureRefusal;
|
|
254
|
+
|
|
255
|
+
export type OpenExposureSumResult =
|
|
256
|
+
| {
|
|
257
|
+
ok: true;
|
|
258
|
+
totalMinor: bigint;
|
|
259
|
+
byProgram: readonly Readonly<OpenExposureBreakdown>[];
|
|
260
|
+
byCounterparty: readonly Readonly<OpenExposureBreakdown>[];
|
|
261
|
+
byActionClass: readonly Readonly<OpenExposureBreakdown>[];
|
|
262
|
+
byStatus: readonly Readonly<OpenExposureBreakdown>[];
|
|
263
|
+
}
|
|
264
|
+
| OpenExposureRefusal;
|
|
265
|
+
|
|
266
|
+
export interface OpenExposureAgingInput {
|
|
267
|
+
tenantId: string;
|
|
268
|
+
asOf: string;
|
|
269
|
+
minimumAgeMs: number;
|
|
270
|
+
limit: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface OpenExposureDeadlineInput {
|
|
274
|
+
tenantId: string;
|
|
275
|
+
dueAtOrBefore: string;
|
|
276
|
+
limit: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type OpenExposureListResult =
|
|
280
|
+
| { ok: true; records: readonly Readonly<OpenExposureRecord>[] }
|
|
281
|
+
| OpenExposureRefusal;
|
|
282
|
+
|
|
283
|
+
export interface OpenExposureAuthenticationInput {
|
|
284
|
+
tenantId: string;
|
|
285
|
+
auth: Readonly<OpenExposureAuth>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface CreateMemoryOpenExposureLedgerOptions {
|
|
289
|
+
authenticate: (
|
|
290
|
+
input: Readonly<OpenExposureAuthenticationInput>,
|
|
291
|
+
) => boolean | Promise<boolean>;
|
|
292
|
+
/** Absolute UTC clock backed by a monotonic source. */
|
|
293
|
+
clock?: () => string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface OpenExposureLedger {
|
|
297
|
+
readonly durable: boolean;
|
|
298
|
+
readonly atomicReserve: true;
|
|
299
|
+
readonly appendOnlyHistory: true;
|
|
300
|
+
readonly blindRelease: false;
|
|
301
|
+
readonly reconciliationOnlyCloseout: true;
|
|
302
|
+
readonly testOnly?: true;
|
|
303
|
+
registerCeiling(
|
|
304
|
+
input: OpenExposureCeilingInput,
|
|
305
|
+
auth: OpenExposureAuth,
|
|
306
|
+
): Promise<OpenExposureCeilingResult>;
|
|
307
|
+
reserve(
|
|
308
|
+
input: OpenExposureReserveInput,
|
|
309
|
+
auth: OpenExposureAuth,
|
|
310
|
+
): Promise<OpenExposureRecordResult>;
|
|
311
|
+
beginInvocation(
|
|
312
|
+
input: OpenExposureBeginInput,
|
|
313
|
+
auth: OpenExposureAuth,
|
|
314
|
+
): Promise<OpenExposureBeginResult>;
|
|
315
|
+
markIndeterminate(
|
|
316
|
+
input: OpenExposureIndeterminateInput,
|
|
317
|
+
auth: OpenExposureAuth,
|
|
318
|
+
): Promise<OpenExposureRecordResult>;
|
|
319
|
+
reconcile(
|
|
320
|
+
input: OpenExposureReconciliationInput,
|
|
321
|
+
auth: OpenExposureAuth,
|
|
322
|
+
): Promise<OpenExposureRecordResult>;
|
|
323
|
+
read(
|
|
324
|
+
input: OpenExposureReference,
|
|
325
|
+
auth: OpenExposureAuth,
|
|
326
|
+
): Promise<OpenExposureReadResult>;
|
|
327
|
+
history(
|
|
328
|
+
input: OpenExposureReference,
|
|
329
|
+
auth: OpenExposureAuth,
|
|
330
|
+
): Promise<OpenExposureHistoryResult>;
|
|
331
|
+
sumOpen(
|
|
332
|
+
input: OpenExposureSumInput,
|
|
333
|
+
auth: OpenExposureAuth,
|
|
334
|
+
): Promise<OpenExposureSumResult>;
|
|
335
|
+
listAging(
|
|
336
|
+
input: OpenExposureAgingInput,
|
|
337
|
+
auth: OpenExposureAuth,
|
|
338
|
+
): Promise<OpenExposureListResult>;
|
|
339
|
+
listDeadlines(
|
|
340
|
+
input: OpenExposureDeadlineInput,
|
|
341
|
+
auth: OpenExposureAuth,
|
|
342
|
+
): Promise<OpenExposureListResult>;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export class OpenExposureValidationError extends TypeError {
|
|
346
|
+
readonly code: string;
|
|
347
|
+
|
|
348
|
+
constructor(code: string, message: string) {
|
|
349
|
+
super(message);
|
|
350
|
+
this.name = 'OpenExposureValidationError';
|
|
351
|
+
this.code = code;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,511}$/;
|
|
356
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
357
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
358
|
+
const OPERATION_TOKEN = /^open-exposure-op:v1:[A-Za-z0-9_-]{32,128}$/;
|
|
359
|
+
const RECONCILIATION_TOKEN = /^open-exposure-reconcile:v1:[A-Za-z0-9_-]{32,128}$/;
|
|
360
|
+
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}$/;
|
|
361
|
+
const OPEN_STATUSES = new Set<OpenExposureStatus>([
|
|
362
|
+
'RESERVED', 'INVOKING', 'INDETERMINATE',
|
|
363
|
+
]);
|
|
364
|
+
const SCOPES: readonly OpenExposureCeilingScope[] = [
|
|
365
|
+
'TENANT', 'PROGRAM', 'COUNTERPARTY', 'ACTION_CLASS',
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
type Jsonish = null | boolean | number | bigint | string | Jsonish[] | {
|
|
369
|
+
[key: string]: Jsonish;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function fail(code: string, message: string): never {
|
|
373
|
+
throw new OpenExposureValidationError(code, message);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function canonical(value: Jsonish): string {
|
|
377
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
|
378
|
+
return JSON.stringify(value);
|
|
379
|
+
}
|
|
380
|
+
if (typeof value === 'number') {
|
|
381
|
+
if (!Number.isFinite(value)) fail('invalid_number', 'numbers must be finite');
|
|
382
|
+
return JSON.stringify(value);
|
|
383
|
+
}
|
|
384
|
+
if (typeof value === 'bigint') return JSON.stringify({ '@bigint': value.toString(10) });
|
|
385
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
386
|
+
const prototype = Object.getPrototypeOf(value);
|
|
387
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
388
|
+
fail('invalid_object', 'values must be plain objects');
|
|
389
|
+
}
|
|
390
|
+
return `{${Object.keys(value).sort().map((key) => (
|
|
391
|
+
`${JSON.stringify(key)}:${canonical(value[key] as Jsonish)}`
|
|
392
|
+
)).join(',')}}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function digest(domain: string, value: unknown): string {
|
|
396
|
+
return `sha256:${crypto.createHash('sha256')
|
|
397
|
+
.update(domain)
|
|
398
|
+
.update('\0')
|
|
399
|
+
.update(canonical(value as Jsonish))
|
|
400
|
+
.digest('hex')}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function frozenCopy<T>(value: T): Readonly<T> {
|
|
404
|
+
return deepFreeze(structuredClone(value));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function deepFreeze<T>(value: T): Readonly<T> {
|
|
408
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
409
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
410
|
+
Object.freeze(value);
|
|
411
|
+
}
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function identifier(value: unknown, field: string): asserts value is string {
|
|
416
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
|
417
|
+
fail('invalid_identifier', `${field} is invalid`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function sha256(value: unknown, field: string): asserts value is string {
|
|
422
|
+
if (typeof value !== 'string' || !SHA256.test(value)) {
|
|
423
|
+
fail('invalid_digest', `${field} must be a sha256 digest`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function instant(value: unknown, field: string): number {
|
|
428
|
+
if (typeof value !== 'string') fail('invalid_time', `${field} must be an RFC3339 instant`);
|
|
429
|
+
const parsed = Date.parse(value);
|
|
430
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) {
|
|
431
|
+
fail('invalid_time', `${field} must be a canonical millisecond UTC instant`);
|
|
432
|
+
}
|
|
433
|
+
return parsed;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function positiveBigInt(value: unknown, field: string): asserts value is bigint {
|
|
437
|
+
if (typeof value !== 'bigint' || value <= 0n || value > 9_223_372_036_854_775_807n) {
|
|
438
|
+
fail('invalid_amount', `${field} must be a positive signed 64-bit bigint`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function nonNegativeBigInt(value: unknown, field: string): asserts value is bigint {
|
|
443
|
+
if (typeof value !== 'bigint' || value < 0n || value > 9_223_372_036_854_775_807n) {
|
|
444
|
+
fail('invalid_amount', `${field} must be a non-negative signed 64-bit bigint`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateAuth(auth: OpenExposureAuth): void {
|
|
449
|
+
if (!auth || !['POLICY_ADMIN', 'ORIGIN', 'EXECUTOR', 'RECONCILER', 'READER'].includes(auth.role)) {
|
|
450
|
+
fail('invalid_auth', 'auth role is invalid');
|
|
451
|
+
}
|
|
452
|
+
identifier(auth.authorityId, 'auth.authorityId');
|
|
453
|
+
if (typeof auth.credential !== 'string' || auth.credential.length < 1 || auth.credential.length > 4096) {
|
|
454
|
+
fail('invalid_auth', 'auth credential is invalid');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function validateWindow(windowStart: string, windowEnd: string): void {
|
|
459
|
+
if (instant(windowStart, 'windowStart') >= instant(windowEnd, 'windowEnd')) {
|
|
460
|
+
fail('invalid_window', 'windowStart must precede windowEnd');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function validateCeiling(input: OpenExposureCeilingInput): void {
|
|
465
|
+
identifier(input.tenantId, 'tenantId');
|
|
466
|
+
identifier(input.ceilingId, 'ceilingId');
|
|
467
|
+
if (!SCOPES.includes(input.scope)) fail('invalid_scope', 'ceiling scope is invalid');
|
|
468
|
+
if (!(input.scope === 'TENANT' && input.scopeValue === '*')) {
|
|
469
|
+
identifier(input.scopeValue, 'scopeValue');
|
|
470
|
+
}
|
|
471
|
+
if (input.scope === 'TENANT' && input.scopeValue !== '*') {
|
|
472
|
+
fail('invalid_scope', 'TENANT scopeValue must be *');
|
|
473
|
+
}
|
|
474
|
+
if (input.scope !== 'TENANT' && input.scopeValue === '*') {
|
|
475
|
+
fail('invalid_scope', 'non-tenant scopeValue cannot be *');
|
|
476
|
+
}
|
|
477
|
+
if (!CURRENCY.test(input.currency)) fail('invalid_currency', 'currency must be ISO-like uppercase ASCII');
|
|
478
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
479
|
+
nonNegativeBigInt(input.limitMinor, 'limitMinor');
|
|
480
|
+
sha256(input.policyDigest, 'policyDigest');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function validateReservation(input: OpenExposureReserveInput): void {
|
|
484
|
+
for (const [field, value] of Object.entries({
|
|
485
|
+
tenantId: input.tenantId,
|
|
486
|
+
exposureId: input.exposureId,
|
|
487
|
+
programId: input.programId,
|
|
488
|
+
programVersion: input.programVersion,
|
|
489
|
+
counterpartyId: input.counterpartyId,
|
|
490
|
+
actionClass: input.actionClass,
|
|
491
|
+
originAuthorityId: input.originAuthorityId,
|
|
492
|
+
executorAuthorityId: input.executorAuthorityId,
|
|
493
|
+
reconciliationAuthorityId: input.reconciliationAuthorityId,
|
|
494
|
+
})) identifier(value, field);
|
|
495
|
+
if (typeof input.caid !== 'string' || !CAID.test(input.caid)) {
|
|
496
|
+
fail('invalid_caid', 'caid is invalid');
|
|
497
|
+
}
|
|
498
|
+
for (const [field, value] of Object.entries({
|
|
499
|
+
programSourceDigest: input.programSourceDigest,
|
|
500
|
+
programDigest: input.programDigest,
|
|
501
|
+
actionDigest: input.actionDigest,
|
|
502
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
503
|
+
authorizationDigest: input.authorizationDigest,
|
|
504
|
+
})) sha256(value, field);
|
|
505
|
+
if (!OPERATION_TOKEN.test(input.operationToken)) fail('invalid_operation_token', 'operationToken is invalid');
|
|
506
|
+
positiveBigInt(input.amountMinor, 'amountMinor');
|
|
507
|
+
if (!CURRENCY.test(input.currency)) fail('invalid_currency', 'currency must be ISO-like uppercase ASCII');
|
|
508
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
509
|
+
const reservedAt = instant(input.reservedAt, 'reservedAt');
|
|
510
|
+
const invokeBy = instant(input.invokeBy, 'invokeBy');
|
|
511
|
+
const reconcileBy = instant(input.reconcileBy, 'reconcileBy');
|
|
512
|
+
const authorizationExpiresAt = instant(
|
|
513
|
+
input.authorizationExpiresAt, 'authorizationExpiresAt',
|
|
514
|
+
);
|
|
515
|
+
if (reservedAt < instant(input.windowStart, 'windowStart')
|
|
516
|
+
|| reservedAt >= instant(input.windowEnd, 'windowEnd')) {
|
|
517
|
+
fail('invalid_time', 'reservedAt must be inside the ceiling window');
|
|
518
|
+
}
|
|
519
|
+
if (invokeBy < reservedAt || reconcileBy < invokeBy
|
|
520
|
+
|| invokeBy > instant(input.windowEnd, 'windowEnd')
|
|
521
|
+
|| invokeBy > authorizationExpiresAt
|
|
522
|
+
|| authorizationExpiresAt < reservedAt) {
|
|
523
|
+
fail('invalid_time', 'deadlines must be monotonic');
|
|
524
|
+
}
|
|
525
|
+
sha256(input.reservationEvidenceDigest, 'reservationEvidenceDigest');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function validateInvocationBinding(input: OpenExposureInvocationBinding): void {
|
|
529
|
+
identifier(input.programVersion, 'programVersion');
|
|
530
|
+
if (typeof input.caid !== 'string' || !CAID.test(input.caid)) {
|
|
531
|
+
fail('invalid_caid', 'caid is invalid');
|
|
532
|
+
}
|
|
533
|
+
for (const [field, value] of Object.entries({
|
|
534
|
+
programSourceDigest: input.programSourceDigest,
|
|
535
|
+
programDigest: input.programDigest,
|
|
536
|
+
actionDigest: input.actionDigest,
|
|
537
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
538
|
+
authorizationDigest: input.authorizationDigest,
|
|
539
|
+
})) sha256(value, field);
|
|
540
|
+
instant(input.authorizationExpiresAt, 'authorizationExpiresAt');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function invocationBindingMatches(
|
|
544
|
+
record: OpenExposureRecord,
|
|
545
|
+
input: OpenExposureInvocationBinding,
|
|
546
|
+
): boolean {
|
|
547
|
+
return record.programVersion === input.programVersion
|
|
548
|
+
&& record.programSourceDigest === input.programSourceDigest
|
|
549
|
+
&& record.programDigest === input.programDigest
|
|
550
|
+
&& record.caid === input.caid
|
|
551
|
+
&& record.actionDigest === input.actionDigest
|
|
552
|
+
&& record.admissionSnapshotDigest === input.admissionSnapshotDigest
|
|
553
|
+
&& record.authorizationDigest === input.authorizationDigest
|
|
554
|
+
&& record.authorizationExpiresAt === input.authorizationExpiresAt;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function validateReference(input: OpenExposureReference): void {
|
|
558
|
+
identifier(input.tenantId, 'tenantId');
|
|
559
|
+
identifier(input.exposureId, 'exposureId');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function tokenDigest(token: string): string {
|
|
563
|
+
return digest('EP-OPEN-EXPOSURE-TOKEN-v1', token);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function permitDigest(record: OpenExposureRecord, permit: string): string {
|
|
567
|
+
return digest('EP-OPEN-EXPOSURE-INVOCATION-PERMIT-v1', {
|
|
568
|
+
permit,
|
|
569
|
+
tenantId: record.tenantId,
|
|
570
|
+
exposureId: record.exposureId,
|
|
571
|
+
operationTokenDigest: record.operationTokenDigest,
|
|
572
|
+
reservationDigest: record.reservationDigest,
|
|
573
|
+
programVersion: record.programVersion,
|
|
574
|
+
programSourceDigest: record.programSourceDigest,
|
|
575
|
+
programDigest: record.programDigest,
|
|
576
|
+
caid: record.caid,
|
|
577
|
+
actionDigest: record.actionDigest,
|
|
578
|
+
admissionSnapshotDigest: record.admissionSnapshotDigest,
|
|
579
|
+
authorizationDigest: record.authorizationDigest,
|
|
580
|
+
authorizationExpiresAt: record.authorizationExpiresAt,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function key(...parts: string[]): string {
|
|
585
|
+
return parts.join('\u0000');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function ceilingScopeValue(input: OpenExposureReserveInput, scope: OpenExposureCeilingScope): string {
|
|
589
|
+
if (scope === 'TENANT') return '*';
|
|
590
|
+
if (scope === 'PROGRAM') return input.programId;
|
|
591
|
+
if (scope === 'COUNTERPARTY') return input.counterpartyId;
|
|
592
|
+
return input.actionClass;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function ceilingScopeKey(input: OpenExposureCeilingInput): string {
|
|
596
|
+
return key(
|
|
597
|
+
input.tenantId,
|
|
598
|
+
input.scope,
|
|
599
|
+
input.scopeValue,
|
|
600
|
+
input.currency,
|
|
601
|
+
input.windowStart,
|
|
602
|
+
input.windowEnd,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function recordBody(record: Omit<OpenExposureRecord, 'recordDigest'>): Omit<OpenExposureRecord, 'recordDigest'> {
|
|
607
|
+
return record;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function makeRecord(body: Omit<OpenExposureRecord, 'recordDigest'>): Readonly<OpenExposureRecord> {
|
|
611
|
+
return frozenCopy({
|
|
612
|
+
...body,
|
|
613
|
+
recordDigest: digest('EP-OPEN-EXPOSURE-RECORD-v1', recordBody(body)),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function isOpen(record: OpenExposureRecord): boolean {
|
|
618
|
+
return OPEN_STATUSES.has(record.status);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function compareText(left: string, right: string): number {
|
|
622
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function addBreakdown(
|
|
626
|
+
source: ReadonlyMap<string, bigint>,
|
|
627
|
+
): readonly Readonly<OpenExposureBreakdown>[] {
|
|
628
|
+
return frozenCopy([...source.entries()]
|
|
629
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
630
|
+
.map(([breakdownKey, amountMinor]) => ({ key: breakdownKey, amountMinor })));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function createMemoryOpenExposureLedger(
|
|
634
|
+
options: CreateMemoryOpenExposureLedgerOptions,
|
|
635
|
+
): OpenExposureLedger {
|
|
636
|
+
if (!options || typeof options.authenticate !== 'function') {
|
|
637
|
+
fail('authenticator_required', 'authenticate is required');
|
|
638
|
+
}
|
|
639
|
+
if (options.clock !== undefined && typeof options.clock !== 'function') {
|
|
640
|
+
fail('clock_invalid', 'clock must be a function');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const readClock = options.clock ?? (() => (
|
|
644
|
+
new Date(performance.timeOrigin + performance.now()).toISOString()
|
|
645
|
+
));
|
|
646
|
+
let lastClockMs: number | null = null;
|
|
647
|
+
|
|
648
|
+
function trustedNow(): string {
|
|
649
|
+
let value: unknown;
|
|
650
|
+
try {
|
|
651
|
+
value = readClock();
|
|
652
|
+
} catch (cause) {
|
|
653
|
+
throw new OpenExposureValidationError(
|
|
654
|
+
'clock_failed',
|
|
655
|
+
`trusted clock failed${cause instanceof Error ? `: ${cause.message}` : ''}`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const parsed = instant(value, 'clock');
|
|
659
|
+
if (lastClockMs !== null && parsed < lastClockMs) {
|
|
660
|
+
fail('clock_regressed', 'trusted clock regressed');
|
|
661
|
+
}
|
|
662
|
+
lastClockMs = parsed;
|
|
663
|
+
return value as string;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const ceilingsById = new Map<string, Readonly<OpenExposureCeiling>>();
|
|
667
|
+
const ceilingsByScope = new Map<string, Readonly<OpenExposureCeiling>>();
|
|
668
|
+
const records = new Map<string, Readonly<OpenExposureRecord>>();
|
|
669
|
+
const operations = new Map<string, {
|
|
670
|
+
reservationDigest: string;
|
|
671
|
+
record: Readonly<OpenExposureRecord>;
|
|
672
|
+
}>();
|
|
673
|
+
const histories = new Map<string, Readonly<OpenExposureHistoryEntry>[]>();
|
|
674
|
+
const reconciliationTokens = new Map<string, {
|
|
675
|
+
requestDigest: string;
|
|
676
|
+
record: Readonly<OpenExposureRecord>;
|
|
677
|
+
}>();
|
|
678
|
+
let mutexTail: Promise<void> = Promise.resolve();
|
|
679
|
+
|
|
680
|
+
async function exclusive<T>(operation: () => T | Promise<T>): Promise<T> {
|
|
681
|
+
let release!: () => void;
|
|
682
|
+
const mine = new Promise<void>((resolve) => { release = resolve; });
|
|
683
|
+
const previous = mutexTail;
|
|
684
|
+
mutexTail = mine;
|
|
685
|
+
await previous;
|
|
686
|
+
try {
|
|
687
|
+
return await operation();
|
|
688
|
+
} finally {
|
|
689
|
+
release();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function authenticated(
|
|
694
|
+
tenantId: string,
|
|
695
|
+
auth: OpenExposureAuth,
|
|
696
|
+
expectedRole?: OpenExposureRole,
|
|
697
|
+
expectedAuthority?: string,
|
|
698
|
+
): Promise<OpenExposureRefusal | null> {
|
|
699
|
+
validateAuth(auth);
|
|
700
|
+
let accepted = false;
|
|
701
|
+
try {
|
|
702
|
+
accepted = await options.authenticate(frozenCopy({ tenantId, auth }));
|
|
703
|
+
} catch {
|
|
704
|
+
accepted = false;
|
|
705
|
+
}
|
|
706
|
+
if (!accepted) return { ok: false, reason: 'unauthenticated' };
|
|
707
|
+
if ((expectedRole && auth.role !== expectedRole)
|
|
708
|
+
|| (expectedAuthority && auth.authorityId !== expectedAuthority)) {
|
|
709
|
+
return { ok: false, reason: 'wrong_authority' };
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function appendHistory(
|
|
715
|
+
record: Readonly<OpenExposureRecord>,
|
|
716
|
+
event: OpenExposureHistoryEvent,
|
|
717
|
+
evidenceDigest: string,
|
|
718
|
+
recordedAt: string,
|
|
719
|
+
): void {
|
|
720
|
+
const recordKey = key(record.tenantId, record.exposureId);
|
|
721
|
+
const existing = histories.get(recordKey) ?? [];
|
|
722
|
+
const predecessor = existing.at(-1)?.entryDigest ?? null;
|
|
723
|
+
const body = {
|
|
724
|
+
'@version': OPEN_EXPOSURE_HISTORY_VERSION,
|
|
725
|
+
tenantId: record.tenantId,
|
|
726
|
+
exposureId: record.exposureId,
|
|
727
|
+
sequence: existing.length,
|
|
728
|
+
event,
|
|
729
|
+
programVersion: record.programVersion,
|
|
730
|
+
programSourceDigest: record.programSourceDigest,
|
|
731
|
+
programDigest: record.programDigest,
|
|
732
|
+
caid: record.caid,
|
|
733
|
+
actionDigest: record.actionDigest,
|
|
734
|
+
admissionSnapshotDigest: record.admissionSnapshotDigest,
|
|
735
|
+
authorizationDigest: record.authorizationDigest,
|
|
736
|
+
authorizationExpiresAt: record.authorizationExpiresAt,
|
|
737
|
+
invocationPermitDigest: record.invocationPermitDigest,
|
|
738
|
+
recordDigest: record.recordDigest,
|
|
739
|
+
evidenceDigest,
|
|
740
|
+
recordedAt,
|
|
741
|
+
predecessorEntryDigest: predecessor,
|
|
742
|
+
} as const;
|
|
743
|
+
const entry = frozenCopy({
|
|
744
|
+
...body,
|
|
745
|
+
entryDigest: digest('EP-OPEN-EXPOSURE-HISTORY-ENTRY-v1', body),
|
|
746
|
+
});
|
|
747
|
+
histories.set(recordKey, [...existing, entry]);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function replaceRecord(
|
|
751
|
+
current: Readonly<OpenExposureRecord>,
|
|
752
|
+
update: Partial<Omit<OpenExposureRecord,
|
|
753
|
+
'@version' | 'tenantId' | 'exposureId' | 'operationTokenDigest'
|
|
754
|
+
| 'reservationDigest' | 'programId' | 'counterpartyId' | 'actionClass'
|
|
755
|
+
| 'programVersion' | 'programSourceDigest' | 'programDigest' | 'caid'
|
|
756
|
+
| 'actionDigest' | 'admissionSnapshotDigest' | 'authorizationDigest'
|
|
757
|
+
| 'authorizationExpiresAt'
|
|
758
|
+
| 'amountMinor' | 'currency' | 'windowStart' | 'windowEnd' | 'reservedAt'
|
|
759
|
+
| 'invokeBy' | 'reconcileBy' | 'originAuthorityId' | 'executorAuthorityId'
|
|
760
|
+
| 'reconciliationAuthorityId' | 'reservationEvidenceDigest'
|
|
761
|
+
| 'ceilingDigests' | 'recordDigest'>>,
|
|
762
|
+
): Readonly<OpenExposureRecord> {
|
|
763
|
+
const { recordDigest: predecessorRecordDigest, ...body } = current;
|
|
764
|
+
return makeRecord({
|
|
765
|
+
...body,
|
|
766
|
+
...update,
|
|
767
|
+
revision: current.revision + 1,
|
|
768
|
+
predecessorRecordDigest,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function recordFor(input: OpenExposureReference): Readonly<OpenExposureRecord> | null {
|
|
773
|
+
return records.get(key(input.tenantId, input.exposureId)) ?? null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function operationMatches(record: OpenExposureRecord, operationToken: string): boolean {
|
|
777
|
+
return record.operationTokenDigest === tokenDigest(operationToken);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
durable: false,
|
|
782
|
+
atomicReserve: true,
|
|
783
|
+
appendOnlyHistory: true,
|
|
784
|
+
blindRelease: false,
|
|
785
|
+
reconciliationOnlyCloseout: true,
|
|
786
|
+
testOnly: true,
|
|
787
|
+
|
|
788
|
+
async registerCeiling(input, auth) {
|
|
789
|
+
validateCeiling(input);
|
|
790
|
+
const denied = await authenticated(input.tenantId, auth, 'POLICY_ADMIN');
|
|
791
|
+
if (denied) return denied;
|
|
792
|
+
return exclusive(() => {
|
|
793
|
+
const idKey = key(input.tenantId, input.ceilingId);
|
|
794
|
+
const scopeKey = ceilingScopeKey(input);
|
|
795
|
+
const body = {
|
|
796
|
+
'@version': OPEN_EXPOSURE_LEDGER_VERSION,
|
|
797
|
+
...structuredClone(input),
|
|
798
|
+
} as const;
|
|
799
|
+
const candidate = frozenCopy({
|
|
800
|
+
...body,
|
|
801
|
+
ceilingDigest: digest('EP-OPEN-EXPOSURE-CEILING-v1', body),
|
|
802
|
+
});
|
|
803
|
+
const byId = ceilingsById.get(idKey);
|
|
804
|
+
if (byId) {
|
|
805
|
+
return byId.ceilingDigest === candidate.ceilingDigest
|
|
806
|
+
? { ok: true as const, ceiling: byId, replayed: true }
|
|
807
|
+
: { ok: false as const, reason: 'ceiling_id_conflict' as const };
|
|
808
|
+
}
|
|
809
|
+
const byScope = ceilingsByScope.get(scopeKey);
|
|
810
|
+
if (byScope) {
|
|
811
|
+
return byScope.ceilingDigest === candidate.ceilingDigest
|
|
812
|
+
? { ok: true as const, ceiling: byScope, replayed: true }
|
|
813
|
+
: { ok: false as const, reason: 'ceiling_scope_conflict' as const };
|
|
814
|
+
}
|
|
815
|
+
ceilingsById.set(idKey, candidate);
|
|
816
|
+
ceilingsByScope.set(scopeKey, candidate);
|
|
817
|
+
return { ok: true as const, ceiling: candidate, replayed: false };
|
|
818
|
+
});
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
async reserve(input, auth) {
|
|
822
|
+
validateReservation(input);
|
|
823
|
+
const denied = await authenticated(
|
|
824
|
+
input.tenantId, auth, 'ORIGIN', input.originAuthorityId,
|
|
825
|
+
);
|
|
826
|
+
if (denied) return denied;
|
|
827
|
+
if (new Set([
|
|
828
|
+
input.originAuthorityId,
|
|
829
|
+
input.executorAuthorityId,
|
|
830
|
+
input.reconciliationAuthorityId,
|
|
831
|
+
]).size !== 3) {
|
|
832
|
+
return { ok: false, reason: 'authority_separation_required' };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const operationTokenDigest = tokenDigest(input.operationToken);
|
|
836
|
+
const { operationToken: _operationToken, ...reservationInput } = structuredClone(input);
|
|
837
|
+
const reservationBody = { ...reservationInput, operationTokenDigest };
|
|
838
|
+
const reservationDigest = digest('EP-OPEN-EXPOSURE-RESERVATION-v1', reservationBody);
|
|
839
|
+
|
|
840
|
+
return exclusive(() => {
|
|
841
|
+
const operationKey = key(input.tenantId, operationTokenDigest);
|
|
842
|
+
const existingOperation = operations.get(operationKey);
|
|
843
|
+
if (existingOperation) {
|
|
844
|
+
return existingOperation.reservationDigest === reservationDigest
|
|
845
|
+
? { ok: true as const, record: existingOperation.record, replayed: true }
|
|
846
|
+
: { ok: false as const, reason: 'operation_token_conflict' as const };
|
|
847
|
+
}
|
|
848
|
+
const recordKey = key(input.tenantId, input.exposureId);
|
|
849
|
+
if (records.has(recordKey)) return { ok: false, reason: 'exposure_exists' } as const;
|
|
850
|
+
|
|
851
|
+
const applicable: Readonly<OpenExposureCeiling>[] = [];
|
|
852
|
+
for (const scope of SCOPES) {
|
|
853
|
+
const configured = ceilingsByScope.get(key(
|
|
854
|
+
input.tenantId,
|
|
855
|
+
scope,
|
|
856
|
+
ceilingScopeValue(input, scope),
|
|
857
|
+
input.currency,
|
|
858
|
+
input.windowStart,
|
|
859
|
+
input.windowEnd,
|
|
860
|
+
));
|
|
861
|
+
if (!configured) return { ok: false, reason: 'ceiling_not_configured' } as const;
|
|
862
|
+
applicable.push(configured);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const open = [...records.values()].filter((record) => (
|
|
866
|
+
record.tenantId === input.tenantId
|
|
867
|
+
&& record.currency === input.currency
|
|
868
|
+
&& record.windowStart === input.windowStart
|
|
869
|
+
&& record.windowEnd === input.windowEnd
|
|
870
|
+
&& isOpen(record)
|
|
871
|
+
));
|
|
872
|
+
for (const configured of applicable) {
|
|
873
|
+
const used = open
|
|
874
|
+
.filter((record) => {
|
|
875
|
+
if (configured.scope === 'TENANT') return true;
|
|
876
|
+
if (configured.scope === 'PROGRAM') return record.programId === configured.scopeValue;
|
|
877
|
+
if (configured.scope === 'COUNTERPARTY') return record.counterpartyId === configured.scopeValue;
|
|
878
|
+
return record.actionClass === configured.scopeValue;
|
|
879
|
+
})
|
|
880
|
+
.reduce((sum, record) => sum + record.amountMinor, 0n);
|
|
881
|
+
if (used > configured.limitMinor
|
|
882
|
+
|| input.amountMinor > configured.limitMinor - used) {
|
|
883
|
+
return { ok: false, reason: 'ceiling_exceeded' } as const;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const record = makeRecord({
|
|
888
|
+
'@version': OPEN_EXPOSURE_LEDGER_VERSION,
|
|
889
|
+
tenantId: input.tenantId,
|
|
890
|
+
exposureId: input.exposureId,
|
|
891
|
+
operationTokenDigest,
|
|
892
|
+
reservationDigest,
|
|
893
|
+
programId: input.programId,
|
|
894
|
+
programVersion: input.programVersion,
|
|
895
|
+
programSourceDigest: input.programSourceDigest,
|
|
896
|
+
programDigest: input.programDigest,
|
|
897
|
+
caid: input.caid,
|
|
898
|
+
actionDigest: input.actionDigest,
|
|
899
|
+
admissionSnapshotDigest: input.admissionSnapshotDigest,
|
|
900
|
+
authorizationDigest: input.authorizationDigest,
|
|
901
|
+
authorizationExpiresAt: input.authorizationExpiresAt,
|
|
902
|
+
counterpartyId: input.counterpartyId,
|
|
903
|
+
actionClass: input.actionClass,
|
|
904
|
+
amountMinor: input.amountMinor,
|
|
905
|
+
currency: input.currency,
|
|
906
|
+
windowStart: input.windowStart,
|
|
907
|
+
windowEnd: input.windowEnd,
|
|
908
|
+
reservedAt: input.reservedAt,
|
|
909
|
+
invokeBy: input.invokeBy,
|
|
910
|
+
reconcileBy: input.reconcileBy,
|
|
911
|
+
originAuthorityId: input.originAuthorityId,
|
|
912
|
+
executorAuthorityId: input.executorAuthorityId,
|
|
913
|
+
reconciliationAuthorityId: input.reconciliationAuthorityId,
|
|
914
|
+
reservationEvidenceDigest: input.reservationEvidenceDigest,
|
|
915
|
+
ceilingDigests: applicable.map((entry) => entry.ceilingDigest).sort(compareText),
|
|
916
|
+
revision: 0,
|
|
917
|
+
status: 'RESERVED',
|
|
918
|
+
invokedAt: null,
|
|
919
|
+
invocationPermitDigest: null,
|
|
920
|
+
indeterminateEvidenceDigest: null,
|
|
921
|
+
reconciliationOutcome: null,
|
|
922
|
+
reconciliationEvidenceDigest: null,
|
|
923
|
+
lastChangedAt: input.reservedAt,
|
|
924
|
+
predecessorRecordDigest: null,
|
|
925
|
+
});
|
|
926
|
+
records.set(recordKey, record);
|
|
927
|
+
operations.set(operationKey, { reservationDigest, record });
|
|
928
|
+
appendHistory(record, 'RESERVED', input.reservationEvidenceDigest, input.reservedAt);
|
|
929
|
+
return { ok: true, record, replayed: false } as const;
|
|
930
|
+
});
|
|
931
|
+
},
|
|
932
|
+
|
|
933
|
+
async beginInvocation(input, auth) {
|
|
934
|
+
validateReference(input);
|
|
935
|
+
if (!OPERATION_TOKEN.test(input.operationToken)) fail('invalid_operation_token', 'operationToken is invalid');
|
|
936
|
+
validateInvocationBinding(input);
|
|
937
|
+
const initial = recordFor(input);
|
|
938
|
+
if (!initial) {
|
|
939
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR');
|
|
940
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
941
|
+
}
|
|
942
|
+
const denied = await authenticated(
|
|
943
|
+
input.tenantId, auth, 'EXECUTOR', initial.executorAuthorityId,
|
|
944
|
+
);
|
|
945
|
+
if (denied) return denied;
|
|
946
|
+
return exclusive(() => {
|
|
947
|
+
const current = recordFor(input);
|
|
948
|
+
if (!current) return { ok: false, reason: 'exposure_not_found' } as const;
|
|
949
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
950
|
+
return { ok: false, reason: 'operation_token_conflict' } as const;
|
|
951
|
+
}
|
|
952
|
+
if (!invocationBindingMatches(current, input)) {
|
|
953
|
+
return { ok: false, reason: 'immutable_binding_conflict' } as const;
|
|
954
|
+
}
|
|
955
|
+
if (current.status === 'INVOKING' || current.status === 'INDETERMINATE') {
|
|
956
|
+
return { ok: false, reason: 'reconciliation_required' } as const;
|
|
957
|
+
}
|
|
958
|
+
if (!isOpen(current)) return { ok: false, reason: 'already_closed' } as const;
|
|
959
|
+
if (current.status !== 'RESERVED') return { ok: false, reason: 'state_conflict' } as const;
|
|
960
|
+
const invokedAt = trustedNow();
|
|
961
|
+
if (instant(invokedAt, 'clock') > instant(current.invokeBy, 'invokeBy')
|
|
962
|
+
|| instant(invokedAt, 'clock') > instant(
|
|
963
|
+
current.authorizationExpiresAt, 'authorizationExpiresAt',
|
|
964
|
+
)) {
|
|
965
|
+
return { ok: false, reason: 'invocation_expired' } as const;
|
|
966
|
+
}
|
|
967
|
+
if (instant(invokedAt, 'clock') < instant(current.reservedAt, 'reservedAt')) {
|
|
968
|
+
return { ok: false, reason: 'state_conflict' } as const;
|
|
969
|
+
}
|
|
970
|
+
const invocationPermit = `open-exposure-invoke:v1:${crypto.randomBytes(32).toString('hex')}`;
|
|
971
|
+
const next = replaceRecord(current, {
|
|
972
|
+
status: 'INVOKING',
|
|
973
|
+
invokedAt,
|
|
974
|
+
invocationPermitDigest: permitDigest(current, invocationPermit),
|
|
975
|
+
lastChangedAt: invokedAt,
|
|
976
|
+
});
|
|
977
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
978
|
+
appendHistory(next, 'INVOKING', current.reservationEvidenceDigest, invokedAt);
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
record: next,
|
|
982
|
+
replayed: false,
|
|
983
|
+
invocationPermit,
|
|
984
|
+
} as const;
|
|
985
|
+
});
|
|
986
|
+
},
|
|
987
|
+
|
|
988
|
+
async markIndeterminate(input, auth) {
|
|
989
|
+
validateReference(input);
|
|
990
|
+
if (!OPERATION_TOKEN.test(input.operationToken)) fail('invalid_operation_token', 'operationToken is invalid');
|
|
991
|
+
sha256(input.evidenceDigest, 'evidenceDigest');
|
|
992
|
+
instant(input.observedAt, 'observedAt');
|
|
993
|
+
const initial = recordFor(input);
|
|
994
|
+
if (!initial) {
|
|
995
|
+
const denied = await authenticated(input.tenantId, auth, 'EXECUTOR');
|
|
996
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
997
|
+
}
|
|
998
|
+
const denied = await authenticated(
|
|
999
|
+
input.tenantId, auth, 'EXECUTOR', initial.executorAuthorityId,
|
|
1000
|
+
);
|
|
1001
|
+
if (denied) return denied;
|
|
1002
|
+
return exclusive(() => {
|
|
1003
|
+
const current = recordFor(input);
|
|
1004
|
+
if (!current) return { ok: false, reason: 'exposure_not_found' } as const;
|
|
1005
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
1006
|
+
return { ok: false, reason: 'operation_token_conflict' } as const;
|
|
1007
|
+
}
|
|
1008
|
+
if (current.status === 'INDETERMINATE') {
|
|
1009
|
+
return current.indeterminateEvidenceDigest === input.evidenceDigest
|
|
1010
|
+
&& current.lastChangedAt === input.observedAt
|
|
1011
|
+
? { ok: true as const, record: current, replayed: true }
|
|
1012
|
+
: { ok: false as const, reason: 'state_conflict' as const };
|
|
1013
|
+
}
|
|
1014
|
+
if (!isOpen(current)) return { ok: false, reason: 'already_closed' } as const;
|
|
1015
|
+
if (current.status !== 'INVOKING') return { ok: false, reason: 'state_conflict' } as const;
|
|
1016
|
+
if (current.invokedAt && instant(input.observedAt, 'observedAt') < instant(current.invokedAt, 'invokedAt')) {
|
|
1017
|
+
return { ok: false, reason: 'state_conflict' } as const;
|
|
1018
|
+
}
|
|
1019
|
+
const next = replaceRecord(current, {
|
|
1020
|
+
status: 'INDETERMINATE',
|
|
1021
|
+
indeterminateEvidenceDigest: input.evidenceDigest,
|
|
1022
|
+
lastChangedAt: input.observedAt,
|
|
1023
|
+
});
|
|
1024
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
1025
|
+
appendHistory(next, 'INDETERMINATE', input.evidenceDigest, input.observedAt);
|
|
1026
|
+
return { ok: true, record: next, replayed: false } as const;
|
|
1027
|
+
});
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
async reconcile(input, auth) {
|
|
1031
|
+
validateReference(input);
|
|
1032
|
+
if (!OPERATION_TOKEN.test(input.operationToken)) fail('invalid_operation_token', 'operationToken is invalid');
|
|
1033
|
+
if (!RECONCILIATION_TOKEN.test(input.reconciliationToken)) {
|
|
1034
|
+
fail('invalid_reconciliation_token', 'reconciliationToken is invalid');
|
|
1035
|
+
}
|
|
1036
|
+
if (!['COMMITTED', 'PROVEN_NOT_COMMITTED', 'INDETERMINATE'].includes(input.outcome)) {
|
|
1037
|
+
fail('invalid_outcome', 'reconciliation outcome is invalid');
|
|
1038
|
+
}
|
|
1039
|
+
sha256(input.evidenceDigest, 'evidenceDigest');
|
|
1040
|
+
instant(input.observedAt, 'observedAt');
|
|
1041
|
+
const initial = recordFor(input);
|
|
1042
|
+
if (!initial) {
|
|
1043
|
+
const denied = await authenticated(input.tenantId, auth, 'RECONCILER');
|
|
1044
|
+
return denied ?? { ok: false, reason: 'exposure_not_found' };
|
|
1045
|
+
}
|
|
1046
|
+
const denied = await authenticated(
|
|
1047
|
+
input.tenantId, auth, 'RECONCILER', initial.reconciliationAuthorityId,
|
|
1048
|
+
);
|
|
1049
|
+
if (denied) return denied;
|
|
1050
|
+
const reconciliationTokenDigest = tokenDigest(input.reconciliationToken);
|
|
1051
|
+
const {
|
|
1052
|
+
operationToken: _operationToken,
|
|
1053
|
+
reconciliationToken: _reconciliationToken,
|
|
1054
|
+
...reconciliationInput
|
|
1055
|
+
} = structuredClone(input);
|
|
1056
|
+
const requestDigest = digest('EP-OPEN-EXPOSURE-RECONCILIATION-v1', {
|
|
1057
|
+
...reconciliationInput,
|
|
1058
|
+
operationTokenDigest: tokenDigest(input.operationToken),
|
|
1059
|
+
reconciliationTokenDigest,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
return exclusive(() => {
|
|
1063
|
+
const reconciliationKey = key(input.tenantId, reconciliationTokenDigest);
|
|
1064
|
+
const prior = reconciliationTokens.get(reconciliationKey);
|
|
1065
|
+
if (prior) {
|
|
1066
|
+
return prior.requestDigest === requestDigest
|
|
1067
|
+
? { ok: true as const, record: prior.record, replayed: true }
|
|
1068
|
+
: { ok: false as const, reason: 'reconciliation_token_conflict' as const };
|
|
1069
|
+
}
|
|
1070
|
+
const current = recordFor(input);
|
|
1071
|
+
if (!current) return { ok: false, reason: 'exposure_not_found' } as const;
|
|
1072
|
+
if (!operationMatches(current, input.operationToken)) {
|
|
1073
|
+
return { ok: false, reason: 'operation_token_conflict' } as const;
|
|
1074
|
+
}
|
|
1075
|
+
if (!isOpen(current)) return { ok: false, reason: 'already_closed' } as const;
|
|
1076
|
+
if (instant(input.observedAt, 'observedAt') < instant(current.lastChangedAt, 'lastChangedAt')) {
|
|
1077
|
+
return { ok: false, reason: 'state_conflict' } as const;
|
|
1078
|
+
}
|
|
1079
|
+
if (input.outcome === 'COMMITTED' && current.status === 'RESERVED') {
|
|
1080
|
+
return { ok: false, reason: 'state_conflict' } as const;
|
|
1081
|
+
}
|
|
1082
|
+
if (input.outcome === 'INDETERMINATE' && current.status === 'RESERVED') {
|
|
1083
|
+
return { ok: false, reason: 'state_conflict' } as const;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const status: OpenExposureStatus = input.outcome === 'COMMITTED'
|
|
1087
|
+
? 'CLOSED_COMMITTED'
|
|
1088
|
+
: input.outcome === 'PROVEN_NOT_COMMITTED'
|
|
1089
|
+
? 'CLOSED_PROVEN_NOT_COMMITTED'
|
|
1090
|
+
: 'INDETERMINATE';
|
|
1091
|
+
const next = replaceRecord(current, {
|
|
1092
|
+
status,
|
|
1093
|
+
indeterminateEvidenceDigest: input.outcome === 'INDETERMINATE'
|
|
1094
|
+
? input.evidenceDigest
|
|
1095
|
+
: current.indeterminateEvidenceDigest,
|
|
1096
|
+
reconciliationOutcome: input.outcome,
|
|
1097
|
+
reconciliationEvidenceDigest: input.evidenceDigest,
|
|
1098
|
+
lastChangedAt: input.observedAt,
|
|
1099
|
+
});
|
|
1100
|
+
records.set(key(input.tenantId, input.exposureId), next);
|
|
1101
|
+
const event: OpenExposureHistoryEvent = input.outcome === 'COMMITTED'
|
|
1102
|
+
? 'CLOSED_COMMITTED'
|
|
1103
|
+
: input.outcome === 'PROVEN_NOT_COMMITTED'
|
|
1104
|
+
? 'CLOSED_PROVEN_NOT_COMMITTED'
|
|
1105
|
+
: 'RECONCILED_INDETERMINATE';
|
|
1106
|
+
appendHistory(next, event, input.evidenceDigest, input.observedAt);
|
|
1107
|
+
reconciliationTokens.set(reconciliationKey, { requestDigest, record: next });
|
|
1108
|
+
return { ok: true, record: next, replayed: false } as const;
|
|
1109
|
+
});
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
async read(input, auth) {
|
|
1113
|
+
validateReference(input);
|
|
1114
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
1115
|
+
if (denied) return denied;
|
|
1116
|
+
return { ok: true, record: recordFor(input) };
|
|
1117
|
+
},
|
|
1118
|
+
|
|
1119
|
+
async history(input, auth) {
|
|
1120
|
+
validateReference(input);
|
|
1121
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
1122
|
+
if (denied) return denied;
|
|
1123
|
+
return {
|
|
1124
|
+
ok: true,
|
|
1125
|
+
entries: frozenCopy(histories.get(key(input.tenantId, input.exposureId)) ?? []),
|
|
1126
|
+
};
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
async sumOpen(input, auth) {
|
|
1130
|
+
identifier(input.tenantId, 'tenantId');
|
|
1131
|
+
if (!CURRENCY.test(input.currency)) fail('invalid_currency', 'currency is invalid');
|
|
1132
|
+
validateWindow(input.windowStart, input.windowEnd);
|
|
1133
|
+
if (input.programId !== undefined) identifier(input.programId, 'programId');
|
|
1134
|
+
if (input.counterpartyId !== undefined) identifier(input.counterpartyId, 'counterpartyId');
|
|
1135
|
+
if (input.actionClass !== undefined) identifier(input.actionClass, 'actionClass');
|
|
1136
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
1137
|
+
if (denied) return denied;
|
|
1138
|
+
|
|
1139
|
+
const selected = [...records.values()].filter((record) => (
|
|
1140
|
+
record.tenantId === input.tenantId
|
|
1141
|
+
&& record.currency === input.currency
|
|
1142
|
+
&& record.windowStart === input.windowStart
|
|
1143
|
+
&& record.windowEnd === input.windowEnd
|
|
1144
|
+
&& (input.programId === undefined || record.programId === input.programId)
|
|
1145
|
+
&& (input.counterpartyId === undefined || record.counterpartyId === input.counterpartyId)
|
|
1146
|
+
&& (input.actionClass === undefined || record.actionClass === input.actionClass)
|
|
1147
|
+
&& isOpen(record)
|
|
1148
|
+
));
|
|
1149
|
+
const maps = {
|
|
1150
|
+
program: new Map<string, bigint>(),
|
|
1151
|
+
counterparty: new Map<string, bigint>(),
|
|
1152
|
+
actionClass: new Map<string, bigint>(),
|
|
1153
|
+
status: new Map<string, bigint>(),
|
|
1154
|
+
};
|
|
1155
|
+
for (const record of selected) {
|
|
1156
|
+
maps.program.set(record.programId, (maps.program.get(record.programId) ?? 0n) + record.amountMinor);
|
|
1157
|
+
maps.counterparty.set(record.counterpartyId, (maps.counterparty.get(record.counterpartyId) ?? 0n) + record.amountMinor);
|
|
1158
|
+
maps.actionClass.set(record.actionClass, (maps.actionClass.get(record.actionClass) ?? 0n) + record.amountMinor);
|
|
1159
|
+
maps.status.set(record.status, (maps.status.get(record.status) ?? 0n) + record.amountMinor);
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
ok: true,
|
|
1163
|
+
totalMinor: selected.reduce((sum, record) => sum + record.amountMinor, 0n),
|
|
1164
|
+
byProgram: addBreakdown(maps.program),
|
|
1165
|
+
byCounterparty: addBreakdown(maps.counterparty),
|
|
1166
|
+
byActionClass: addBreakdown(maps.actionClass),
|
|
1167
|
+
byStatus: addBreakdown(maps.status),
|
|
1168
|
+
};
|
|
1169
|
+
},
|
|
1170
|
+
|
|
1171
|
+
async listAging(input, auth) {
|
|
1172
|
+
identifier(input.tenantId, 'tenantId');
|
|
1173
|
+
const asOf = instant(input.asOf, 'asOf');
|
|
1174
|
+
if (!Number.isSafeInteger(input.minimumAgeMs) || input.minimumAgeMs < 0) {
|
|
1175
|
+
fail('invalid_age', 'minimumAgeMs must be a non-negative safe integer');
|
|
1176
|
+
}
|
|
1177
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 10_000) {
|
|
1178
|
+
fail('invalid_limit', 'limit is invalid');
|
|
1179
|
+
}
|
|
1180
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
1181
|
+
if (denied) return denied;
|
|
1182
|
+
const selected = [...records.values()]
|
|
1183
|
+
.filter((record) => record.tenantId === input.tenantId
|
|
1184
|
+
&& isOpen(record)
|
|
1185
|
+
&& asOf - instant(record.reservedAt, 'reservedAt') >= input.minimumAgeMs)
|
|
1186
|
+
.sort((left, right) => compareText(left.reservedAt, right.reservedAt)
|
|
1187
|
+
|| compareText(left.exposureId, right.exposureId))
|
|
1188
|
+
.slice(0, input.limit);
|
|
1189
|
+
return { ok: true, records: frozenCopy(selected) };
|
|
1190
|
+
},
|
|
1191
|
+
|
|
1192
|
+
async listDeadlines(input, auth) {
|
|
1193
|
+
identifier(input.tenantId, 'tenantId');
|
|
1194
|
+
const dueAtOrBefore = instant(input.dueAtOrBefore, 'dueAtOrBefore');
|
|
1195
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 10_000) {
|
|
1196
|
+
fail('invalid_limit', 'limit is invalid');
|
|
1197
|
+
}
|
|
1198
|
+
const denied = await authenticated(input.tenantId, auth, 'READER');
|
|
1199
|
+
if (denied) return denied;
|
|
1200
|
+
const deadline = (record: OpenExposureRecord): string => (
|
|
1201
|
+
record.status === 'RESERVED' ? record.invokeBy : record.reconcileBy
|
|
1202
|
+
);
|
|
1203
|
+
const selected = [...records.values()]
|
|
1204
|
+
.filter((record) => record.tenantId === input.tenantId
|
|
1205
|
+
&& isOpen(record)
|
|
1206
|
+
&& instant(deadline(record), 'deadline') <= dueAtOrBefore)
|
|
1207
|
+
.sort((left, right) => compareText(deadline(left), deadline(right))
|
|
1208
|
+
|| compareText(left.exposureId, right.exposureId))
|
|
1209
|
+
.slice(0, input.limit);
|
|
1210
|
+
return { ok: true, records: frozenCopy(selected) };
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
}
|