@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,398 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Signed external loss-experience observations with governed action classes.
|
|
4
|
+
*
|
|
5
|
+
* The feed records what its issuer reports. It deliberately does not establish
|
|
6
|
+
* causation, insurance coverage, legal liability, adjudicated loss, solvency,
|
|
7
|
+
* payment, or authorization.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
RISK_DIGEST,
|
|
11
|
+
riskDigest,
|
|
12
|
+
riskExact,
|
|
13
|
+
riskIdentifier,
|
|
14
|
+
riskInstant,
|
|
15
|
+
riskRecord,
|
|
16
|
+
signRiskBody,
|
|
17
|
+
verifyRiskBody,
|
|
18
|
+
type RiskRecord,
|
|
19
|
+
type TrustedRiskKeys,
|
|
20
|
+
} from './reliance-risk-crypto.js';
|
|
21
|
+
|
|
22
|
+
export const LOSS_EXPERIENCE_FEED_VERSION = 'EP-LOSS-EXPERIENCE-FEED-v1';
|
|
23
|
+
export const LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY =
|
|
24
|
+
'externally_reported_observation_not_verified_causation_not_insurance_coverage_not_legal_liability_not_adjudicated_loss_not_solvency_not_payment_not_authorization';
|
|
25
|
+
|
|
26
|
+
const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
|
|
27
|
+
const PERIOD_KEYS = ['start', 'end'] as const;
|
|
28
|
+
const RECORD_KEYS = [
|
|
29
|
+
'record_id',
|
|
30
|
+
'lineage_digest',
|
|
31
|
+
'receipt_digest',
|
|
32
|
+
'action_class',
|
|
33
|
+
'classification',
|
|
34
|
+
'reported_amount_minor',
|
|
35
|
+
'currency',
|
|
36
|
+
'occurred_at',
|
|
37
|
+
'reported_at',
|
|
38
|
+
'source_record_digest',
|
|
39
|
+
'event_type',
|
|
40
|
+
'supersedes_record_digest',
|
|
41
|
+
] as const;
|
|
42
|
+
const ANCHOR_KEYS = ['method', 'evidence_digest'] as const;
|
|
43
|
+
const BODY_KEYS = [
|
|
44
|
+
'@version',
|
|
45
|
+
'feed_id',
|
|
46
|
+
'reporting_party_id',
|
|
47
|
+
'relying_party_id',
|
|
48
|
+
'program',
|
|
49
|
+
'period',
|
|
50
|
+
'census_digest',
|
|
51
|
+
'taxonomy_digest',
|
|
52
|
+
'source_inventory_digest',
|
|
53
|
+
'records',
|
|
54
|
+
'issued_at',
|
|
55
|
+
'expires_at',
|
|
56
|
+
'timestamp_anchor',
|
|
57
|
+
'claim_boundary',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
const CLASSIFICATIONS = new Set([
|
|
61
|
+
'NO_REPORTED_LOSS',
|
|
62
|
+
'LOSS_REPORTED',
|
|
63
|
+
'NEAR_MISS',
|
|
64
|
+
'DISPUTED',
|
|
65
|
+
'UNKNOWN',
|
|
66
|
+
]);
|
|
67
|
+
const EVENT_TYPES = new Set(['OBSERVED', 'CORRECTED', 'WITHDRAWN']);
|
|
68
|
+
|
|
69
|
+
function money(value: unknown): value is string {
|
|
70
|
+
return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,39})$/.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateRecord(value: unknown, periodStart: number, periodEnd: number, issuedAt: number): asserts value is RiskRecord {
|
|
74
|
+
if (!riskExact(value, RECORD_KEYS)
|
|
75
|
+
|| !riskIdentifier(value.record_id)
|
|
76
|
+
|| typeof value.lineage_digest !== 'string'
|
|
77
|
+
|| !RISK_DIGEST.test(value.lineage_digest)
|
|
78
|
+
|| typeof value.receipt_digest !== 'string'
|
|
79
|
+
|| !RISK_DIGEST.test(value.receipt_digest)
|
|
80
|
+
|| !riskIdentifier(value.action_class)
|
|
81
|
+
|| typeof value.classification !== 'string'
|
|
82
|
+
|| !CLASSIFICATIONS.has(value.classification)
|
|
83
|
+
|| !money(value.reported_amount_minor)
|
|
84
|
+
|| typeof value.currency !== 'string'
|
|
85
|
+
|| !/^[A-Z]{3}$/.test(value.currency)
|
|
86
|
+
|| typeof value.source_record_digest !== 'string'
|
|
87
|
+
|| !RISK_DIGEST.test(value.source_record_digest)
|
|
88
|
+
|| typeof value.event_type !== 'string'
|
|
89
|
+
|| !EVENT_TYPES.has(value.event_type)) {
|
|
90
|
+
throw new TypeError('loss experience record shape or field is invalid');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const occurredAt = riskInstant(value.occurred_at);
|
|
94
|
+
const reportedAt = riskInstant(value.reported_at);
|
|
95
|
+
if (!Number.isFinite(occurredAt) || occurredAt < periodStart || occurredAt >= periodEnd
|
|
96
|
+
|| !Number.isFinite(reportedAt) || reportedAt < occurredAt || reportedAt > issuedAt) {
|
|
97
|
+
throw new TypeError('loss experience occurred/reported time is invalid for the issued period');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (value.event_type === 'OBSERVED') {
|
|
101
|
+
if (value.supersedes_record_digest !== null) {
|
|
102
|
+
throw new TypeError('an observed loss record cannot supersede another record');
|
|
103
|
+
}
|
|
104
|
+
} else if (typeof value.supersedes_record_digest !== 'string'
|
|
105
|
+
|| !RISK_DIGEST.test(value.supersedes_record_digest)) {
|
|
106
|
+
throw new TypeError('a corrected or withdrawn record requires a supersedes record digest');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateBody(value: unknown): asserts value is RiskRecord {
|
|
111
|
+
if (!riskExact(value, BODY_KEYS)
|
|
112
|
+
|| value['@version'] !== LOSS_EXPERIENCE_FEED_VERSION
|
|
113
|
+
|| !riskIdentifier(value.feed_id)
|
|
114
|
+
|| !riskIdentifier(value.reporting_party_id)
|
|
115
|
+
|| !riskIdentifier(value.relying_party_id)
|
|
116
|
+
|| !riskExact(value.program, PROGRAM_KEYS)
|
|
117
|
+
|| !riskIdentifier(value.program.program_id)
|
|
118
|
+
|| !Number.isSafeInteger(value.program.version)
|
|
119
|
+
|| value.program.version < 1
|
|
120
|
+
|| typeof value.program.source_digest !== 'string'
|
|
121
|
+
|| !RISK_DIGEST.test(value.program.source_digest)
|
|
122
|
+
|| typeof value.program.program_digest !== 'string'
|
|
123
|
+
|| !RISK_DIGEST.test(value.program.program_digest)
|
|
124
|
+
|| !riskExact(value.period, PERIOD_KEYS)
|
|
125
|
+
|| typeof value.census_digest !== 'string'
|
|
126
|
+
|| !RISK_DIGEST.test(value.census_digest)
|
|
127
|
+
|| typeof value.taxonomy_digest !== 'string'
|
|
128
|
+
|| !RISK_DIGEST.test(value.taxonomy_digest)
|
|
129
|
+
|| typeof value.source_inventory_digest !== 'string'
|
|
130
|
+
|| !RISK_DIGEST.test(value.source_inventory_digest)
|
|
131
|
+
|| !Array.isArray(value.records)
|
|
132
|
+
|| value.records.length > 10_000
|
|
133
|
+
|| value.claim_boundary !== LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY) {
|
|
134
|
+
throw new TypeError('loss experience feed shape or field is invalid');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const periodStart = riskInstant(value.period.start);
|
|
138
|
+
const periodEnd = riskInstant(value.period.end);
|
|
139
|
+
const issuedAt = riskInstant(value.issued_at);
|
|
140
|
+
const expiresAt = riskInstant(value.expires_at);
|
|
141
|
+
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd <= periodStart
|
|
142
|
+
|| !Number.isFinite(issuedAt) || issuedAt < periodEnd
|
|
143
|
+
|| !Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
|
|
144
|
+
throw new TypeError('loss experience feed period or validity is invalid');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const record of value.records) validateRecord(record, periodStart, periodEnd, issuedAt);
|
|
148
|
+
const recordIds = value.records.map((record: RiskRecord) => record.record_id);
|
|
149
|
+
if (new Set(recordIds).size !== recordIds.length) {
|
|
150
|
+
throw new TypeError('loss experience feed contains duplicate record identifiers');
|
|
151
|
+
}
|
|
152
|
+
const superseded = value.records
|
|
153
|
+
.filter((record: RiskRecord) => record.supersedes_record_digest !== null)
|
|
154
|
+
.map((record: RiskRecord) => record.supersedes_record_digest);
|
|
155
|
+
if (new Set(superseded).size !== superseded.length) {
|
|
156
|
+
throw new TypeError('loss experience feed forks one predecessor into multiple current records');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (value.timestamp_anchor !== null
|
|
160
|
+
&& (!riskExact(value.timestamp_anchor, ANCHOR_KEYS)
|
|
161
|
+
|| !riskIdentifier(value.timestamp_anchor.method)
|
|
162
|
+
|| typeof value.timestamp_anchor.evidence_digest !== 'string'
|
|
163
|
+
|| !RISK_DIGEST.test(value.timestamp_anchor.evidence_digest))) {
|
|
164
|
+
throw new TypeError('loss experience timestamp anchor is invalid');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface VerifyLossExperienceFeedOptions {
|
|
169
|
+
trusted_keys?: TrustedRiskKeys;
|
|
170
|
+
now?: string | number;
|
|
171
|
+
expected_program?: RiskRecord;
|
|
172
|
+
expected_census_digest?: string;
|
|
173
|
+
expected_taxonomy_digest?: string;
|
|
174
|
+
expected_relying_party_id?: string;
|
|
175
|
+
expected_action_classes?: readonly string[];
|
|
176
|
+
commit_lineage_batch?: (
|
|
177
|
+
request: Readonly<LossExperienceLineageCommitRequest>,
|
|
178
|
+
) => LossExperienceLineageCommitResult;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface LossExperiencePredecessorResolution {
|
|
182
|
+
current_head: boolean;
|
|
183
|
+
reporting_party_id: string;
|
|
184
|
+
relying_party_id: string;
|
|
185
|
+
program_digest: string;
|
|
186
|
+
record: RiskRecord;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface LossExperienceLineageTransition {
|
|
190
|
+
lineage_digest: string;
|
|
191
|
+
event_type: 'OBSERVED' | 'CORRECTED' | 'WITHDRAWN';
|
|
192
|
+
predecessor_digest: string | null;
|
|
193
|
+
successor_digest: string;
|
|
194
|
+
reporting_party_id: string;
|
|
195
|
+
relying_party_id: string;
|
|
196
|
+
program_digest: string;
|
|
197
|
+
receipt_digest: string;
|
|
198
|
+
action_class: string;
|
|
199
|
+
currency: string;
|
|
200
|
+
reported_at: string;
|
|
201
|
+
record: Readonly<RiskRecord>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface LossExperienceLineageCommitRequest {
|
|
205
|
+
feed_digest: string;
|
|
206
|
+
transitions: readonly LossExperienceLineageTransition[];
|
|
207
|
+
/**
|
|
208
|
+
* The lineage store MUST invoke this callback inside the same transaction
|
|
209
|
+
* after locking/loading the current heads and before making successors
|
|
210
|
+
* visible. A refusal MUST leave every lineage unchanged.
|
|
211
|
+
*/
|
|
212
|
+
validate_predecessors: (
|
|
213
|
+
predecessors: Readonly<Record<string, LossExperiencePredecessorResolution>>,
|
|
214
|
+
) => LossExperienceLineageValidationResult;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type LossExperienceLineageValidationResult =
|
|
218
|
+
| { accepted: true }
|
|
219
|
+
| { accepted: false; reason: string };
|
|
220
|
+
|
|
221
|
+
export type LossExperienceLineageCommitResult =
|
|
222
|
+
| { accepted: true }
|
|
223
|
+
| { accepted: false; reason: string };
|
|
224
|
+
|
|
225
|
+
function validExpectedProgram(value: unknown): value is RiskRecord {
|
|
226
|
+
return riskExact(value, PROGRAM_KEYS) && riskIdentifier(value.program_id)
|
|
227
|
+
&& Number.isSafeInteger(value.version) && value.version >= 1
|
|
228
|
+
&& RISK_DIGEST.test(value.source_digest) && RISK_DIGEST.test(value.program_digest);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sameProgram(actual: RiskRecord, expected: RiskRecord): boolean {
|
|
232
|
+
return actual.program_id === expected.program_id && actual.version === expected.version
|
|
233
|
+
&& actual.source_digest === expected.source_digest && actual.program_digest === expected.program_digest;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateLineage(
|
|
237
|
+
payload: RiskRecord,
|
|
238
|
+
artifactDigest: string,
|
|
239
|
+
options: VerifyLossExperienceFeedOptions,
|
|
240
|
+
): string | null {
|
|
241
|
+
if (payload.records.length === 0) return null;
|
|
242
|
+
if (options.commit_lineage_batch === undefined) return 'lineage_store_required';
|
|
243
|
+
const transitions: LossExperienceLineageTransition[] = payload.records.map((record: RiskRecord) => ({
|
|
244
|
+
lineage_digest: record.lineage_digest,
|
|
245
|
+
event_type: record.event_type,
|
|
246
|
+
predecessor_digest: record.supersedes_record_digest,
|
|
247
|
+
successor_digest: riskDigest(record),
|
|
248
|
+
reporting_party_id: payload.reporting_party_id,
|
|
249
|
+
relying_party_id: payload.relying_party_id,
|
|
250
|
+
program_digest: payload.program.program_digest,
|
|
251
|
+
receipt_digest: record.receipt_digest,
|
|
252
|
+
action_class: record.action_class,
|
|
253
|
+
currency: record.currency,
|
|
254
|
+
reported_at: record.reported_at,
|
|
255
|
+
record: Object.freeze(structuredClone(record)),
|
|
256
|
+
}));
|
|
257
|
+
if (new Set(transitions.map((entry) => entry.lineage_digest)).size !== transitions.length) {
|
|
258
|
+
return 'lineage_batch_conflict';
|
|
259
|
+
}
|
|
260
|
+
const validatePredecessors = (
|
|
261
|
+
predecessors: Readonly<Record<string, LossExperiencePredecessorResolution>>,
|
|
262
|
+
): LossExperienceLineageValidationResult => {
|
|
263
|
+
if (!riskRecord(predecessors)) return { accepted: false, reason: 'lineage_predecessors_invalid' };
|
|
264
|
+
for (const record of payload.records.filter((entry: RiskRecord) => entry.event_type !== 'OBSERVED')) {
|
|
265
|
+
const resolved = predecessors[record.supersedes_record_digest] as LossExperiencePredecessorResolution | undefined;
|
|
266
|
+
if (!resolved) return { accepted: false, reason: 'lineage_predecessor_missing' };
|
|
267
|
+
if (resolved.current_head !== true) return { accepted: false, reason: 'lineage_head_invalid' };
|
|
268
|
+
if (resolved.reporting_party_id !== payload.reporting_party_id
|
|
269
|
+
|| resolved.relying_party_id !== payload.relying_party_id
|
|
270
|
+
|| resolved.program_digest !== payload.program.program_digest) {
|
|
271
|
+
return { accepted: false, reason: 'lineage_context_mismatch' };
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
validateRecord(resolved.record, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY,
|
|
275
|
+
riskInstant(payload.issued_at));
|
|
276
|
+
} catch {
|
|
277
|
+
return { accepted: false, reason: 'lineage_predecessor_invalid' };
|
|
278
|
+
}
|
|
279
|
+
if (riskDigest(resolved.record) !== record.supersedes_record_digest) {
|
|
280
|
+
return { accepted: false, reason: 'lineage_predecessor_digest_mismatch' };
|
|
281
|
+
}
|
|
282
|
+
if (resolved.record.event_type === 'WITHDRAWN') {
|
|
283
|
+
return { accepted: false, reason: 'lineage_terminal_predecessor' };
|
|
284
|
+
}
|
|
285
|
+
if (resolved.record.lineage_digest !== record.lineage_digest
|
|
286
|
+
|| resolved.record.receipt_digest !== record.receipt_digest
|
|
287
|
+
|| resolved.record.action_class !== record.action_class
|
|
288
|
+
|| resolved.record.currency !== record.currency) {
|
|
289
|
+
return { accepted: false, reason: 'lineage_record_mismatch' };
|
|
290
|
+
}
|
|
291
|
+
if (riskInstant(record.reported_at) <= riskInstant(resolved.record.reported_at)
|
|
292
|
+
|| record.source_record_digest === resolved.record.source_record_digest
|
|
293
|
+
|| riskDigest(record) === record.supersedes_record_digest) {
|
|
294
|
+
return { accepted: false, reason: 'lineage_order_invalid' };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { accepted: true };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let committed: LossExperienceLineageCommitResult;
|
|
301
|
+
try {
|
|
302
|
+
committed = options.commit_lineage_batch(Object.freeze({
|
|
303
|
+
feed_digest: artifactDigest,
|
|
304
|
+
transitions: Object.freeze(transitions.map((entry) => Object.freeze(entry))),
|
|
305
|
+
validate_predecessors: validatePredecessors,
|
|
306
|
+
}));
|
|
307
|
+
} catch {
|
|
308
|
+
return 'lineage_commit_failed';
|
|
309
|
+
}
|
|
310
|
+
if (!riskRecord(committed) || committed.accepted !== true) {
|
|
311
|
+
return riskRecord(committed) && typeof committed.reason === 'string'
|
|
312
|
+
? committed.reason
|
|
313
|
+
: 'lineage_commit_refused';
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function signLossExperienceFeed(
|
|
319
|
+
input: RiskRecord,
|
|
320
|
+
signer: { issuer_id: string; key_id: string; private_key: any },
|
|
321
|
+
) {
|
|
322
|
+
const body: RiskRecord = { '@version': LOSS_EXPERIENCE_FEED_VERSION, ...input };
|
|
323
|
+
validateBody(body);
|
|
324
|
+
if (signer.issuer_id !== body.reporting_party_id) {
|
|
325
|
+
throw new TypeError('loss experience feed issuer must be the reporting party');
|
|
326
|
+
}
|
|
327
|
+
return signRiskBody(LOSS_EXPERIENCE_FEED_VERSION, body, signer);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function verifyLossExperienceFeed(
|
|
331
|
+
feed: unknown,
|
|
332
|
+
options: VerifyLossExperienceFeedOptions = {},
|
|
333
|
+
) {
|
|
334
|
+
const refuse = (reason: string, verified = false, feedDigest: string | null = null) => ({
|
|
335
|
+
accepted: false,
|
|
336
|
+
verified,
|
|
337
|
+
reason,
|
|
338
|
+
feed_digest: feedDigest,
|
|
339
|
+
claim_boundary: LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY,
|
|
340
|
+
});
|
|
341
|
+
const signed = verifyRiskBody(feed, LOSS_EXPERIENCE_FEED_VERSION, options.trusted_keys);
|
|
342
|
+
if (!signed.valid || !signed.body) return refuse(signed.reason ?? 'feed_invalid');
|
|
343
|
+
if (typeof signed.artifact_digest !== 'string') return refuse('feed_digest_missing', true);
|
|
344
|
+
|
|
345
|
+
const { issuer, ...payload } = signed.body;
|
|
346
|
+
if (issuer.id !== payload.reporting_party_id) {
|
|
347
|
+
return refuse('reporting_party_issuer_mismatch', true, signed.artifact_digest);
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
validateBody(payload);
|
|
351
|
+
} catch {
|
|
352
|
+
return refuse('feed_schema_or_period_invalid', true, signed.artifact_digest);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const now = options.now === undefined
|
|
356
|
+
? Date.now()
|
|
357
|
+
: (typeof options.now === 'string' ? Date.parse(options.now) : Number(options.now));
|
|
358
|
+
if (!Number.isFinite(now)) return refuse('verification_time_invalid', true, signed.artifact_digest);
|
|
359
|
+
if (now < riskInstant(payload.issued_at)) return refuse('feed_not_yet_issued', true, signed.artifact_digest);
|
|
360
|
+
if (now >= riskInstant(payload.expires_at)) return refuse('feed_expired', true, signed.artifact_digest);
|
|
361
|
+
if (options.expected_program === undefined || options.expected_census_digest === undefined
|
|
362
|
+
|| options.expected_taxonomy_digest === undefined
|
|
363
|
+
|| options.expected_relying_party_id === undefined
|
|
364
|
+
|| options.expected_action_classes === undefined
|
|
365
|
+
|| options.expected_action_classes.length < 1
|
|
366
|
+
|| !options.expected_action_classes.every(riskIdentifier)) {
|
|
367
|
+
return refuse('context_binding_required', true, signed.artifact_digest);
|
|
368
|
+
}
|
|
369
|
+
if (!validExpectedProgram(options.expected_program)) {
|
|
370
|
+
return refuse('expected_program_invalid', true, signed.artifact_digest);
|
|
371
|
+
}
|
|
372
|
+
if (!sameProgram(payload.program, options.expected_program)) {
|
|
373
|
+
return refuse('program_binding_mismatch', true, signed.artifact_digest);
|
|
374
|
+
}
|
|
375
|
+
if (options.expected_census_digest !== payload.census_digest) {
|
|
376
|
+
return refuse('census_digest_mismatch', true, signed.artifact_digest);
|
|
377
|
+
}
|
|
378
|
+
if (options.expected_taxonomy_digest !== payload.taxonomy_digest) {
|
|
379
|
+
return refuse('taxonomy_digest_mismatch', true, signed.artifact_digest);
|
|
380
|
+
}
|
|
381
|
+
if (options.expected_relying_party_id !== payload.relying_party_id) {
|
|
382
|
+
return refuse('relying_party_mismatch', true, signed.artifact_digest);
|
|
383
|
+
}
|
|
384
|
+
const actionClasses = new Set(options.expected_action_classes);
|
|
385
|
+
if (!payload.records.every((record: RiskRecord) => actionClasses.has(record.action_class))) {
|
|
386
|
+
return refuse('action_class_taxonomy_mismatch', true, signed.artifact_digest);
|
|
387
|
+
}
|
|
388
|
+
const lineageFailure = validateLineage(payload, signed.artifact_digest, options);
|
|
389
|
+
if (lineageFailure !== null) return refuse(lineageFailure, true, signed.artifact_digest);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
accepted: true,
|
|
393
|
+
verified: true,
|
|
394
|
+
reason: null,
|
|
395
|
+
feed_digest: signed.artifact_digest,
|
|
396
|
+
claim_boundary: LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY,
|
|
397
|
+
};
|
|
398
|
+
}
|