@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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* immutable AdmissionSnapshot may cross the one-time invocation boundary.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import crypto from 'node:crypto';
|
|
9
10
|
import { createAdmissionSnapshot } from './admission-store.js';
|
|
10
11
|
import type {
|
|
11
12
|
AdmissionSnapshot,
|
|
@@ -30,6 +31,14 @@ const REQUIRED_QUALIFICATION_CHECKS = Object.freeze([
|
|
|
30
31
|
'assignment_in_scope',
|
|
31
32
|
'protected_request_bound',
|
|
32
33
|
] as const);
|
|
34
|
+
const GATE_QUALIFICATION_V2_PROGRAM_DIGEST = `sha256:${crypto
|
|
35
|
+
.createHash('sha256')
|
|
36
|
+
.update(JSON.stringify({
|
|
37
|
+
version: GATE_QUALIFICATION_V2_VERSION,
|
|
38
|
+
checks: REQUIRED_QUALIFICATION_CHECKS,
|
|
39
|
+
requirements: ['qualification', 'aeb', 'aec', 'local_policy'],
|
|
40
|
+
}))
|
|
41
|
+
.digest('hex')}` as const;
|
|
33
42
|
const DEFAULT_ADAPTER_TIMEOUT_MS = 30_000;
|
|
34
43
|
const MAX_ADAPTER_TIMEOUT_MS = 300_000;
|
|
35
44
|
|
|
@@ -56,6 +65,8 @@ export interface BoundRequirementDecisionV2 {
|
|
|
56
65
|
export interface BoundLocalPolicyDecisionV2 {
|
|
57
66
|
readonly decision: 'allow' | 'deny';
|
|
58
67
|
readonly policyId: string;
|
|
68
|
+
/** Digest of the relying-party authorization policy that made this decision. */
|
|
69
|
+
readonly policyDigest?: AdmissionDigestV2;
|
|
59
70
|
readonly caid: string;
|
|
60
71
|
readonly actionDigest: AdmissionDigestV2;
|
|
61
72
|
readonly evidenceDigest: AdmissionDigestV2;
|
|
@@ -85,6 +96,7 @@ export interface GateQualificationDecisionV2 {
|
|
|
85
96
|
readonly caid: string;
|
|
86
97
|
readonly actionDigest: string;
|
|
87
98
|
readonly snapshotDigest: string;
|
|
99
|
+
readonly programDigest: AdmissionDigestV2;
|
|
88
100
|
readonly effectKey: string;
|
|
89
101
|
readonly requirements: Readonly<{
|
|
90
102
|
qualificationEvidenceDigest: string;
|
|
@@ -222,6 +234,7 @@ export type GateQualificationExecutionResultV2 =
|
|
|
222
234
|
| {
|
|
223
235
|
readonly status: 'refused';
|
|
224
236
|
readonly reason: string;
|
|
237
|
+
readonly programDigest: AdmissionDigestV2;
|
|
225
238
|
readonly decision?: Readonly<GateQualificationDecisionV2>;
|
|
226
239
|
}
|
|
227
240
|
| {
|
|
@@ -505,6 +518,11 @@ function validateLocalPolicy(
|
|
|
505
518
|
if (!validString(leg.policyId)) {
|
|
506
519
|
addReason(reasons, 'localPolicy_policy_missing');
|
|
507
520
|
}
|
|
521
|
+
if (!validDigest(leg.policyDigest)) {
|
|
522
|
+
addReason(reasons, 'localPolicy_program_digest_missing');
|
|
523
|
+
} else if (leg.policyDigest !== snapshot.body.authorization_policy_digest) {
|
|
524
|
+
addReason(reasons, 'localPolicy_program_digest_mismatch');
|
|
525
|
+
}
|
|
508
526
|
if (leg.caid !== snapshot.body.caid
|
|
509
527
|
|| leg.actionDigest !== snapshot.body.action_digest) {
|
|
510
528
|
addReason(reasons, 'localPolicy_binding_mismatch');
|
|
@@ -580,6 +598,7 @@ function emptyDecision(reasons: readonly string[]): GateQualificationDecisionV2
|
|
|
580
598
|
caid: '',
|
|
581
599
|
actionDigest: '',
|
|
582
600
|
snapshotDigest: '',
|
|
601
|
+
programDigest: GATE_QUALIFICATION_V2_PROGRAM_DIGEST,
|
|
583
602
|
effectKey: '',
|
|
584
603
|
requirements: deepFreeze({
|
|
585
604
|
qualificationEvidenceDigest: '',
|
|
@@ -613,6 +632,7 @@ function composeCanonical(
|
|
|
613
632
|
caid: snapshot.body.caid,
|
|
614
633
|
actionDigest: snapshot.body.action_digest,
|
|
615
634
|
snapshotDigest: snapshot.snapshot_digest,
|
|
635
|
+
programDigest: snapshot.body.authorization_policy_digest,
|
|
616
636
|
effectKey: actuationKey(snapshot),
|
|
617
637
|
requirements: deepFreeze({
|
|
618
638
|
qualificationEvidenceDigest:
|
|
@@ -785,6 +805,20 @@ function reconciliationRequired(
|
|
|
785
805
|
return deepFreeze({ status: 'reconciliation_required', reason, admissionId });
|
|
786
806
|
}
|
|
787
807
|
|
|
808
|
+
function refused(
|
|
809
|
+
reason: string,
|
|
810
|
+
decision?: Readonly<GateQualificationDecisionV2>,
|
|
811
|
+
programDigest: AdmissionDigestV2 = decision?.programDigest
|
|
812
|
+
?? GATE_QUALIFICATION_V2_PROGRAM_DIGEST,
|
|
813
|
+
): Readonly<GateQualificationExecutionResultV2> {
|
|
814
|
+
return deepFreeze({
|
|
815
|
+
status: 'refused',
|
|
816
|
+
reason,
|
|
817
|
+
programDigest,
|
|
818
|
+
...(decision ? { decision } : {}),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
788
822
|
export class GateQualificationV2 {
|
|
789
823
|
readonly mode: QualificationModeV2;
|
|
790
824
|
readonly #store?: AdmissionStore;
|
|
@@ -1022,7 +1056,7 @@ export class GateQualificationV2 {
|
|
|
1022
1056
|
decision: Readonly<GateQualificationDecisionV2>,
|
|
1023
1057
|
): Promise<GateQualificationExecutionResultV2> {
|
|
1024
1058
|
if (storeReason !== 'admission_exists') {
|
|
1025
|
-
return
|
|
1059
|
+
return refused(storeReason, decision);
|
|
1026
1060
|
}
|
|
1027
1061
|
try {
|
|
1028
1062
|
const record = await this.#store!.read(reference(snapshot));
|
|
@@ -1037,7 +1071,7 @@ export class GateQualificationV2 {
|
|
|
1037
1071
|
'admission_read_ambiguous',
|
|
1038
1072
|
);
|
|
1039
1073
|
}
|
|
1040
|
-
return
|
|
1074
|
+
return refused(storeReason, decision);
|
|
1041
1075
|
}
|
|
1042
1076
|
|
|
1043
1077
|
async #verifyProviderEvidence(
|
|
@@ -1228,11 +1262,10 @@ export class GateQualificationV2 {
|
|
|
1228
1262
|
: emptyDecision(['admission_snapshot_invalid']);
|
|
1229
1263
|
if (this.mode === 'shadow') return this.#shadow(input, decision);
|
|
1230
1264
|
if (!checked.ok || !decision.allow) {
|
|
1231
|
-
return
|
|
1232
|
-
|
|
1233
|
-
reason: decision.reasons[0] ?? 'qualification_refused',
|
|
1265
|
+
return refused(
|
|
1266
|
+
decision.reasons[0] ?? 'qualification_refused',
|
|
1234
1267
|
decision,
|
|
1235
|
-
|
|
1268
|
+
);
|
|
1236
1269
|
}
|
|
1237
1270
|
|
|
1238
1271
|
const snapshot = checked.snapshot;
|
|
@@ -1240,11 +1273,7 @@ export class GateQualificationV2 {
|
|
|
1240
1273
|
try {
|
|
1241
1274
|
reserved = await this.#store!.reserve(snapshot);
|
|
1242
1275
|
} catch {
|
|
1243
|
-
return
|
|
1244
|
-
status: 'refused',
|
|
1245
|
-
reason: 'admission_reserve_failed',
|
|
1246
|
-
decision,
|
|
1247
|
-
});
|
|
1276
|
+
return refused('admission_reserve_failed', decision);
|
|
1248
1277
|
}
|
|
1249
1278
|
if (!reserved.ok) {
|
|
1250
1279
|
return this.#existingAdmissionResult(snapshot, reserved.reason, decision);
|
|
@@ -1263,11 +1292,7 @@ export class GateQualificationV2 {
|
|
|
1263
1292
|
} catch {
|
|
1264
1293
|
// No provider entry occurred; the reservation remains closed.
|
|
1265
1294
|
}
|
|
1266
|
-
return
|
|
1267
|
-
status: 'refused',
|
|
1268
|
-
reason: 'reserve_snapshot_mismatch',
|
|
1269
|
-
decision,
|
|
1270
|
-
});
|
|
1295
|
+
return refused('reserve_snapshot_mismatch', decision);
|
|
1271
1296
|
}
|
|
1272
1297
|
|
|
1273
1298
|
// Reread the candidate, qualification status, AEB, AEC, local policy and
|
|
@@ -1290,11 +1315,7 @@ export class GateQualificationV2 {
|
|
|
1290
1315
|
} catch {
|
|
1291
1316
|
// No execution right was consumed and no provider entry occurred.
|
|
1292
1317
|
}
|
|
1293
|
-
return
|
|
1294
|
-
status: 'refused',
|
|
1295
|
-
reason: 'invocation_remeasurement_failed',
|
|
1296
|
-
decision,
|
|
1297
|
-
});
|
|
1318
|
+
return refused('invocation_remeasurement_failed', decision);
|
|
1298
1319
|
}
|
|
1299
1320
|
const refreshedDecision = composeCanonical(
|
|
1300
1321
|
reserved.snapshot,
|
|
@@ -1310,12 +1331,10 @@ export class GateQualificationV2 {
|
|
|
1310
1331
|
} catch {
|
|
1311
1332
|
// No execution right was consumed and no provider entry occurred.
|
|
1312
1333
|
}
|
|
1313
|
-
return
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
decision: refreshedDecision,
|
|
1318
|
-
});
|
|
1334
|
+
return refused(
|
|
1335
|
+
refreshedDecision.reasons[0] ?? 'invocation_remeasurement_refused',
|
|
1336
|
+
refreshedDecision,
|
|
1337
|
+
);
|
|
1319
1338
|
}
|
|
1320
1339
|
|
|
1321
1340
|
let begun: Awaited<ReturnType<AdmissionStore['beginInvocation']>>;
|
|
@@ -1333,11 +1352,7 @@ export class GateQualificationV2 {
|
|
|
1333
1352
|
|| begun.reason === 'revision_conflict') {
|
|
1334
1353
|
return this.#beginAmbiguity(reserved.snapshot, reserved.owner_token);
|
|
1335
1354
|
}
|
|
1336
|
-
return
|
|
1337
|
-
status: 'refused',
|
|
1338
|
-
reason: begun.reason,
|
|
1339
|
-
decision,
|
|
1340
|
-
});
|
|
1355
|
+
return refused(begun.reason, decision);
|
|
1341
1356
|
}
|
|
1342
1357
|
|
|
1343
1358
|
const begunSnapshot = canonicalSnapshot(begun.snapshot);
|
|
@@ -1404,14 +1419,11 @@ export class GateQualificationV2 {
|
|
|
1404
1419
|
>>,
|
|
1405
1420
|
): Promise<GateQualificationExecutionResultV2> {
|
|
1406
1421
|
if (this.mode !== 'enforce') {
|
|
1407
|
-
return
|
|
1408
|
-
status: 'refused',
|
|
1409
|
-
reason: 'reconciliation_unavailable_in_shadow',
|
|
1410
|
-
});
|
|
1422
|
+
return refused('reconciliation_unavailable_in_shadow');
|
|
1411
1423
|
}
|
|
1412
1424
|
if (!input || !validString(input.tenant_id)
|
|
1413
1425
|
|| !validString(input.admission_id)) {
|
|
1414
|
-
return
|
|
1426
|
+
return refused('admission_reference_invalid');
|
|
1415
1427
|
}
|
|
1416
1428
|
|
|
1417
1429
|
let record: StoreRecordV2 | null;
|
|
@@ -1424,13 +1436,10 @@ export class GateQualificationV2 {
|
|
|
1424
1436
|
);
|
|
1425
1437
|
}
|
|
1426
1438
|
if (!record) {
|
|
1427
|
-
return
|
|
1439
|
+
return refused('admission_not_found');
|
|
1428
1440
|
}
|
|
1429
1441
|
if (record.execution_right !== 'CONSUMED') {
|
|
1430
|
-
return
|
|
1431
|
-
status: 'refused',
|
|
1432
|
-
reason: 'reconciliation_not_required',
|
|
1433
|
-
});
|
|
1442
|
+
return refused('reconciliation_not_required');
|
|
1434
1443
|
}
|
|
1435
1444
|
|
|
1436
1445
|
let snapshot: Readonly<AdmissionSnapshot> | null;
|
|
@@ -1499,10 +1508,11 @@ export class GateQualificationV2 {
|
|
|
1499
1508
|
const effectUnresolved = !record.effect_relation
|
|
1500
1509
|
|| record.effect_relation.value === 'INDETERMINATE';
|
|
1501
1510
|
if (!providerUnresolved && !effectUnresolved) {
|
|
1502
|
-
return
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1511
|
+
return refused(
|
|
1512
|
+
'reconciliation_not_required',
|
|
1513
|
+
undefined,
|
|
1514
|
+
snapshot.body.authorization_policy_digest,
|
|
1515
|
+
);
|
|
1506
1516
|
}
|
|
1507
1517
|
|
|
1508
1518
|
const base = protectedInvocation(snapshot, authority.invocationToken);
|
package/src/index.ts
CHANGED
|
@@ -238,6 +238,13 @@ export * from './admission-store-postgres.js';
|
|
|
238
238
|
export * from './gate-qualification-v2.js';
|
|
239
239
|
export * from './referee.js';
|
|
240
240
|
export * from './referee-runner.js';
|
|
241
|
+
export * from './loss-allocation-schedule.js';
|
|
242
|
+
export * from './action-refusal-statement.js';
|
|
243
|
+
export * from './open-exposure-ledger.js';
|
|
244
|
+
export * from './open-exposure-ledger-postgres.js';
|
|
245
|
+
export * from './coverage-reconciliation-attestation.js';
|
|
246
|
+
export * from './receipt-census.js';
|
|
247
|
+
export * from './loss-experience-feed.js';
|
|
241
248
|
export {
|
|
242
249
|
ZK_RANGE_RECEIPT_VERSION,
|
|
243
250
|
ZK_RANGE_SCHEME,
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EP-LOSS-ALLOCATION-SCHEDULE-v1.
|
|
4
|
+
*
|
|
5
|
+
* A separately signed, status-gated record of responsibility terms for one
|
|
6
|
+
* relying party and one exact Reliance Program. It does not adjudicate legal
|
|
7
|
+
* liability, prove solvency or coverage, authorize an action, or move money.
|
|
8
|
+
*/
|
|
9
|
+
import { canonicalize } from './execution-binding.js';
|
|
10
|
+
import {
|
|
11
|
+
RISK_DIGEST,
|
|
12
|
+
riskDigest,
|
|
13
|
+
riskExact,
|
|
14
|
+
riskFreeze,
|
|
15
|
+
riskIdentifier,
|
|
16
|
+
riskInstant,
|
|
17
|
+
riskRecord,
|
|
18
|
+
signRiskBody,
|
|
19
|
+
verifyRiskBody,
|
|
20
|
+
type RiskRecord,
|
|
21
|
+
type TrustedRiskKeys,
|
|
22
|
+
} from './reliance-risk-crypto.js';
|
|
23
|
+
|
|
24
|
+
export const LOSS_ALLOCATION_SCHEDULE_VERSION = 'EP-LOSS-ALLOCATION-SCHEDULE-v1';
|
|
25
|
+
export const LOSS_ALLOCATION_SCHEDULE_CLAIM_BOUNDARY =
|
|
26
|
+
'signed_terms_not_legal_liability_adjudication_enforceability_insurance_coverage_solvency_authorization_or_payment';
|
|
27
|
+
|
|
28
|
+
const SOURCE_KEYS = [
|
|
29
|
+
'schedule_id', 'relying_party_id', 'program', 'issued_at', 'valid_from',
|
|
30
|
+
'expires_at', 'status_target', 'rules', 'claim_boundary',
|
|
31
|
+
] as const;
|
|
32
|
+
const BODY_KEYS = ['@version', ...SOURCE_KEYS, 'issuer'] as const;
|
|
33
|
+
const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
|
|
34
|
+
const STATUS_TARGET_KEYS = ['type', 'usage'] as const;
|
|
35
|
+
const RULE_KEYS = [
|
|
36
|
+
'failure_class', 'responsible_party_id', 'allocation', 'terms_digest',
|
|
37
|
+
'dispute_endpoint',
|
|
38
|
+
] as const;
|
|
39
|
+
const ALLOCATION_KEYS = ['currency', 'max_amount_minor'] as const;
|
|
40
|
+
const ISSUER_KEYS = ['id', 'key_id'] as const;
|
|
41
|
+
const STATUS_KEYS = ['outcome', 'target_digest'] as const;
|
|
42
|
+
const FAILURE_CLASS = /^[a-z][a-z0-9._-]{0,127}$/;
|
|
43
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
44
|
+
const MINOR_AMOUNT = /^(0|[1-9][0-9]*)$/;
|
|
45
|
+
const MAX_RULES = 256;
|
|
46
|
+
const ADMISSIBILITY_VERDICTS = Object.freeze([
|
|
47
|
+
'unverifiable', 'conflicted', 'stale', 'missing_evidence', 'admissible',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export interface LossAllocationProgramBinding {
|
|
51
|
+
program_id: string;
|
|
52
|
+
version: number;
|
|
53
|
+
source_digest: string;
|
|
54
|
+
program_digest: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LossAllocationStatusResult {
|
|
58
|
+
outcome: 'current_not_revoked' | 'revoked' | 'unavailable';
|
|
59
|
+
target_digest: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface VerifyLossAllocationScheduleOptions {
|
|
63
|
+
trusted_keys?: TrustedRiskKeys;
|
|
64
|
+
expected_relying_party_id?: string;
|
|
65
|
+
expected_program?: LossAllocationProgramBinding;
|
|
66
|
+
status?: LossAllocationStatusResult;
|
|
67
|
+
now?: string | number | Date;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LossAllocationSigner {
|
|
71
|
+
issuer_id: string;
|
|
72
|
+
key_id: string;
|
|
73
|
+
private_key: any;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class LossAllocationScheduleValidationError extends TypeError {
|
|
77
|
+
readonly code: string;
|
|
78
|
+
|
|
79
|
+
constructor(code: string, message: string) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = 'LossAllocationScheduleValidationError';
|
|
82
|
+
this.code = code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function refuse(code: string, message: string): never {
|
|
87
|
+
throw new LossAllocationScheduleValidationError(code, message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateProgram(value: unknown): value is RiskRecord {
|
|
91
|
+
return riskExact(value, PROGRAM_KEYS)
|
|
92
|
+
&& riskIdentifier(value.program_id)
|
|
93
|
+
&& Number.isSafeInteger(value.version) && value.version >= 1
|
|
94
|
+
&& typeof value.source_digest === 'string' && RISK_DIGEST.test(value.source_digest)
|
|
95
|
+
&& typeof value.program_digest === 'string' && RISK_DIGEST.test(value.program_digest);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function httpsEndpoint(value: unknown): boolean {
|
|
99
|
+
if (value === null) return true;
|
|
100
|
+
if (typeof value !== 'string' || value.length > 2048) return false;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(value);
|
|
103
|
+
return parsed.protocol === 'https:' && parsed.username === '' && parsed.password === '';
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateRules(value: unknown): asserts value is RiskRecord[] {
|
|
110
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_RULES) {
|
|
111
|
+
refuse('schedule_rules_invalid', 'loss-allocation rules must be a bounded non-empty array');
|
|
112
|
+
}
|
|
113
|
+
const classes = new Map<string, string>();
|
|
114
|
+
let previousFailureClass: string | null = null;
|
|
115
|
+
for (const rule of value) {
|
|
116
|
+
if (!riskExact(rule, RULE_KEYS)
|
|
117
|
+
|| typeof rule.failure_class !== 'string' || !FAILURE_CLASS.test(rule.failure_class)
|
|
118
|
+
|| !riskIdentifier(rule.responsible_party_id)
|
|
119
|
+
|| !riskExact(rule.allocation, ALLOCATION_KEYS)
|
|
120
|
+
|| typeof rule.allocation.currency !== 'string' || !CURRENCY.test(rule.allocation.currency)
|
|
121
|
+
|| typeof rule.allocation.max_amount_minor !== 'string'
|
|
122
|
+
|| !MINOR_AMOUNT.test(rule.allocation.max_amount_minor)
|
|
123
|
+
|| typeof rule.terms_digest !== 'string' || !RISK_DIGEST.test(rule.terms_digest)
|
|
124
|
+
|| !httpsEndpoint(rule.dispute_endpoint)) {
|
|
125
|
+
refuse('failure_rule_invalid', 'failure rule or decimal monetary allocation is invalid');
|
|
126
|
+
}
|
|
127
|
+
const canonicalRule = canonicalize(rule);
|
|
128
|
+
const previous = classes.get(rule.failure_class);
|
|
129
|
+
if (previous !== undefined) {
|
|
130
|
+
refuse(previous === canonicalRule ? 'duplicate_failure_rule' : 'conflicting_failure_rule',
|
|
131
|
+
'duplicate or conflicting failure class is not allowed');
|
|
132
|
+
}
|
|
133
|
+
if (previousFailureClass !== null && previousFailureClass > rule.failure_class) {
|
|
134
|
+
refuse('rules_not_canonical_order', 'failure rules must be strictly ordered by ASCII failure class');
|
|
135
|
+
}
|
|
136
|
+
previousFailureClass = rule.failure_class;
|
|
137
|
+
classes.set(rule.failure_class, canonicalRule);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateTimes(value: RiskRecord): void {
|
|
142
|
+
const issuedAt = riskInstant(value.issued_at);
|
|
143
|
+
const validFrom = riskInstant(value.valid_from);
|
|
144
|
+
const expiresAt = riskInstant(value.expires_at);
|
|
145
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(validFrom) || !Number.isFinite(expiresAt)
|
|
146
|
+
|| expiresAt <= validFrom || issuedAt > expiresAt) {
|
|
147
|
+
refuse('schedule_validity_invalid', 'schedule issuance and validity window are invalid');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateSource(value: unknown): asserts value is RiskRecord {
|
|
152
|
+
try {
|
|
153
|
+
canonicalize(value);
|
|
154
|
+
} catch {
|
|
155
|
+
refuse('schedule_not_canonical', 'loss-allocation source is not canonicalizable JSON');
|
|
156
|
+
}
|
|
157
|
+
if (!riskExact(value, SOURCE_KEYS)
|
|
158
|
+
|| !riskIdentifier(value.schedule_id)
|
|
159
|
+
|| !riskIdentifier(value.relying_party_id)
|
|
160
|
+
|| !validateProgram(value.program)
|
|
161
|
+
|| !riskExact(value.status_target, STATUS_TARGET_KEYS)
|
|
162
|
+
|| value.status_target.type !== 'loss-allocation-schedule'
|
|
163
|
+
|| value.status_target.usage !== 'reliance') {
|
|
164
|
+
refuse('schedule_schema_invalid', 'loss-allocation source is not a closed v1 object');
|
|
165
|
+
}
|
|
166
|
+
validateTimes(value);
|
|
167
|
+
validateRules(value.rules);
|
|
168
|
+
if (value.claim_boundary !== LOSS_ALLOCATION_SCHEDULE_CLAIM_BOUNDARY) {
|
|
169
|
+
refuse('claim_boundary_invalid', 'loss-allocation claim boundary is missing or changed');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateBody(value: unknown): asserts value is RiskRecord {
|
|
174
|
+
try {
|
|
175
|
+
canonicalize(value);
|
|
176
|
+
} catch {
|
|
177
|
+
refuse('schedule_not_canonical', 'signed loss-allocation body is not canonicalizable JSON');
|
|
178
|
+
}
|
|
179
|
+
if (!riskExact(value, BODY_KEYS)
|
|
180
|
+
|| value['@version'] !== LOSS_ALLOCATION_SCHEDULE_VERSION
|
|
181
|
+
|| !riskExact(value.issuer, ISSUER_KEYS)
|
|
182
|
+
|| !riskIdentifier(value.issuer.id)
|
|
183
|
+
|| !riskIdentifier(value.issuer.key_id)) {
|
|
184
|
+
refuse('schedule_schema_invalid', 'signed loss-allocation body is not a closed v1 object');
|
|
185
|
+
}
|
|
186
|
+
const { '@version': _version, issuer: _issuer, ...source } = value;
|
|
187
|
+
validateSource(source);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function signedParts(artifact: unknown): { artifact: RiskRecord; body: RiskRecord } {
|
|
191
|
+
if (!riskRecord(artifact) || !riskRecord(artifact.proof)) {
|
|
192
|
+
refuse('artifact_shape_invalid', 'signed loss-allocation artifact is malformed');
|
|
193
|
+
}
|
|
194
|
+
const { proof: _proof, ...body } = artifact;
|
|
195
|
+
validateBody(body);
|
|
196
|
+
return { artifact, body };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function verificationTime(value: unknown): number {
|
|
200
|
+
if (value === undefined) return Date.now();
|
|
201
|
+
if (value instanceof Date) return value.getTime();
|
|
202
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN;
|
|
203
|
+
return riskInstant(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sameProgram(actual: RiskRecord, expected: LossAllocationProgramBinding): boolean {
|
|
207
|
+
return actual.program_id === expected.program_id
|
|
208
|
+
&& actual.version === expected.version
|
|
209
|
+
&& actual.source_digest === expected.source_digest
|
|
210
|
+
&& actual.program_digest === expected.program_digest;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Digest of the complete signed artifact, including its proof. */
|
|
214
|
+
export function lossAllocationScheduleDigest(artifact: unknown): string {
|
|
215
|
+
const signed = signedParts(artifact);
|
|
216
|
+
return riskDigest(signed.artifact);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Digest of the exact versioned failure-class rules, excluding program pins. */
|
|
220
|
+
export function lossAllocationRulesDigest(artifact: unknown): string {
|
|
221
|
+
const signed = signedParts(artifact);
|
|
222
|
+
return riskDigest({
|
|
223
|
+
'@version': LOSS_ALLOCATION_SCHEDULE_VERSION,
|
|
224
|
+
rules: signed.body.rules,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Compact digest pin for an RP-owned Admissibility Profile. Status remains a
|
|
230
|
+
* separate required input; this reference does not claim global freshness.
|
|
231
|
+
*/
|
|
232
|
+
export function lossAllocationScheduleProfileReference(artifact: unknown): RiskRecord {
|
|
233
|
+
return riskFreeze({
|
|
234
|
+
artifact_type: LOSS_ALLOCATION_SCHEDULE_VERSION,
|
|
235
|
+
artifact_digest: lossAllocationScheduleDigest(artifact),
|
|
236
|
+
required_status: true,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Sign a closed schedule under the shared JCS/Ed25519 risk-artifact proof. */
|
|
241
|
+
export function signLossAllocationSchedule(
|
|
242
|
+
input: unknown,
|
|
243
|
+
signer: LossAllocationSigner,
|
|
244
|
+
): RiskRecord {
|
|
245
|
+
validateSource(input);
|
|
246
|
+
if (!riskRecord(signer)
|
|
247
|
+
|| !riskIdentifier(signer.issuer_id)
|
|
248
|
+
|| !riskIdentifier(signer.key_id)
|
|
249
|
+
|| !Object.hasOwn(signer, 'private_key')) {
|
|
250
|
+
refuse('signing_key_invalid', 'loss-allocation signer is invalid');
|
|
251
|
+
}
|
|
252
|
+
const body = {
|
|
253
|
+
'@version': LOSS_ALLOCATION_SCHEDULE_VERSION,
|
|
254
|
+
...(input as RiskRecord),
|
|
255
|
+
issuer: { id: signer.issuer_id, key_id: signer.key_id },
|
|
256
|
+
};
|
|
257
|
+
validateBody(body);
|
|
258
|
+
try {
|
|
259
|
+
return signRiskBody(LOSS_ALLOCATION_SCHEDULE_VERSION, body, signer);
|
|
260
|
+
} catch {
|
|
261
|
+
refuse('signing_key_invalid', 'loss-allocation signing key must be Ed25519');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Verify signature, RP/program pins, external current status, and validity. */
|
|
266
|
+
export function verifyLossAllocationSchedule(
|
|
267
|
+
artifact: unknown,
|
|
268
|
+
options: VerifyLossAllocationScheduleOptions = {},
|
|
269
|
+
): RiskRecord {
|
|
270
|
+
const fail = (
|
|
271
|
+
reason: string,
|
|
272
|
+
verified = false,
|
|
273
|
+
scheduleDigest: string | null = null,
|
|
274
|
+
) => ({
|
|
275
|
+
accepted: false,
|
|
276
|
+
verified,
|
|
277
|
+
reason,
|
|
278
|
+
schedule_digest: scheduleDigest,
|
|
279
|
+
rules_digest: null,
|
|
280
|
+
claim_boundary: LOSS_ALLOCATION_SCHEDULE_CLAIM_BOUNDARY,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const signed = verifyRiskBody(
|
|
284
|
+
artifact,
|
|
285
|
+
LOSS_ALLOCATION_SCHEDULE_VERSION,
|
|
286
|
+
options.trusted_keys,
|
|
287
|
+
);
|
|
288
|
+
if (!signed.valid || !signed.body || !signed.artifact_digest) {
|
|
289
|
+
return fail(signed.reason ?? 'schedule_invalid');
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
validateBody(signed.body);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return fail(error instanceof LossAllocationScheduleValidationError
|
|
295
|
+
? error.code : 'schedule_schema_invalid');
|
|
296
|
+
}
|
|
297
|
+
const body = signed.body;
|
|
298
|
+
const artifactDigest = signed.artifact_digest;
|
|
299
|
+
|
|
300
|
+
if (!riskExact(options.status, STATUS_KEYS)
|
|
301
|
+
|| typeof options.status.target_digest !== 'string'
|
|
302
|
+
|| !RISK_DIGEST.test(options.status.target_digest)) {
|
|
303
|
+
return fail('schedule_status_required', true, artifactDigest);
|
|
304
|
+
}
|
|
305
|
+
if (options.status.target_digest !== artifactDigest) {
|
|
306
|
+
return fail('status_target_mismatch', true, artifactDigest);
|
|
307
|
+
}
|
|
308
|
+
if (options.status.outcome === 'revoked') {
|
|
309
|
+
return fail('schedule_revoked', true, artifactDigest);
|
|
310
|
+
}
|
|
311
|
+
if (options.status.outcome !== 'current_not_revoked') {
|
|
312
|
+
return fail('schedule_status_not_current', true, artifactDigest);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const now = verificationTime(options.now);
|
|
316
|
+
if (!Number.isFinite(now)) return fail('verification_time_invalid', true, artifactDigest);
|
|
317
|
+
if (now < riskInstant(body.issued_at)) {
|
|
318
|
+
return fail('schedule_not_yet_issued', true, artifactDigest);
|
|
319
|
+
}
|
|
320
|
+
if (now < riskInstant(body.valid_from)) {
|
|
321
|
+
return fail('schedule_not_yet_valid', true, artifactDigest);
|
|
322
|
+
}
|
|
323
|
+
if (now >= riskInstant(body.expires_at)) {
|
|
324
|
+
return fail('schedule_stale', true, artifactDigest);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (options.expected_relying_party_id === undefined) {
|
|
328
|
+
return fail('relying_party_binding_required', true, artifactDigest);
|
|
329
|
+
}
|
|
330
|
+
if (!riskIdentifier(options.expected_relying_party_id)
|
|
331
|
+
|| options.expected_relying_party_id !== body.relying_party_id) {
|
|
332
|
+
return fail('relying_party_binding_mismatch', true, artifactDigest);
|
|
333
|
+
}
|
|
334
|
+
if (options.expected_program === undefined) {
|
|
335
|
+
return fail('program_binding_required', true, artifactDigest);
|
|
336
|
+
}
|
|
337
|
+
if (!validateProgram(options.expected_program)) {
|
|
338
|
+
return fail('expected_program_invalid', true, artifactDigest);
|
|
339
|
+
}
|
|
340
|
+
if (!sameProgram(body.program, options.expected_program)) {
|
|
341
|
+
return fail('reliance_program_binding_mismatch', true, artifactDigest);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return riskFreeze({
|
|
345
|
+
accepted: true,
|
|
346
|
+
verified: true,
|
|
347
|
+
reason: null,
|
|
348
|
+
schedule_digest: artifactDigest,
|
|
349
|
+
rules_digest: lossAllocationRulesDigest(artifact),
|
|
350
|
+
schedule_id: body.schedule_id,
|
|
351
|
+
relying_party_id: body.relying_party_id,
|
|
352
|
+
program_id: body.program.program_id,
|
|
353
|
+
program_version: body.program.version,
|
|
354
|
+
source_digest: body.program.source_digest,
|
|
355
|
+
program_digest: body.program.program_digest,
|
|
356
|
+
issuer_id: body.issuer.id,
|
|
357
|
+
key_id: body.issuer.key_id,
|
|
358
|
+
status: 'current_not_revoked',
|
|
359
|
+
claim_boundary: LOSS_ALLOCATION_SCHEDULE_CLAIM_BOUNDARY,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Build a standard self-hashed Admissibility Profile and its unchanged
|
|
365
|
+
* Reliance Program v1 profile reference. Only schedule identity, RP, issuer,
|
|
366
|
+
* and exact rules are pinned here; final program digests are deliberately
|
|
367
|
+
* excluded to avoid a profile/program/schedule digest cycle.
|
|
368
|
+
*/
|
|
369
|
+
export function createLossAllocationAdmissibilityProfilePin(
|
|
370
|
+
artifact: unknown,
|
|
371
|
+
{
|
|
372
|
+
profileId,
|
|
373
|
+
evaluationMaxAgeSec,
|
|
374
|
+
}: {
|
|
375
|
+
profileId: string;
|
|
376
|
+
evaluationMaxAgeSec: number;
|
|
377
|
+
},
|
|
378
|
+
verification: VerifyLossAllocationScheduleOptions,
|
|
379
|
+
): RiskRecord {
|
|
380
|
+
const signed = signedParts(artifact);
|
|
381
|
+
const verified = verifyLossAllocationSchedule(artifact, verification);
|
|
382
|
+
if (verified.accepted !== true) {
|
|
383
|
+
refuse('schedule_verification_required', `cannot pin an unaccepted schedule: ${verified.reason}`);
|
|
384
|
+
}
|
|
385
|
+
if (!riskIdentifier(profileId)
|
|
386
|
+
|| !Number.isSafeInteger(evaluationMaxAgeSec)
|
|
387
|
+
|| evaluationMaxAgeSec < 1 || evaluationMaxAgeSec > 31_536_000) {
|
|
388
|
+
refuse('admissibility_profile_pin_invalid', 'profile id and evaluation age must be bounded');
|
|
389
|
+
}
|
|
390
|
+
const profileBody = {
|
|
391
|
+
id: profileId,
|
|
392
|
+
version: 1,
|
|
393
|
+
authored_by: signed.body.relying_party_id,
|
|
394
|
+
requires: [{
|
|
395
|
+
evidence: 'loss_allocation_schedule',
|
|
396
|
+
max_staleness_sec: evaluationMaxAgeSec,
|
|
397
|
+
checks: [
|
|
398
|
+
'signature_valid',
|
|
399
|
+
'reliance_program_binding_valid',
|
|
400
|
+
'rules_digest_matches',
|
|
401
|
+
'status_current',
|
|
402
|
+
],
|
|
403
|
+
}],
|
|
404
|
+
verdicts: [...ADMISSIBILITY_VERDICTS],
|
|
405
|
+
loss_allocation_schedule: {
|
|
406
|
+
artifact_version: LOSS_ALLOCATION_SCHEDULE_VERSION,
|
|
407
|
+
schedule_id: signed.body.schedule_id,
|
|
408
|
+
relying_party_id: signed.body.relying_party_id,
|
|
409
|
+
issuer_id: signed.body.issuer.id,
|
|
410
|
+
issuer_key_id: signed.body.issuer.key_id,
|
|
411
|
+
rules_digest: lossAllocationRulesDigest(artifact),
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
const profile = {
|
|
415
|
+
...profileBody,
|
|
416
|
+
profile_hash: riskDigest(profileBody),
|
|
417
|
+
};
|
|
418
|
+
return riskFreeze({
|
|
419
|
+
profile,
|
|
420
|
+
reference: {
|
|
421
|
+
profile_id: profile.id,
|
|
422
|
+
profile_hash: profile.profile_hash,
|
|
423
|
+
evaluation_max_age_sec: evaluationMaxAgeSec,
|
|
424
|
+
revocation_required: true,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|