@emilia-protocol/gate 0.13.0 → 0.15.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 +53 -0
- package/README.md +89 -0
- package/aeb-consumption-store.js +3 -0
- package/dist/aeb-consumption-store.d.ts +80 -0
- package/dist/aeb-consumption-store.d.ts.map +1 -0
- package/dist/aeb-consumption-store.js +430 -0
- package/dist/aeb-consumption-store.js.map +1 -0
- package/dist/capability-receipt.d.ts +8 -0
- package/dist/capability-receipt.d.ts.map +1 -1
- package/dist/capability-receipt.js +17 -0
- package/dist/capability-receipt.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts +129 -0
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
- package/dist/proposal-to-effect-postgres.js +1511 -0
- package/dist/proposal-to-effect-postgres.js.map +1 -0
- package/dist/proposal-to-effect-status.d.ts +55 -0
- package/dist/proposal-to-effect-status.d.ts.map +1 -0
- package/dist/proposal-to-effect-status.js +280 -0
- package/dist/proposal-to-effect-status.js.map +1 -0
- package/dist/proposal-to-effect.d.ts +294 -0
- package/dist/proposal-to-effect.d.ts.map +1 -0
- package/dist/proposal-to-effect.js +970 -0
- package/dist/proposal-to-effect.js.map +1 -0
- package/dist/remedy-case-set-postgres.d.ts +73 -0
- package/dist/remedy-case-set-postgres.d.ts.map +1 -0
- package/dist/remedy-case-set-postgres.js +846 -0
- package/dist/remedy-case-set-postgres.js.map +1 -0
- package/dist/remedy-case-set.d.ts +53 -0
- package/dist/remedy-case-set.d.ts.map +1 -0
- package/dist/remedy-case-set.js +571 -0
- package/dist/remedy-case-set.js.map +1 -0
- package/dist/remedy-program-adapters.d.ts +137 -0
- package/dist/remedy-program-adapters.d.ts.map +1 -0
- package/dist/remedy-program-adapters.js +590 -0
- package/dist/remedy-program-adapters.js.map +1 -0
- package/dist/trust-program-revocation.js.map +1 -1
- package/package.json +280 -63
- package/proposal-to-effect-postgres.js +3 -0
- package/proposal-to-effect-status.js +2 -0
- package/proposal-to-effect.js +4 -0
- package/remedy-case-set-postgres.js +3 -0
- package/remedy-case-set.js +3 -0
- package/remedy-program-adapters.js +3 -0
- package/src/aeb-consumption-store.ts +521 -0
- package/src/capability-receipt.ts +17 -0
- package/src/index.ts +57 -3
- package/src/proposal-to-effect-postgres.ts +1740 -0
- package/src/proposal-to-effect-status.ts +357 -0
- package/src/proposal-to-effect.ts +1276 -0
- package/src/remedy-case-set-postgres.ts +1008 -0
- package/src/remedy-case-set.ts +603 -0
- package/src/remedy-program-adapters.ts +657 -0
- package/src/trust-program-revocation.ts +1 -1
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Proposal-to-Effect is a product orchestration profile over existing EMILIA
|
|
5
|
+
* artifacts. A proposal is deliberately NOT a bearer authorization object.
|
|
6
|
+
* Authority remains in EP-RECEIPT-v1 and the relying party's pinned AEB
|
|
7
|
+
* requirement; consequence custody remains in Gate and its durable stores.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { beginReceiptApproval, pollReceiptApproval, approvalActionHash, validateApprovalAuthorization, validateCaidSelector, validateRequiredFields, } from '@emilia-protocol/require-receipt/acquisition';
|
|
11
|
+
import { aebReservationKey, authorizeAebExecutionDurable, digestAeb, pinnedConfigDigest, reconcileAebExecutionDurable, verifyAebEvaluation, } from '@emilia-protocol/verify/aeb-adapter-contract';
|
|
12
|
+
import { actionDigest as aecActionDigest } from '@emilia-protocol/verify/evidence-chain';
|
|
13
|
+
export const PROPOSAL_TO_EFFECT_VERSION = 'EMILIA-PROPOSAL-TO-EFFECT-v1';
|
|
14
|
+
const CAID_PATTERN = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:[a-z0-9]+(?:-[a-z0-9]+)*:[A-Za-z0-9_-]{43}$/;
|
|
15
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$/;
|
|
16
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
17
|
+
const PROPOSAL_INTEGRITY_DOMAIN = `${PROPOSAL_TO_EFFECT_VERSION}:INTEGRITY\0`;
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
20
|
+
return false;
|
|
21
|
+
const prototype = Object.getPrototypeOf(value);
|
|
22
|
+
return prototype === Object.prototype || prototype === null;
|
|
23
|
+
}
|
|
24
|
+
function clone(value) {
|
|
25
|
+
return structuredClone(value);
|
|
26
|
+
}
|
|
27
|
+
function assertIdentifier(value, name) {
|
|
28
|
+
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
|
29
|
+
throw new Error(`${name}_invalid`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validDigest(value) {
|
|
33
|
+
return typeof value === 'string' && DIGEST_PATTERN.test(value);
|
|
34
|
+
}
|
|
35
|
+
function canonicalInstant(value) {
|
|
36
|
+
if (typeof value !== 'string')
|
|
37
|
+
return NaN;
|
|
38
|
+
const parsed = Date.parse(value);
|
|
39
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value)
|
|
40
|
+
return NaN;
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
function exactKeys(value, keys) {
|
|
44
|
+
if (!isPlainObject(value))
|
|
45
|
+
return false;
|
|
46
|
+
const actual = Object.keys(value).sort();
|
|
47
|
+
const expected = [...keys].sort();
|
|
48
|
+
return actual.length === expected.length && expected.every((key, index) => key === actual[index]);
|
|
49
|
+
}
|
|
50
|
+
function secureConsequenceStore(store) {
|
|
51
|
+
return store !== null && typeof store === 'object'
|
|
52
|
+
&& store.durable === true && store.ownershipFenced === true
|
|
53
|
+
&& store.compareAndSwap === true && store.atomicEvidenceBinding === true
|
|
54
|
+
&& typeof store.reserve === 'function' && typeof store.transition === 'function'
|
|
55
|
+
&& typeof store.reconcile === 'function' && typeof store.read === 'function';
|
|
56
|
+
}
|
|
57
|
+
function normalizedCurrentStatus(value) {
|
|
58
|
+
return exactKeys(value, [
|
|
59
|
+
'checked_at', 'expires_at', 'revocation_checked', 'revoked', 'consumed',
|
|
60
|
+
...(isPlainObject(value) && Object.hasOwn(value, 'unavailable') ? ['unavailable'] : []),
|
|
61
|
+
]) && typeof value.checked_at === 'string' && Number.isFinite(Date.parse(value.checked_at))
|
|
62
|
+
&& typeof value.expires_at === 'string' && Number.isFinite(Date.parse(value.expires_at))
|
|
63
|
+
&& typeof value.revocation_checked === 'boolean' && typeof value.revoked === 'boolean'
|
|
64
|
+
&& typeof value.consumed === 'boolean'
|
|
65
|
+
&& (value.unavailable === undefined || typeof value.unavailable === 'boolean');
|
|
66
|
+
}
|
|
67
|
+
function assertProfile(profile) {
|
|
68
|
+
if (!isPlainObject(profile))
|
|
69
|
+
throw new Error('proposal_profile_invalid');
|
|
70
|
+
assertIdentifier(profile.id, 'proposal_profile_id');
|
|
71
|
+
assertIdentifier(profile.action_type, 'proposal_action_type');
|
|
72
|
+
assertIdentifier(profile.aeb_requirement_ref, 'proposal_aeb_requirement_ref');
|
|
73
|
+
if (!isPlainObject(profile.selector) || Object.keys(profile.selector).length === 0) {
|
|
74
|
+
throw new Error('proposal_selector_invalid');
|
|
75
|
+
}
|
|
76
|
+
const requiredFields = validateRequiredFields(profile.required_fields);
|
|
77
|
+
if (!requiredFields.ok)
|
|
78
|
+
throw new Error(requiredFields.reason);
|
|
79
|
+
if (!Number.isSafeInteger(profile.ttl_sec) || profile.ttl_sec <= 0 || profile.ttl_sec > 86_400) {
|
|
80
|
+
throw new Error('proposal_ttl_invalid');
|
|
81
|
+
}
|
|
82
|
+
if (typeof profile.canonicalize_action !== 'function') {
|
|
83
|
+
throw new Error('proposal_canonicalizer_required');
|
|
84
|
+
}
|
|
85
|
+
const authorization = validateApprovalAuthorization(profile.authorization);
|
|
86
|
+
if (!authorization.ok)
|
|
87
|
+
throw new Error(authorization.reason);
|
|
88
|
+
if (profile.caid_selector) {
|
|
89
|
+
const caidSelector = validateCaidSelector(profile.caid_selector);
|
|
90
|
+
if (!caidSelector.ok)
|
|
91
|
+
throw new Error(caidSelector.reason);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function canonicalizeForProfile(profile, input) {
|
|
95
|
+
const normalized = profile.canonicalize_action(clone(input));
|
|
96
|
+
if (!isPlainObject(normalized) || !isPlainObject(normalized.action)
|
|
97
|
+
|| typeof normalized.caid !== 'string' || !CAID_PATTERN.test(normalized.caid)) {
|
|
98
|
+
throw new Error('proposal_canonicalization_invalid');
|
|
99
|
+
}
|
|
100
|
+
if (normalized.action.action_type !== profile.action_type) {
|
|
101
|
+
throw new Error('proposal_action_type_mismatch');
|
|
102
|
+
}
|
|
103
|
+
for (const field of profile.required_fields) {
|
|
104
|
+
if (!Object.hasOwn(normalized.action, field) || normalized.action[field] === undefined) {
|
|
105
|
+
throw new Error(`proposal_required_field_missing:${field}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (profile.caid_selector) {
|
|
109
|
+
const field = profile.caid_selector.field;
|
|
110
|
+
if (typeof field !== 'string' || normalized.action[field] !== normalized.caid) {
|
|
111
|
+
throw new Error(`proposal_caid_binding_invalid:${field}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Both digest functions reject unsupported/non-canonical JSON values.
|
|
115
|
+
approvalActionHash(normalized.action);
|
|
116
|
+
digestAeb(normalized.action);
|
|
117
|
+
return { action: clone(normalized.action), caid: normalized.caid };
|
|
118
|
+
}
|
|
119
|
+
function assertSameObject(left, right, reason) {
|
|
120
|
+
if (digestAeb(left) !== digestAeb(right))
|
|
121
|
+
throw new Error(reason);
|
|
122
|
+
}
|
|
123
|
+
function exactProposalKeys(proposal) {
|
|
124
|
+
const expected = [
|
|
125
|
+
'@version', 'action', 'action_digest', 'aeb', 'aeb_action_digest', 'authorization',
|
|
126
|
+
'caid', 'challenge', 'consequence', 'created_at', 'expires_at', 'initiator_id',
|
|
127
|
+
'integrity', 'operation_id', 'profile_id', 'proposal_id',
|
|
128
|
+
].sort();
|
|
129
|
+
const actual = Object.keys(proposal).sort();
|
|
130
|
+
return expected.length === actual.length && expected.every((key, index) => key === actual[index]);
|
|
131
|
+
}
|
|
132
|
+
function proposalAdmissibility(proposal, record) {
|
|
133
|
+
return {
|
|
134
|
+
admissibility_profile: { id: `aeb:${proposal.aeb.requirement_ref}`, version: '1' },
|
|
135
|
+
profile_hash: proposal.aeb.pinned_config_digest,
|
|
136
|
+
verdict: 'admissible',
|
|
137
|
+
replay_digest: record.evidence_digest,
|
|
138
|
+
challenge_id: proposal.proposal_id,
|
|
139
|
+
aeb_evaluation_digest: digestAeb(record),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function refusal(reason, extra = {}) {
|
|
143
|
+
return { ok: false, reason, ...extra };
|
|
144
|
+
}
|
|
145
|
+
export function proposalToEffectConsumptionNonce(operationId, pinnedConfigDigest) {
|
|
146
|
+
assertIdentifier(operationId, 'proposal_operation_id');
|
|
147
|
+
if (typeof pinnedConfigDigest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(pinnedConfigDigest)) {
|
|
148
|
+
throw new Error('proposal_aeb_pin_invalid');
|
|
149
|
+
}
|
|
150
|
+
return digestAeb({
|
|
151
|
+
domain: PROPOSAL_TO_EFFECT_VERSION,
|
|
152
|
+
operation_id: operationId,
|
|
153
|
+
pinned_config_digest: pinnedConfigDigest,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function expectedAebCompositionDigest(proposal) {
|
|
157
|
+
return `sha256:${aecActionDigest({
|
|
158
|
+
caid: proposal.caid,
|
|
159
|
+
normalized_action_digest: proposal.aeb_action_digest,
|
|
160
|
+
})}`;
|
|
161
|
+
}
|
|
162
|
+
export function createProposalToEffect(options) {
|
|
163
|
+
if (!options?.gate || typeof options.gate.check !== 'function' || typeof options.gate.run !== 'function') {
|
|
164
|
+
throw new Error('proposal_gate_required');
|
|
165
|
+
}
|
|
166
|
+
if (!isPlainObject(options.profiles) || Object.keys(options.profiles).length === 0) {
|
|
167
|
+
throw new Error('proposal_profiles_required');
|
|
168
|
+
}
|
|
169
|
+
for (const [id, profile] of Object.entries(options.profiles)) {
|
|
170
|
+
assertProfile(profile);
|
|
171
|
+
if (id !== profile.id)
|
|
172
|
+
throw new Error('proposal_profile_registry_mismatch');
|
|
173
|
+
}
|
|
174
|
+
if (!options.aeb?.config || !options.aeb.adapters || !options.aeb.store
|
|
175
|
+
|| typeof options.aeb.resolve_artifacts !== 'function'
|
|
176
|
+
|| typeof options.aeb.currentStatusResolver !== 'function'
|
|
177
|
+
|| typeof options.aeb.statusVerifier !== 'function'
|
|
178
|
+
|| typeof options.aeb.verify_provider_evidence !== 'function') {
|
|
179
|
+
throw new Error('proposal_aeb_configuration_required');
|
|
180
|
+
}
|
|
181
|
+
if (!(options.proposal_integrity?.hmac_sha256_key instanceof Uint8Array)
|
|
182
|
+
|| options.proposal_integrity.hmac_sha256_key.byteLength < 32) {
|
|
183
|
+
throw new Error('proposal_integrity_configuration_required');
|
|
184
|
+
}
|
|
185
|
+
if (!options.consequence)
|
|
186
|
+
throw new Error('proposal_consequence_configuration_required');
|
|
187
|
+
assertIdentifier(options.consequence.tenant_id, 'proposal_tenant_id');
|
|
188
|
+
assertIdentifier(options.consequence.provider_id, 'proposal_provider_id');
|
|
189
|
+
assertIdentifier(options.consequence.provider_account_id, 'proposal_provider_account_id');
|
|
190
|
+
assertIdentifier(options.consequence.environment, 'proposal_environment');
|
|
191
|
+
assertIdentifier(options.consequence.executor_id, 'proposal_executor_id');
|
|
192
|
+
if (!secureConsequenceStore(options.consequence.store)
|
|
193
|
+
|| (options.consequence.create_attempt_id !== undefined
|
|
194
|
+
&& typeof options.consequence.create_attempt_id !== 'function')) {
|
|
195
|
+
throw new Error('proposal_consequence_store_required');
|
|
196
|
+
}
|
|
197
|
+
const now = options.now ?? Date.now;
|
|
198
|
+
const configDigest = pinnedConfigDigest(options.aeb.config);
|
|
199
|
+
const integrityKey = Buffer.from(options.proposal_integrity.hmac_sha256_key);
|
|
200
|
+
const consequenceContext = Object.freeze({
|
|
201
|
+
tenant_id: options.consequence.tenant_id,
|
|
202
|
+
provider_id: options.consequence.provider_id,
|
|
203
|
+
provider_account_id: options.consequence.provider_account_id,
|
|
204
|
+
environment: options.consequence.environment,
|
|
205
|
+
executor_id: options.consequence.executor_id,
|
|
206
|
+
});
|
|
207
|
+
// Active owner capabilities never become enumerable response/error data.
|
|
208
|
+
// The same-process service can recover a handle from the exact object; after
|
|
209
|
+
// restart it must use the durable store's separately authorized recovery API.
|
|
210
|
+
const reconciliationHandles = new WeakMap();
|
|
211
|
+
function rememberReconciliationHandle(target, reference) {
|
|
212
|
+
reconciliationHandles.set(target, reference);
|
|
213
|
+
return target;
|
|
214
|
+
}
|
|
215
|
+
function getReconciliationHandle(target) {
|
|
216
|
+
const reference = reconciliationHandles.get(target);
|
|
217
|
+
return reference ? clone(reference) : null;
|
|
218
|
+
}
|
|
219
|
+
function currentTime() {
|
|
220
|
+
const value = now();
|
|
221
|
+
if (!Number.isSafeInteger(value) || !Number.isFinite(new Date(value).getTime())) {
|
|
222
|
+
throw new Error('proposal_time_invalid');
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
async function reconcileAebWithRecovery(key, outcome, authorization) {
|
|
227
|
+
let result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
|
|
228
|
+
const recoveryStore = options.aeb.store;
|
|
229
|
+
if (result.state === 'RECONCILIATION_REQUIRED'
|
|
230
|
+
&& authorization !== undefined
|
|
231
|
+
&& typeof recoveryStore.claimReservation === 'function'
|
|
232
|
+
&& await recoveryStore.claimReservation(key, authorization).catch(() => false)) {
|
|
233
|
+
result = await reconcileAebExecutionDurable(options.aeb.store, key, outcome);
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
function requestDigestFor(proposal) {
|
|
238
|
+
return digestAeb({
|
|
239
|
+
domain: `${PROPOSAL_TO_EFFECT_VERSION}:REQUEST`,
|
|
240
|
+
...consequenceContext,
|
|
241
|
+
proposal_id: proposal.proposal_id,
|
|
242
|
+
operation_id: proposal.operation_id,
|
|
243
|
+
initiator_id: proposal.initiator_id,
|
|
244
|
+
profile_id: proposal.profile_id,
|
|
245
|
+
action: proposal.action,
|
|
246
|
+
action_digest: proposal.action_digest,
|
|
247
|
+
aeb_action_digest: proposal.aeb_action_digest,
|
|
248
|
+
caid: proposal.caid,
|
|
249
|
+
created_at: proposal.created_at,
|
|
250
|
+
expires_at: proposal.expires_at,
|
|
251
|
+
challenge: proposal.challenge,
|
|
252
|
+
authorization: proposal.authorization,
|
|
253
|
+
aeb: proposal.aeb,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function integrityMac(unsignedProposal) {
|
|
257
|
+
return crypto.createHmac('sha256', integrityKey)
|
|
258
|
+
.update(PROPOSAL_INTEGRITY_DOMAIN)
|
|
259
|
+
.update(digestAeb(unsignedProposal))
|
|
260
|
+
.digest('base64url');
|
|
261
|
+
}
|
|
262
|
+
function proposalIntegrityValid(proposal) {
|
|
263
|
+
if (!exactKeys(proposal.integrity, ['alg', 'value'])
|
|
264
|
+
|| proposal.integrity.alg !== 'HMAC-SHA256'
|
|
265
|
+
|| typeof proposal.integrity.value !== 'string'
|
|
266
|
+
|| !/^[A-Za-z0-9_-]{43}$/.test(proposal.integrity.value))
|
|
267
|
+
return false;
|
|
268
|
+
const unsigned = clone(proposal);
|
|
269
|
+
delete unsigned.integrity;
|
|
270
|
+
const actual = Buffer.from(proposal.integrity.value, 'base64url');
|
|
271
|
+
const expected = Buffer.from(integrityMac(unsigned), 'base64url');
|
|
272
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
273
|
+
}
|
|
274
|
+
function profileFor(id) {
|
|
275
|
+
if (typeof id !== 'string' || !options.profiles[id])
|
|
276
|
+
throw new Error('proposal_profile_not_pinned');
|
|
277
|
+
return options.profiles[id];
|
|
278
|
+
}
|
|
279
|
+
function prepare(input) {
|
|
280
|
+
assertIdentifier(input?.proposal_id, 'proposal_id');
|
|
281
|
+
assertIdentifier(input?.operation_id, 'proposal_operation_id');
|
|
282
|
+
assertIdentifier(input?.initiator_id, 'proposal_initiator_id');
|
|
283
|
+
const profile = profileFor(input?.profile_id);
|
|
284
|
+
const normalized = canonicalizeForProfile(profile, input.action);
|
|
285
|
+
const createdAtMs = currentTime();
|
|
286
|
+
const actionDigest = approvalActionHash(normalized.action);
|
|
287
|
+
const base = {
|
|
288
|
+
'@version': PROPOSAL_TO_EFFECT_VERSION,
|
|
289
|
+
proposal_id: input.proposal_id,
|
|
290
|
+
operation_id: input.operation_id,
|
|
291
|
+
initiator_id: input.initiator_id,
|
|
292
|
+
profile_id: profile.id,
|
|
293
|
+
action: normalized.action,
|
|
294
|
+
action_digest: actionDigest,
|
|
295
|
+
aeb_action_digest: digestAeb(normalized.action),
|
|
296
|
+
caid: normalized.caid,
|
|
297
|
+
created_at: new Date(createdAtMs).toISOString(),
|
|
298
|
+
expires_at: new Date(createdAtMs + profile.ttl_sec * 1000).toISOString(),
|
|
299
|
+
challenge: {
|
|
300
|
+
action: profile.action_type,
|
|
301
|
+
action_hash: actionDigest,
|
|
302
|
+
required_fields: [...profile.required_fields],
|
|
303
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
304
|
+
},
|
|
305
|
+
authorization: clone(profile.authorization),
|
|
306
|
+
aeb: {
|
|
307
|
+
requirement_ref: profile.aeb_requirement_ref,
|
|
308
|
+
pinned_config_digest: configDigest,
|
|
309
|
+
consumption_nonce: proposalToEffectConsumptionNonce(input.operation_id, configDigest),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
const unsigned = {
|
|
313
|
+
...base,
|
|
314
|
+
consequence: {
|
|
315
|
+
...consequenceContext,
|
|
316
|
+
request_digest: requestDigestFor(base),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
return clone({
|
|
320
|
+
...unsigned,
|
|
321
|
+
integrity: { alg: 'HMAC-SHA256', value: integrityMac(unsigned) },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
function verifyProposal(input, { allowExpired = false } = {}) {
|
|
325
|
+
if (!isPlainObject(input) || !exactProposalKeys(input)
|
|
326
|
+
|| input['@version'] !== PROPOSAL_TO_EFFECT_VERSION) {
|
|
327
|
+
throw new Error('proposal_shape_invalid');
|
|
328
|
+
}
|
|
329
|
+
if (!proposalIntegrityValid(input))
|
|
330
|
+
throw new Error('proposal_integrity_invalid');
|
|
331
|
+
const proposal = input;
|
|
332
|
+
assertIdentifier(proposal.proposal_id, 'proposal_id');
|
|
333
|
+
assertIdentifier(proposal.operation_id, 'proposal_operation_id');
|
|
334
|
+
assertIdentifier(proposal.initiator_id, 'proposal_initiator_id');
|
|
335
|
+
const profile = profileFor(proposal.profile_id);
|
|
336
|
+
const normalized = canonicalizeForProfile(profile, proposal.action);
|
|
337
|
+
if (normalized.caid !== proposal.caid)
|
|
338
|
+
throw new Error('proposal_caid_mismatch');
|
|
339
|
+
assertSameObject(normalized.action, proposal.action, 'proposal_action_not_canonical');
|
|
340
|
+
if (approvalActionHash(normalized.action) !== proposal.action_digest) {
|
|
341
|
+
throw new Error('proposal_action_digest_mismatch');
|
|
342
|
+
}
|
|
343
|
+
if (digestAeb(normalized.action) !== proposal.aeb_action_digest) {
|
|
344
|
+
throw new Error('proposal_aeb_action_digest_mismatch');
|
|
345
|
+
}
|
|
346
|
+
const createdAtMs = canonicalInstant(proposal.created_at);
|
|
347
|
+
const expiresAtMs = canonicalInstant(proposal.expires_at);
|
|
348
|
+
if (!Number.isFinite(createdAtMs) || !Number.isFinite(expiresAtMs))
|
|
349
|
+
throw new Error('proposal_time_invalid');
|
|
350
|
+
if (expiresAtMs - createdAtMs !== profile.ttl_sec * 1000)
|
|
351
|
+
throw new Error('proposal_ttl_mismatch');
|
|
352
|
+
const decisionTime = currentTime();
|
|
353
|
+
if (createdAtMs > decisionTime)
|
|
354
|
+
throw new Error('proposal_created_in_future');
|
|
355
|
+
if (!allowExpired && decisionTime >= expiresAtMs)
|
|
356
|
+
throw new Error('proposal_expired');
|
|
357
|
+
assertSameObject(proposal.authorization, profile.authorization, 'proposal_authorization_mismatch');
|
|
358
|
+
if (!isPlainObject(proposal.aeb)
|
|
359
|
+
|| Object.keys(proposal.aeb).sort().join(',') !== 'consumption_nonce,pinned_config_digest,requirement_ref'
|
|
360
|
+
|| proposal.aeb?.requirement_ref !== profile.aeb_requirement_ref
|
|
361
|
+
|| proposal.aeb?.pinned_config_digest !== configDigest) {
|
|
362
|
+
throw new Error('proposal_aeb_pin_mismatch');
|
|
363
|
+
}
|
|
364
|
+
if (proposal.aeb.consumption_nonce !== proposalToEffectConsumptionNonce(proposal.operation_id, configDigest)) {
|
|
365
|
+
throw new Error('proposal_aeb_nonce_mismatch');
|
|
366
|
+
}
|
|
367
|
+
if (!exactKeys(proposal.consequence, [
|
|
368
|
+
'tenant_id', 'provider_id', 'provider_account_id', 'environment', 'executor_id', 'request_digest',
|
|
369
|
+
]) || proposal.consequence.tenant_id !== consequenceContext.tenant_id
|
|
370
|
+
|| proposal.consequence.provider_id !== consequenceContext.provider_id
|
|
371
|
+
|| proposal.consequence.provider_account_id !== consequenceContext.provider_account_id
|
|
372
|
+
|| proposal.consequence.environment !== consequenceContext.environment
|
|
373
|
+
|| proposal.consequence.executor_id !== consequenceContext.executor_id
|
|
374
|
+
|| proposal.consequence.request_digest !== requestDigestFor(proposal)) {
|
|
375
|
+
throw new Error('proposal_consequence_binding_mismatch');
|
|
376
|
+
}
|
|
377
|
+
const expectedChallenge = {
|
|
378
|
+
action: profile.action_type,
|
|
379
|
+
action_hash: proposal.action_digest,
|
|
380
|
+
required_fields: [...profile.required_fields],
|
|
381
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
382
|
+
};
|
|
383
|
+
assertSameObject(proposal.challenge, expectedChallenge, 'proposal_challenge_mismatch');
|
|
384
|
+
return { proposal: clone(proposal), profile };
|
|
385
|
+
}
|
|
386
|
+
async function verifyEvaluation(proposal, evaluation) {
|
|
387
|
+
if (!isPlainObject(evaluation))
|
|
388
|
+
return { valid: false, reason: 'aeb_evaluation_missing', record: null };
|
|
389
|
+
const record = evaluation;
|
|
390
|
+
if (record.operation_id !== proposal.operation_id
|
|
391
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
392
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
393
|
+
|| record.executor_id !== proposal.consequence.executor_id
|
|
394
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
395
|
+
|| record.caid !== proposal.caid) {
|
|
396
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
|
|
397
|
+
}
|
|
398
|
+
if (record.verdict === 'SATISFIED'
|
|
399
|
+
&& record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
400
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
|
|
401
|
+
}
|
|
402
|
+
let artifacts;
|
|
403
|
+
try {
|
|
404
|
+
artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { valid: false, reason: 'aeb_artifact_resolution_failed', record };
|
|
408
|
+
}
|
|
409
|
+
const decisionNow = new Date(currentTime()).toISOString();
|
|
410
|
+
const currentStatuses = {};
|
|
411
|
+
for (const leg of record.legs ?? []) {
|
|
412
|
+
try {
|
|
413
|
+
const statusArtifact = await options.aeb.currentStatusResolver({
|
|
414
|
+
proposal: clone(proposal),
|
|
415
|
+
evaluation: clone(record),
|
|
416
|
+
leg: clone(leg),
|
|
417
|
+
});
|
|
418
|
+
if (statusArtifact === undefined || statusArtifact === null) {
|
|
419
|
+
return { valid: false, reason: 'aeb_current_status_refused', record };
|
|
420
|
+
}
|
|
421
|
+
const status = await options.aeb.statusVerifier({
|
|
422
|
+
status_artifact: clone(statusArtifact),
|
|
423
|
+
expected: {
|
|
424
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
425
|
+
executor_id: proposal.consequence.executor_id,
|
|
426
|
+
operation_id: proposal.operation_id,
|
|
427
|
+
caid: proposal.caid,
|
|
428
|
+
artifact_ref: leg.artifact_ref,
|
|
429
|
+
evidence_digest: leg.evidence_digest,
|
|
430
|
+
replay_unit: leg.replay_unit,
|
|
431
|
+
},
|
|
432
|
+
now: decisionNow,
|
|
433
|
+
});
|
|
434
|
+
if (!status?.valid || status.outcome !== 'current_not_revoked'
|
|
435
|
+
|| !normalizedCurrentStatus(status.status)
|
|
436
|
+
|| status.status.revocation_checked !== true || status.status.revoked !== false
|
|
437
|
+
|| status.status.consumed !== false || status.status.unavailable === true) {
|
|
438
|
+
return { valid: false, reason: 'aeb_current_status_refused', record };
|
|
439
|
+
}
|
|
440
|
+
currentStatuses[leg.artifact_ref] = clone(status.status);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return { valid: false, reason: 'aeb_current_status_refused', record };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (record.legs.length === 0 || Object.keys(currentStatuses).length !== record.legs.length) {
|
|
447
|
+
return { valid: false, reason: 'aeb_current_status_refused', record };
|
|
448
|
+
}
|
|
449
|
+
const verificationOptions = {
|
|
450
|
+
mode: 'execution',
|
|
451
|
+
config: options.aeb.config,
|
|
452
|
+
adapters: options.aeb.adapters,
|
|
453
|
+
artifacts,
|
|
454
|
+
expected_action: clone(proposal.action),
|
|
455
|
+
executor_id: proposal.consequence.executor_id,
|
|
456
|
+
current_statuses: currentStatuses,
|
|
457
|
+
now: decisionNow,
|
|
458
|
+
};
|
|
459
|
+
const checked = verifyAebEvaluation(record, verificationOptions);
|
|
460
|
+
if (!checked.valid || checked.execution_authorizing !== true
|
|
461
|
+
|| record.verdict !== 'SATISFIED'
|
|
462
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
463
|
+
return { valid: false, reason: 'aeb_evaluation_refused', record, checked };
|
|
464
|
+
}
|
|
465
|
+
return { valid: true, reason: null, record, checked };
|
|
466
|
+
}
|
|
467
|
+
function gateInput(proposal, profile, receipt, record) {
|
|
468
|
+
return {
|
|
469
|
+
selector: {
|
|
470
|
+
...clone(profile.selector),
|
|
471
|
+
operation_id: proposal.operation_id,
|
|
472
|
+
initiator_id: proposal.initiator_id,
|
|
473
|
+
aeb_requirement_ref: proposal.aeb.requirement_ref,
|
|
474
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
475
|
+
provider_id: proposal.consequence.provider_id,
|
|
476
|
+
provider_account_id: proposal.consequence.provider_account_id,
|
|
477
|
+
environment: proposal.consequence.environment,
|
|
478
|
+
executor_id: proposal.consequence.executor_id,
|
|
479
|
+
request_digest: proposal.consequence.request_digest,
|
|
480
|
+
},
|
|
481
|
+
receipt,
|
|
482
|
+
observedAction: clone(proposal.action),
|
|
483
|
+
admissibility: proposalAdmissibility(proposal, record),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
async function execute(input, effect) {
|
|
487
|
+
if (typeof effect !== 'function')
|
|
488
|
+
throw new Error('proposal_effect_required');
|
|
489
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
490
|
+
const evaluation = await verifyEvaluation(proposal, input?.evaluation);
|
|
491
|
+
if (!evaluation.valid || !evaluation.record) {
|
|
492
|
+
return refusal(evaluation.reason || 'aeb_evaluation_refused', { aeb: evaluation.checked ?? null });
|
|
493
|
+
}
|
|
494
|
+
if (!evaluation.checked?.valid || evaluation.checked.execution_authorizing !== true) {
|
|
495
|
+
return refusal('aeb_execution_verification_required', { aeb: evaluation.checked ?? null });
|
|
496
|
+
}
|
|
497
|
+
const preparedGateInput = gateInput(proposal, profile, input.receipt, evaluation.record);
|
|
498
|
+
const preflight = await options.gate.check({ ...preparedGateInput, consumptionMode: 'none' });
|
|
499
|
+
if (preflight.allow !== true) {
|
|
500
|
+
return refusal(preflight.reason || 'gate_refused', { authorization: preflight });
|
|
501
|
+
}
|
|
502
|
+
if (preflight.reason === 'not_guarded' || preflight.requirement?.receipt_required !== true) {
|
|
503
|
+
return refusal('gate_profile_not_receipt_guarded', { authorization: preflight });
|
|
504
|
+
}
|
|
505
|
+
const reservation = await authorizeAebExecutionDurable(evaluation.record, {
|
|
506
|
+
verification: evaluation.checked,
|
|
507
|
+
local_authorization: true,
|
|
508
|
+
store: options.aeb.store,
|
|
509
|
+
});
|
|
510
|
+
if (!reservation.invoke_allowed || !reservation.reservation_key) {
|
|
511
|
+
return refusal(reservation.reason === 'consumption_conflict' ? 'aeb_consumption_conflict' : reservation.reason, { aeb: reservation });
|
|
512
|
+
}
|
|
513
|
+
const key = reservation.reservation_key;
|
|
514
|
+
let attemptId;
|
|
515
|
+
try {
|
|
516
|
+
attemptId = options.consequence.create_attempt_id
|
|
517
|
+
? await options.consequence.create_attempt_id({
|
|
518
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
519
|
+
request_digest: proposal.consequence.request_digest,
|
|
520
|
+
})
|
|
521
|
+
: `attempt:${crypto.randomUUID()}`;
|
|
522
|
+
assertIdentifier(attemptId, 'proposal_attempt_id');
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
526
|
+
return refusal('consequence_attempt_allocation_failed');
|
|
527
|
+
}
|
|
528
|
+
const binding = {
|
|
529
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
530
|
+
provider_id: proposal.consequence.provider_id,
|
|
531
|
+
provider_account_id: proposal.consequence.provider_account_id,
|
|
532
|
+
environment: proposal.consequence.environment,
|
|
533
|
+
attempt_id: attemptId,
|
|
534
|
+
request_digest: proposal.consequence.request_digest,
|
|
535
|
+
};
|
|
536
|
+
let reservedAttempt;
|
|
537
|
+
try {
|
|
538
|
+
reservedAttempt = await options.consequence.store.reserve(clone(binding));
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
542
|
+
return refusal('consequence_attempt_store_unavailable');
|
|
543
|
+
}
|
|
544
|
+
if (!reservedAttempt?.reserved || typeof reservedAttempt.owner !== 'string'
|
|
545
|
+
|| reservedAttempt.owner.length < 1 || reservedAttempt.owner.length > 1024) {
|
|
546
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
547
|
+
const reason = reservedAttempt.reserved === false
|
|
548
|
+
? reservedAttempt.reason : 'consequence_attempt_conflict';
|
|
549
|
+
return refusal(reason);
|
|
550
|
+
}
|
|
551
|
+
const attempt = {
|
|
552
|
+
...binding,
|
|
553
|
+
owner: reservedAttempt.owner,
|
|
554
|
+
};
|
|
555
|
+
const attemptRef = {
|
|
556
|
+
tenant_id: attempt.tenant_id,
|
|
557
|
+
attempt_id: attempt.attempt_id,
|
|
558
|
+
owner: attempt.owner,
|
|
559
|
+
};
|
|
560
|
+
const attemptCustody = { state: 'RESERVED' };
|
|
561
|
+
const transition = async (change) => {
|
|
562
|
+
try {
|
|
563
|
+
const changed = await options.consequence.store.transition({
|
|
564
|
+
...attemptRef,
|
|
565
|
+
...change,
|
|
566
|
+
});
|
|
567
|
+
if (changed === true)
|
|
568
|
+
attemptCustody.state = change.next_state;
|
|
569
|
+
return changed === true;
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
if (!await transition({ expected_state: 'RESERVED', next_state: 'INVOKING' })) {
|
|
576
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
577
|
+
return refusal('consequence_attempt_transition_conflict');
|
|
578
|
+
}
|
|
579
|
+
let callbackEntered = false;
|
|
580
|
+
const freeze = async () => (attemptCustody.state === 'INDETERMINATE'
|
|
581
|
+
|| (attemptCustody.state === 'INVOKING'
|
|
582
|
+
&& await transition({ expected_state: 'INVOKING', next_state: 'INDETERMINATE' })));
|
|
583
|
+
const attachAttempt = (thrown, outcome) => {
|
|
584
|
+
const error = thrown && (typeof thrown === 'object' || typeof thrown === 'function')
|
|
585
|
+
? thrown : new Error(String(thrown));
|
|
586
|
+
error.proposalToEffect = {
|
|
587
|
+
...(isPlainObject(error.proposalToEffect) ? error.proposalToEffect : {}),
|
|
588
|
+
...(outcome ? { outcome } : {}),
|
|
589
|
+
reservation_key: key,
|
|
590
|
+
attempt: clone(binding),
|
|
591
|
+
attempt_state: attemptCustody.state,
|
|
592
|
+
};
|
|
593
|
+
return rememberReconciliationHandle(error, attemptRef);
|
|
594
|
+
};
|
|
595
|
+
try {
|
|
596
|
+
const result = await options.gate.run(preparedGateInput, async (authorization) => {
|
|
597
|
+
callbackEntered = true;
|
|
598
|
+
return effect({
|
|
599
|
+
action: clone(proposal.action),
|
|
600
|
+
proposal: clone(proposal),
|
|
601
|
+
authorization: clone(authorization),
|
|
602
|
+
attempt: clone(binding),
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
if (!await freeze()) {
|
|
606
|
+
throw attachAttempt(new Error('consequence_attempt_freeze_failed'), 'indeterminate');
|
|
607
|
+
}
|
|
608
|
+
if (result?.ok !== true) {
|
|
609
|
+
if (callbackEntered) {
|
|
610
|
+
return rememberReconciliationHandle(refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
|
|
611
|
+
authorization: result?.authorization ?? null,
|
|
612
|
+
consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
|
|
613
|
+
}), attemptRef);
|
|
614
|
+
}
|
|
615
|
+
if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
|
|
616
|
+
return rememberReconciliationHandle(refusal('consequence_attempt_transition_conflict', {
|
|
617
|
+
consequence: { state: 'INDETERMINATE', attempt: clone(binding) },
|
|
618
|
+
}), attemptRef);
|
|
619
|
+
}
|
|
620
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
621
|
+
return refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
|
|
622
|
+
authorization: result?.authorization ?? null,
|
|
623
|
+
consequence: { state: 'RELEASED', attempt: clone(binding) },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (!callbackEntered) {
|
|
627
|
+
if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
|
|
628
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
629
|
+
}
|
|
630
|
+
const response = refusal('gate_effect_not_invoked', {
|
|
631
|
+
consequence: { state: attemptCustody.state, attempt: clone(binding) },
|
|
632
|
+
});
|
|
633
|
+
return attemptCustody.state === 'INDETERMINATE'
|
|
634
|
+
? rememberReconciliationHandle(response, attemptRef) : response;
|
|
635
|
+
}
|
|
636
|
+
const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
|
|
637
|
+
if (committed.state !== 'CONSUMED') {
|
|
638
|
+
const error = new Error('aeb_consumption_commit_failed');
|
|
639
|
+
error.code = 'EMILIA_PROPOSAL_TO_EFFECT_COMMIT_FAILED';
|
|
640
|
+
throw attachAttempt(error, 'indeterminate');
|
|
641
|
+
}
|
|
642
|
+
if (!await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' })) {
|
|
643
|
+
throw attachAttempt(new Error('consequence_attempt_commit_failed'), 'executed');
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
...result,
|
|
647
|
+
proposal: clone(proposal),
|
|
648
|
+
aeb: committed,
|
|
649
|
+
consequence: { state: 'COMMITTED', attempt: clone(binding) },
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
const outcome = error?.proposalToEffect?.outcome ?? error?.emiliaGateOutcome?.outcome;
|
|
654
|
+
if (attemptCustody.state === 'INVOKING')
|
|
655
|
+
await freeze();
|
|
656
|
+
if (attemptCustody.state === 'INDETERMINATE' && outcome === 'executed') {
|
|
657
|
+
const committed = await reconcileAebWithRecovery(key, 'COMMITTED');
|
|
658
|
+
if (committed.state === 'CONSUMED') {
|
|
659
|
+
await transition({ expected_state: 'INDETERMINATE', next_state: 'COMMITTED' });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else if (attemptCustody.state === 'INDETERMINATE' && !callbackEntered && outcome !== 'indeterminate') {
|
|
663
|
+
if (await transition({ expected_state: 'INDETERMINATE', next_state: 'RELEASED' })) {
|
|
664
|
+
await options.aeb.store.release(key).catch(() => false);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
throw attachAttempt(error, callbackEntered ? (outcome || 'indeterminate') : outcome);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async function reconcile(input) {
|
|
671
|
+
const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
672
|
+
if (!isPlainObject(input?.evaluation))
|
|
673
|
+
return refusal('aeb_evaluation_missing');
|
|
674
|
+
const record = input.evaluation;
|
|
675
|
+
if (record.operation_id !== proposal.operation_id
|
|
676
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
677
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
678
|
+
|| record.executor_id !== proposal.consequence.executor_id
|
|
679
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
680
|
+
|| record.caid !== proposal.caid) {
|
|
681
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
682
|
+
}
|
|
683
|
+
if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
684
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
685
|
+
}
|
|
686
|
+
let artifacts;
|
|
687
|
+
try {
|
|
688
|
+
artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return refusal('aeb_artifact_resolution_failed');
|
|
692
|
+
}
|
|
693
|
+
const historicalOptions = {
|
|
694
|
+
mode: 'historical',
|
|
695
|
+
config: options.aeb.config,
|
|
696
|
+
adapters: options.aeb.adapters,
|
|
697
|
+
artifacts,
|
|
698
|
+
expected_action: clone(proposal.action),
|
|
699
|
+
executor_id: proposal.consequence.executor_id,
|
|
700
|
+
};
|
|
701
|
+
const historical = verifyAebEvaluation(record, historicalOptions);
|
|
702
|
+
if (!historical.valid)
|
|
703
|
+
return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
704
|
+
if (record.verdict !== 'SATISFIED'
|
|
705
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
706
|
+
return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
707
|
+
}
|
|
708
|
+
if (!isPlainObject(input?.attempt)
|
|
709
|
+
|| input.attempt.tenant_id !== proposal.consequence.tenant_id
|
|
710
|
+
|| typeof input.attempt.attempt_id !== 'string'
|
|
711
|
+
|| !IDENTIFIER_PATTERN.test(input.attempt.attempt_id)
|
|
712
|
+
|| typeof input.attempt.owner !== 'string'
|
|
713
|
+
|| input.attempt.owner.length < 1 || input.attempt.owner.length > 1024) {
|
|
714
|
+
return refusal('consequence_attempt_binding_mismatch');
|
|
715
|
+
}
|
|
716
|
+
const attemptRef = {
|
|
717
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
718
|
+
attempt_id: input.attempt.attempt_id,
|
|
719
|
+
owner: input.attempt.owner,
|
|
720
|
+
};
|
|
721
|
+
const publicAttempt = {
|
|
722
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
723
|
+
provider_id: proposal.consequence.provider_id,
|
|
724
|
+
provider_account_id: proposal.consequence.provider_account_id,
|
|
725
|
+
environment: proposal.consequence.environment,
|
|
726
|
+
attempt_id: attemptRef.attempt_id,
|
|
727
|
+
request_digest: proposal.consequence.request_digest,
|
|
728
|
+
};
|
|
729
|
+
let provider;
|
|
730
|
+
try {
|
|
731
|
+
provider = await options.aeb.verify_provider_evidence({
|
|
732
|
+
evidence: clone(input.provider_evidence),
|
|
733
|
+
expected: {
|
|
734
|
+
operation_id: proposal.operation_id,
|
|
735
|
+
caid: proposal.caid,
|
|
736
|
+
action_digest: proposal.aeb_action_digest,
|
|
737
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
738
|
+
request_digest: proposal.consequence.request_digest,
|
|
739
|
+
provider_id: proposal.consequence.provider_id,
|
|
740
|
+
provider_account_id: proposal.consequence.provider_account_id,
|
|
741
|
+
environment: proposal.consequence.environment,
|
|
742
|
+
attempt_id: attemptRef.attempt_id,
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return refusal('provider_evidence_unverified');
|
|
748
|
+
}
|
|
749
|
+
const observedAtMs = typeof provider?.observed_at === 'string'
|
|
750
|
+
? Date.parse(provider.observed_at) : NaN;
|
|
751
|
+
const proposalCreatedAtMs = Date.parse(proposal.created_at);
|
|
752
|
+
if (!provider?.valid || !['COMMITTED', 'NOT_COMMITTED', 'ESCALATED'].includes(provider.outcome ?? '')
|
|
753
|
+
|| typeof provider.evidence_id !== 'string' || !IDENTIFIER_PATTERN.test(provider.evidence_id)
|
|
754
|
+
|| !Number.isFinite(observedAtMs) || observedAtMs < proposalCreatedAtMs
|
|
755
|
+
|| observedAtMs > currentTime()
|
|
756
|
+
|| !validDigest(provider.evidence_digest)) {
|
|
757
|
+
return refusal(provider?.reason || 'provider_evidence_unverified');
|
|
758
|
+
}
|
|
759
|
+
if (provider.tenant_id !== proposal.consequence.tenant_id
|
|
760
|
+
|| provider.request_digest !== proposal.consequence.request_digest
|
|
761
|
+
|| provider.provider_id !== proposal.consequence.provider_id
|
|
762
|
+
|| provider.provider_account_id !== proposal.consequence.provider_account_id
|
|
763
|
+
|| provider.environment !== proposal.consequence.environment
|
|
764
|
+
|| provider.attempt_id !== attemptRef.attempt_id
|
|
765
|
+
|| provider.operation_id !== proposal.operation_id
|
|
766
|
+
|| provider.caid !== proposal.caid
|
|
767
|
+
|| provider.action_digest !== proposal.aeb_action_digest) {
|
|
768
|
+
return refusal('provider_evidence_binding_mismatch');
|
|
769
|
+
}
|
|
770
|
+
const providerOutcome = provider.outcome;
|
|
771
|
+
const evidence = {
|
|
772
|
+
operation_id: proposal.operation_id,
|
|
773
|
+
caid: proposal.caid,
|
|
774
|
+
action_digest: proposal.aeb_action_digest,
|
|
775
|
+
tenant_id: proposal.consequence.tenant_id,
|
|
776
|
+
provider_id: proposal.consequence.provider_id,
|
|
777
|
+
provider_account_id: proposal.consequence.provider_account_id,
|
|
778
|
+
environment: proposal.consequence.environment,
|
|
779
|
+
attempt_id: attemptRef.attempt_id,
|
|
780
|
+
request_digest: proposal.consequence.request_digest,
|
|
781
|
+
evidence_id: provider.evidence_id,
|
|
782
|
+
observed_at: provider.observed_at,
|
|
783
|
+
outcome: providerOutcome,
|
|
784
|
+
evidence_digest: provider.evidence_digest,
|
|
785
|
+
};
|
|
786
|
+
const terminalState = providerOutcome === 'COMMITTED'
|
|
787
|
+
? 'COMMITTED' : providerOutcome === 'NOT_COMMITTED' ? 'RELEASED' : 'ESCALATED';
|
|
788
|
+
const key = aebReservationKey(record);
|
|
789
|
+
// For a committed real-world effect, consume replay authority before the
|
|
790
|
+
// attempt becomes terminal. A failed AEB commit leaves the attempt
|
|
791
|
+
// INDETERMINATE and therefore repairable, never terminal split-brain.
|
|
792
|
+
const preTerminalAeb = providerOutcome === 'COMMITTED'
|
|
793
|
+
? await reconcileAebWithRecovery(key, 'COMMITTED', input.aeb_recovery_authorization)
|
|
794
|
+
: null;
|
|
795
|
+
if (providerOutcome === 'COMMITTED' && preTerminalAeb?.state !== 'CONSUMED') {
|
|
796
|
+
return refusal('aeb_consumption_reconciliation_failed', {
|
|
797
|
+
state: 'INDETERMINATE',
|
|
798
|
+
consequence: { state: 'INDETERMINATE', attempt: clone(publicAttempt) },
|
|
799
|
+
aeb: preTerminalAeb,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
let attemptReconciled = false;
|
|
803
|
+
try {
|
|
804
|
+
attemptReconciled = await options.consequence.store.reconcile({
|
|
805
|
+
...attemptRef,
|
|
806
|
+
expected_state: 'INDETERMINATE',
|
|
807
|
+
next_state: terminalState,
|
|
808
|
+
evidence: clone(evidence),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
return refusal('consequence_attempt_store_unavailable');
|
|
813
|
+
}
|
|
814
|
+
if (!attemptReconciled)
|
|
815
|
+
return refusal('consequence_attempt_not_indeterminate');
|
|
816
|
+
const reconciled = providerOutcome === 'ESCALATED'
|
|
817
|
+
? { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' }
|
|
818
|
+
: providerOutcome === 'COMMITTED'
|
|
819
|
+
? preTerminalAeb
|
|
820
|
+
: await reconcileAebWithRecovery(key, providerOutcome, input.aeb_recovery_authorization);
|
|
821
|
+
if (providerOutcome !== 'ESCALATED' && reconciled.state === 'RECONCILIATION_REQUIRED') {
|
|
822
|
+
return refusal('aeb_consumption_reconciliation_failed', {
|
|
823
|
+
state: terminalState,
|
|
824
|
+
consequence: { state: terminalState, attempt: clone(publicAttempt) },
|
|
825
|
+
aeb: reconciled,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
ok: true,
|
|
830
|
+
state: terminalState,
|
|
831
|
+
outcome: providerOutcome,
|
|
832
|
+
evidence_digest: provider.evidence_digest,
|
|
833
|
+
reservation_key: key,
|
|
834
|
+
consequence: { state: terminalState, attempt: clone(publicAttempt) },
|
|
835
|
+
aeb: reconciled,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Converge legacy or crash-window terminal consequence state with the AEB
|
|
840
|
+
* reservation. This never invokes an effect and trusts only the signed
|
|
841
|
+
* proposal/evaluation plus the durable consequence store's terminal state.
|
|
842
|
+
*/
|
|
843
|
+
async function repairAeb(input) {
|
|
844
|
+
const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
845
|
+
if (!isPlainObject(input?.evaluation))
|
|
846
|
+
return refusal('aeb_evaluation_missing');
|
|
847
|
+
const record = input.evaluation;
|
|
848
|
+
if (record.operation_id !== proposal.operation_id
|
|
849
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
850
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
851
|
+
|| record.executor_id !== proposal.consequence.executor_id
|
|
852
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
853
|
+
|| record.caid !== proposal.caid
|
|
854
|
+
|| record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
855
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
856
|
+
}
|
|
857
|
+
let artifacts;
|
|
858
|
+
try {
|
|
859
|
+
artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return refusal('aeb_artifact_resolution_failed');
|
|
863
|
+
}
|
|
864
|
+
const historicalOptions = {
|
|
865
|
+
mode: 'historical',
|
|
866
|
+
config: options.aeb.config,
|
|
867
|
+
adapters: options.aeb.adapters,
|
|
868
|
+
artifacts,
|
|
869
|
+
expected_action: clone(proposal.action),
|
|
870
|
+
executor_id: proposal.consequence.executor_id,
|
|
871
|
+
};
|
|
872
|
+
const historical = verifyAebEvaluation(record, historicalOptions);
|
|
873
|
+
if (!historical.valid || record.verdict !== 'SATISFIED'
|
|
874
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
875
|
+
return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
876
|
+
}
|
|
877
|
+
if (!exactKeys(input?.attempt, [
|
|
878
|
+
'tenant_id', 'provider_id', 'provider_account_id', 'environment',
|
|
879
|
+
'attempt_id', 'request_digest',
|
|
880
|
+
])) {
|
|
881
|
+
return refusal('consequence_attempt_binding_mismatch');
|
|
882
|
+
}
|
|
883
|
+
const attempt = input.attempt;
|
|
884
|
+
if (attempt.tenant_id !== proposal.consequence.tenant_id
|
|
885
|
+
|| attempt.provider_id !== proposal.consequence.provider_id
|
|
886
|
+
|| attempt.provider_account_id !== proposal.consequence.provider_account_id
|
|
887
|
+
|| attempt.environment !== proposal.consequence.environment
|
|
888
|
+
|| attempt.request_digest !== proposal.consequence.request_digest
|
|
889
|
+
|| typeof attempt.attempt_id !== 'string' || !IDENTIFIER_PATTERN.test(attempt.attempt_id)) {
|
|
890
|
+
return refusal('consequence_attempt_binding_mismatch');
|
|
891
|
+
}
|
|
892
|
+
let snapshot;
|
|
893
|
+
try {
|
|
894
|
+
snapshot = await options.consequence.store.read(clone(attempt));
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return refusal('consequence_attempt_store_unavailable');
|
|
898
|
+
}
|
|
899
|
+
if (!snapshot)
|
|
900
|
+
return refusal('consequence_attempt_not_found');
|
|
901
|
+
if (!['COMMITTED', 'RELEASED', 'ESCALATED'].includes(snapshot.state)) {
|
|
902
|
+
return refusal('consequence_attempt_not_terminal', { state: snapshot.state });
|
|
903
|
+
}
|
|
904
|
+
if (snapshot.state === 'ESCALATED') {
|
|
905
|
+
return {
|
|
906
|
+
ok: true,
|
|
907
|
+
state: 'ESCALATED',
|
|
908
|
+
consequence: { state: 'ESCALATED', attempt: clone(attempt) },
|
|
909
|
+
aeb: { state: 'RECONCILIATION_REQUIRED', retry_allowed: false, reason: 'execution_escalated' },
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const key = aebReservationKey(record);
|
|
913
|
+
const outcome = snapshot.state === 'COMMITTED' ? 'COMMITTED' : 'NOT_COMMITTED';
|
|
914
|
+
const repaired = await reconcileAebWithRecovery(key, outcome, input.aeb_recovery_authorization);
|
|
915
|
+
const expectedState = outcome === 'COMMITTED' ? 'CONSUMED' : 'AVAILABLE';
|
|
916
|
+
if (repaired.state !== expectedState) {
|
|
917
|
+
return refusal('aeb_consumption_repair_failed', {
|
|
918
|
+
state: snapshot.state,
|
|
919
|
+
consequence: { state: snapshot.state, attempt: clone(attempt) },
|
|
920
|
+
aeb: repaired,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
state: snapshot.state,
|
|
926
|
+
consequence: { state: snapshot.state, attempt: clone(attempt) },
|
|
927
|
+
reservation_key: key,
|
|
928
|
+
aeb: repaired,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
async function beginApproval(input) {
|
|
932
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
933
|
+
return beginReceiptApproval({
|
|
934
|
+
authorization: proposal.authorization,
|
|
935
|
+
trustedAuthorization: profile.authorization,
|
|
936
|
+
challenge: proposal.challenge,
|
|
937
|
+
action: proposal.action,
|
|
938
|
+
approver_id: input.approver_id,
|
|
939
|
+
idempotency_key: input.idempotency_key,
|
|
940
|
+
requesterAuthorization: input.requester_authorization,
|
|
941
|
+
fetchImpl: input.fetch_impl,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
async function pollApproval(input) {
|
|
945
|
+
const { proposal, profile } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
946
|
+
return pollReceiptApproval({
|
|
947
|
+
authorization: proposal.authorization,
|
|
948
|
+
trustedAuthorization: profile.authorization,
|
|
949
|
+
request_id: input.request_id,
|
|
950
|
+
poll_token: input.poll_token,
|
|
951
|
+
fetchImpl: input.fetch_impl,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return Object.freeze({
|
|
955
|
+
prepare,
|
|
956
|
+
verifyProposal,
|
|
957
|
+
beginApproval,
|
|
958
|
+
pollApproval,
|
|
959
|
+
execute,
|
|
960
|
+
reconcile,
|
|
961
|
+
repairAeb,
|
|
962
|
+
getReconciliationHandle,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
export default {
|
|
966
|
+
PROPOSAL_TO_EFFECT_VERSION,
|
|
967
|
+
proposalToEffectConsumptionNonce,
|
|
968
|
+
createProposalToEffect,
|
|
969
|
+
};
|
|
970
|
+
//# sourceMappingURL=proposal-to-effect.js.map
|