@emilia-protocol/gate 0.13.0 → 0.14.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 +22 -0
- package/README.md +58 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect.d.ts +146 -0
- package/dist/proposal-to-effect.d.ts.map +1 -0
- package/dist/proposal-to-effect.js +427 -0
- package/dist/proposal-to-effect.js.map +1 -0
- package/package.json +5 -3
- package/proposal-to-effect.js +4 -0
- package/src/index.ts +5 -0
- package/src/proposal-to-effect.ts +571 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Proposal-to-Effect is a product orchestration profile over existing EMILIA
|
|
4
|
+
* artifacts. A proposal is deliberately NOT a bearer authorization object.
|
|
5
|
+
* Authority remains in EP-RECEIPT-v1 and the relying party's pinned AEB
|
|
6
|
+
* requirement; consequence custody remains in Gate and its durable stores.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
beginReceiptApproval,
|
|
11
|
+
pollReceiptApproval,
|
|
12
|
+
approvalActionHash,
|
|
13
|
+
EP_APPROVAL_FLOW,
|
|
14
|
+
validateApprovalAuthorization,
|
|
15
|
+
validateCaidSelector,
|
|
16
|
+
validateRequiredFields,
|
|
17
|
+
} from '@emilia-protocol/require-receipt/acquisition';
|
|
18
|
+
import {
|
|
19
|
+
aebReservationKey,
|
|
20
|
+
authorizeAebExecutionDurable,
|
|
21
|
+
digestAeb,
|
|
22
|
+
pinnedConfigDigest,
|
|
23
|
+
reconcileAebExecutionDurable,
|
|
24
|
+
verifyAebEvaluation,
|
|
25
|
+
type AebAdapter,
|
|
26
|
+
type AebDigest,
|
|
27
|
+
type AebDurableConsumptionStore,
|
|
28
|
+
type AebEvaluationRecord,
|
|
29
|
+
type AebPinnedConfig,
|
|
30
|
+
} from '@emilia-protocol/verify/aeb-adapter-contract';
|
|
31
|
+
import { actionDigest as aecActionDigest } from '@emilia-protocol/verify/evidence-chain';
|
|
32
|
+
|
|
33
|
+
export const PROPOSAL_TO_EFFECT_VERSION = 'EMILIA-PROPOSAL-TO-EFFECT-v1';
|
|
34
|
+
|
|
35
|
+
type JsonObject = Record<string, any>;
|
|
36
|
+
type FetchLike = typeof fetch;
|
|
37
|
+
|
|
38
|
+
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}$/;
|
|
39
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$/;
|
|
40
|
+
|
|
41
|
+
export interface ProposalToEffectProfile {
|
|
42
|
+
id: string;
|
|
43
|
+
action_type: string;
|
|
44
|
+
selector: JsonObject;
|
|
45
|
+
required_fields: readonly string[];
|
|
46
|
+
authorization: {
|
|
47
|
+
authorization_endpoint: string;
|
|
48
|
+
flow: typeof EP_APPROVAL_FLOW;
|
|
49
|
+
};
|
|
50
|
+
aeb_requirement_ref: string;
|
|
51
|
+
ttl_sec: number;
|
|
52
|
+
/**
|
|
53
|
+
* Relying-party-controlled canonicalization and CAID derivation. It runs on
|
|
54
|
+
* both proposal creation and execution. Never select it from presented data.
|
|
55
|
+
*/
|
|
56
|
+
canonicalize_action(input: unknown): { action: JsonObject; caid: string };
|
|
57
|
+
caid_selector?: { field: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ProposalToEffectProposal {
|
|
61
|
+
'@version': typeof PROPOSAL_TO_EFFECT_VERSION;
|
|
62
|
+
proposal_id: string;
|
|
63
|
+
operation_id: string;
|
|
64
|
+
initiator_id: string;
|
|
65
|
+
profile_id: string;
|
|
66
|
+
action: JsonObject;
|
|
67
|
+
action_digest: string;
|
|
68
|
+
aeb_action_digest: AebDigest;
|
|
69
|
+
caid: string;
|
|
70
|
+
created_at: string;
|
|
71
|
+
expires_at: string;
|
|
72
|
+
challenge: {
|
|
73
|
+
action: string;
|
|
74
|
+
action_hash: string;
|
|
75
|
+
required_fields: string[];
|
|
76
|
+
caid_selector?: { field: string };
|
|
77
|
+
};
|
|
78
|
+
authorization: {
|
|
79
|
+
authorization_endpoint: string;
|
|
80
|
+
flow: typeof EP_APPROVAL_FLOW;
|
|
81
|
+
};
|
|
82
|
+
aeb: {
|
|
83
|
+
requirement_ref: string;
|
|
84
|
+
pinned_config_digest: AebDigest;
|
|
85
|
+
consumption_nonce: AebDigest;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ProposalToEffectGate {
|
|
90
|
+
check(input: JsonObject): Promise<JsonObject>;
|
|
91
|
+
run(input: JsonObject, effect: (authorization: JsonObject) => unknown | Promise<unknown>): Promise<JsonObject>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ProposalToEffectProviderVerification {
|
|
95
|
+
valid: boolean;
|
|
96
|
+
outcome?: 'COMMITTED' | 'NOT_COMMITTED';
|
|
97
|
+
evidence_digest?: AebDigest | null;
|
|
98
|
+
reason?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ProposalToEffectOptions {
|
|
102
|
+
gate: ProposalToEffectGate;
|
|
103
|
+
profiles: Record<string, ProposalToEffectProfile>;
|
|
104
|
+
aeb: {
|
|
105
|
+
config: AebPinnedConfig;
|
|
106
|
+
adapters: Record<string, AebAdapter>;
|
|
107
|
+
store: AebDurableConsumptionStore;
|
|
108
|
+
resolve_artifacts(input: {
|
|
109
|
+
proposal: ProposalToEffectProposal;
|
|
110
|
+
evaluation: AebEvaluationRecord;
|
|
111
|
+
}): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
112
|
+
verify_provider_evidence(input: {
|
|
113
|
+
evidence: unknown;
|
|
114
|
+
expected: {
|
|
115
|
+
operation_id: string;
|
|
116
|
+
caid: string;
|
|
117
|
+
action_digest: AebDigest;
|
|
118
|
+
};
|
|
119
|
+
}): Promise<ProposalToEffectProviderVerification> | ProposalToEffectProviderVerification;
|
|
120
|
+
};
|
|
121
|
+
now?: () => number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPlainObject(value: unknown): value is JsonObject {
|
|
125
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
126
|
+
const prototype = Object.getPrototypeOf(value);
|
|
127
|
+
return prototype === Object.prototype || prototype === null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clone<T>(value: T): T {
|
|
131
|
+
return structuredClone(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertIdentifier(value: unknown, name: string): asserts value is string {
|
|
135
|
+
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
|
136
|
+
throw new Error(`${name}_invalid`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertProfile(profile: ProposalToEffectProfile): void {
|
|
141
|
+
if (!isPlainObject(profile)) throw new Error('proposal_profile_invalid');
|
|
142
|
+
assertIdentifier(profile.id, 'proposal_profile_id');
|
|
143
|
+
assertIdentifier(profile.action_type, 'proposal_action_type');
|
|
144
|
+
assertIdentifier(profile.aeb_requirement_ref, 'proposal_aeb_requirement_ref');
|
|
145
|
+
if (!isPlainObject(profile.selector) || Object.keys(profile.selector).length === 0) {
|
|
146
|
+
throw new Error('proposal_selector_invalid');
|
|
147
|
+
}
|
|
148
|
+
const requiredFields = validateRequiredFields(profile.required_fields);
|
|
149
|
+
if (!requiredFields.ok) throw new Error(requiredFields.reason);
|
|
150
|
+
if (!Number.isSafeInteger(profile.ttl_sec) || profile.ttl_sec <= 0 || profile.ttl_sec > 86_400) {
|
|
151
|
+
throw new Error('proposal_ttl_invalid');
|
|
152
|
+
}
|
|
153
|
+
if (typeof profile.canonicalize_action !== 'function') {
|
|
154
|
+
throw new Error('proposal_canonicalizer_required');
|
|
155
|
+
}
|
|
156
|
+
const authorization = validateApprovalAuthorization(profile.authorization);
|
|
157
|
+
if (!authorization.ok) throw new Error(authorization.reason);
|
|
158
|
+
if (profile.caid_selector) {
|
|
159
|
+
const caidSelector = validateCaidSelector(profile.caid_selector);
|
|
160
|
+
if (!caidSelector.ok) throw new Error(caidSelector.reason);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function canonicalizeForProfile(profile: ProposalToEffectProfile, input: unknown): { action: JsonObject; caid: string } {
|
|
165
|
+
const normalized = profile.canonicalize_action(clone(input));
|
|
166
|
+
if (!isPlainObject(normalized) || !isPlainObject(normalized.action)
|
|
167
|
+
|| typeof normalized.caid !== 'string' || !CAID_PATTERN.test(normalized.caid)) {
|
|
168
|
+
throw new Error('proposal_canonicalization_invalid');
|
|
169
|
+
}
|
|
170
|
+
if (normalized.action.action_type !== profile.action_type) {
|
|
171
|
+
throw new Error('proposal_action_type_mismatch');
|
|
172
|
+
}
|
|
173
|
+
for (const field of profile.required_fields) {
|
|
174
|
+
if (!Object.hasOwn(normalized.action, field) || normalized.action[field] === undefined) {
|
|
175
|
+
throw new Error(`proposal_required_field_missing:${field}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (profile.caid_selector) {
|
|
179
|
+
const field = profile.caid_selector.field;
|
|
180
|
+
if (typeof field !== 'string' || normalized.action[field] !== normalized.caid) {
|
|
181
|
+
throw new Error(`proposal_caid_binding_invalid:${field}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Both digest functions reject unsupported/non-canonical JSON values.
|
|
185
|
+
approvalActionHash(normalized.action);
|
|
186
|
+
digestAeb(normalized.action);
|
|
187
|
+
return { action: clone(normalized.action), caid: normalized.caid };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function assertSameObject(left: unknown, right: unknown, reason: string): void {
|
|
191
|
+
if (digestAeb(left) !== digestAeb(right)) throw new Error(reason);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function exactProposalKeys(proposal: JsonObject): boolean {
|
|
195
|
+
const expected = [
|
|
196
|
+
'@version', 'action', 'action_digest', 'aeb', 'aeb_action_digest', 'authorization',
|
|
197
|
+
'caid', 'challenge', 'created_at', 'expires_at', 'initiator_id', 'operation_id',
|
|
198
|
+
'profile_id', 'proposal_id',
|
|
199
|
+
].sort();
|
|
200
|
+
const actual = Object.keys(proposal).sort();
|
|
201
|
+
return expected.length === actual.length && expected.every((key, index) => key === actual[index]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function proposalAdmissibility(proposal: ProposalToEffectProposal, record: AebEvaluationRecord): JsonObject {
|
|
205
|
+
return {
|
|
206
|
+
admissibility_profile: { id: `aeb:${proposal.aeb.requirement_ref}`, version: '1' },
|
|
207
|
+
profile_hash: proposal.aeb.pinned_config_digest,
|
|
208
|
+
verdict: 'admissible',
|
|
209
|
+
replay_digest: record.evidence_digest,
|
|
210
|
+
challenge_id: proposal.proposal_id,
|
|
211
|
+
aeb_evaluation_digest: digestAeb(record),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function refusal(reason: string, extra: JsonObject = {}): JsonObject {
|
|
216
|
+
return { ok: false, reason, ...extra };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function proposalToEffectConsumptionNonce(
|
|
220
|
+
operationId: string,
|
|
221
|
+
pinnedConfigDigest: AebDigest,
|
|
222
|
+
): AebDigest {
|
|
223
|
+
assertIdentifier(operationId, 'proposal_operation_id');
|
|
224
|
+
if (typeof pinnedConfigDigest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(pinnedConfigDigest)) {
|
|
225
|
+
throw new Error('proposal_aeb_pin_invalid');
|
|
226
|
+
}
|
|
227
|
+
return digestAeb({
|
|
228
|
+
domain: PROPOSAL_TO_EFFECT_VERSION,
|
|
229
|
+
operation_id: operationId,
|
|
230
|
+
pinned_config_digest: pinnedConfigDigest,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function expectedAebCompositionDigest(proposal: ProposalToEffectProposal): AebDigest {
|
|
235
|
+
return `sha256:${aecActionDigest({
|
|
236
|
+
caid: proposal.caid,
|
|
237
|
+
normalized_action_digest: proposal.aeb_action_digest,
|
|
238
|
+
})}` as AebDigest;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createProposalToEffect(options: ProposalToEffectOptions) {
|
|
242
|
+
if (!options?.gate || typeof options.gate.check !== 'function' || typeof options.gate.run !== 'function') {
|
|
243
|
+
throw new Error('proposal_gate_required');
|
|
244
|
+
}
|
|
245
|
+
if (!isPlainObject(options.profiles) || Object.keys(options.profiles).length === 0) {
|
|
246
|
+
throw new Error('proposal_profiles_required');
|
|
247
|
+
}
|
|
248
|
+
for (const [id, profile] of Object.entries(options.profiles)) {
|
|
249
|
+
assertProfile(profile);
|
|
250
|
+
if (id !== profile.id) throw new Error('proposal_profile_registry_mismatch');
|
|
251
|
+
}
|
|
252
|
+
if (!options.aeb?.config || !options.aeb.adapters || !options.aeb.store
|
|
253
|
+
|| typeof options.aeb.resolve_artifacts !== 'function'
|
|
254
|
+
|| typeof options.aeb.verify_provider_evidence !== 'function') {
|
|
255
|
+
throw new Error('proposal_aeb_configuration_required');
|
|
256
|
+
}
|
|
257
|
+
const now = options.now ?? Date.now;
|
|
258
|
+
const configDigest = pinnedConfigDigest(options.aeb.config);
|
|
259
|
+
|
|
260
|
+
function profileFor(id: unknown): ProposalToEffectProfile {
|
|
261
|
+
if (typeof id !== 'string' || !options.profiles[id]) throw new Error('proposal_profile_not_pinned');
|
|
262
|
+
return options.profiles[id];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function prepare(input: {
|
|
266
|
+
proposal_id: string;
|
|
267
|
+
profile_id: string;
|
|
268
|
+
operation_id: string;
|
|
269
|
+
initiator_id: string;
|
|
270
|
+
action: unknown;
|
|
271
|
+
}): ProposalToEffectProposal {
|
|
272
|
+
assertIdentifier(input?.proposal_id, 'proposal_id');
|
|
273
|
+
assertIdentifier(input?.operation_id, 'proposal_operation_id');
|
|
274
|
+
assertIdentifier(input?.initiator_id, 'proposal_initiator_id');
|
|
275
|
+
const profile = profileFor(input?.profile_id);
|
|
276
|
+
const normalized = canonicalizeForProfile(profile, input.action);
|
|
277
|
+
const createdAtMs = now();
|
|
278
|
+
if (!Number.isFinite(createdAtMs)) throw new Error('proposal_time_invalid');
|
|
279
|
+
const actionDigest = approvalActionHash(normalized.action);
|
|
280
|
+
return clone({
|
|
281
|
+
'@version': PROPOSAL_TO_EFFECT_VERSION,
|
|
282
|
+
proposal_id: input.proposal_id,
|
|
283
|
+
operation_id: input.operation_id,
|
|
284
|
+
initiator_id: input.initiator_id,
|
|
285
|
+
profile_id: profile.id,
|
|
286
|
+
action: normalized.action,
|
|
287
|
+
action_digest: actionDigest,
|
|
288
|
+
aeb_action_digest: digestAeb(normalized.action),
|
|
289
|
+
caid: normalized.caid,
|
|
290
|
+
created_at: new Date(createdAtMs).toISOString(),
|
|
291
|
+
expires_at: new Date(createdAtMs + profile.ttl_sec * 1000).toISOString(),
|
|
292
|
+
challenge: {
|
|
293
|
+
action: profile.action_type,
|
|
294
|
+
action_hash: actionDigest,
|
|
295
|
+
required_fields: [...profile.required_fields],
|
|
296
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
297
|
+
},
|
|
298
|
+
authorization: clone(profile.authorization),
|
|
299
|
+
aeb: {
|
|
300
|
+
requirement_ref: profile.aeb_requirement_ref,
|
|
301
|
+
pinned_config_digest: configDigest,
|
|
302
|
+
consumption_nonce: proposalToEffectConsumptionNonce(input.operation_id, configDigest),
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function verifyProposal(input: unknown, { allowExpired = false }: { allowExpired?: boolean } = {}): {
|
|
308
|
+
proposal: ProposalToEffectProposal;
|
|
309
|
+
profile: ProposalToEffectProfile;
|
|
310
|
+
} {
|
|
311
|
+
if (!isPlainObject(input) || !exactProposalKeys(input)
|
|
312
|
+
|| input['@version'] !== PROPOSAL_TO_EFFECT_VERSION) {
|
|
313
|
+
throw new Error('proposal_shape_invalid');
|
|
314
|
+
}
|
|
315
|
+
const proposal = input as ProposalToEffectProposal;
|
|
316
|
+
assertIdentifier(proposal.proposal_id, 'proposal_id');
|
|
317
|
+
assertIdentifier(proposal.operation_id, 'proposal_operation_id');
|
|
318
|
+
assertIdentifier(proposal.initiator_id, 'proposal_initiator_id');
|
|
319
|
+
const profile = profileFor(proposal.profile_id);
|
|
320
|
+
const normalized = canonicalizeForProfile(profile, proposal.action);
|
|
321
|
+
if (normalized.caid !== proposal.caid) throw new Error('proposal_caid_mismatch');
|
|
322
|
+
assertSameObject(normalized.action, proposal.action, 'proposal_action_not_canonical');
|
|
323
|
+
if (approvalActionHash(normalized.action) !== proposal.action_digest) {
|
|
324
|
+
throw new Error('proposal_action_digest_mismatch');
|
|
325
|
+
}
|
|
326
|
+
if (digestAeb(normalized.action) !== proposal.aeb_action_digest) {
|
|
327
|
+
throw new Error('proposal_aeb_action_digest_mismatch');
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isFinite(Date.parse(proposal.created_at)) || !Number.isFinite(Date.parse(proposal.expires_at))
|
|
330
|
+
|| Date.parse(proposal.expires_at) <= Date.parse(proposal.created_at)) {
|
|
331
|
+
throw new Error('proposal_time_invalid');
|
|
332
|
+
}
|
|
333
|
+
if (!allowExpired && now() >= Date.parse(proposal.expires_at)) throw new Error('proposal_expired');
|
|
334
|
+
assertSameObject(proposal.authorization, profile.authorization, 'proposal_authorization_mismatch');
|
|
335
|
+
if (!isPlainObject(proposal.aeb)
|
|
336
|
+
|| Object.keys(proposal.aeb).sort().join(',') !== 'consumption_nonce,pinned_config_digest,requirement_ref'
|
|
337
|
+
|| proposal.aeb?.requirement_ref !== profile.aeb_requirement_ref
|
|
338
|
+
|| proposal.aeb?.pinned_config_digest !== configDigest) {
|
|
339
|
+
throw new Error('proposal_aeb_pin_mismatch');
|
|
340
|
+
}
|
|
341
|
+
if (proposal.aeb.consumption_nonce !== proposalToEffectConsumptionNonce(proposal.operation_id, configDigest)) {
|
|
342
|
+
throw new Error('proposal_aeb_nonce_mismatch');
|
|
343
|
+
}
|
|
344
|
+
const expectedChallenge = {
|
|
345
|
+
action: profile.action_type,
|
|
346
|
+
action_hash: proposal.action_digest,
|
|
347
|
+
required_fields: [...profile.required_fields],
|
|
348
|
+
...(profile.caid_selector ? { caid_selector: clone(profile.caid_selector) } : {}),
|
|
349
|
+
};
|
|
350
|
+
assertSameObject(proposal.challenge, expectedChallenge, 'proposal_challenge_mismatch');
|
|
351
|
+
return { proposal: clone(proposal), profile };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function verifyEvaluation(proposal: ProposalToEffectProposal, evaluation: unknown) {
|
|
355
|
+
if (!isPlainObject(evaluation)) return { valid: false, reason: 'aeb_evaluation_missing', record: null };
|
|
356
|
+
const record = evaluation as unknown as AebEvaluationRecord;
|
|
357
|
+
if (record.operation_id !== proposal.operation_id
|
|
358
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
359
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
360
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
361
|
+
|| record.caid !== proposal.caid) {
|
|
362
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record };
|
|
363
|
+
}
|
|
364
|
+
let artifacts: Record<string, unknown>;
|
|
365
|
+
try {
|
|
366
|
+
artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
367
|
+
} catch {
|
|
368
|
+
return { valid: false, reason: 'aeb_artifact_resolution_failed', record };
|
|
369
|
+
}
|
|
370
|
+
const checked = verifyAebEvaluation(record, {
|
|
371
|
+
config: options.aeb.config,
|
|
372
|
+
adapters: options.aeb.adapters,
|
|
373
|
+
artifacts,
|
|
374
|
+
now: new Date(now()).toISOString(),
|
|
375
|
+
});
|
|
376
|
+
if (!checked.valid || record.verdict !== 'SATISFIED'
|
|
377
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
378
|
+
return { valid: false, reason: 'aeb_evaluation_refused', record, checked };
|
|
379
|
+
}
|
|
380
|
+
if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
381
|
+
return { valid: false, reason: 'aeb_evaluation_binding_mismatch', record, checked };
|
|
382
|
+
}
|
|
383
|
+
return { valid: true, reason: null, record, checked };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function gateInput(proposal: ProposalToEffectProposal, profile: ProposalToEffectProfile, receipt: unknown, record: AebEvaluationRecord) {
|
|
387
|
+
return {
|
|
388
|
+
selector: {
|
|
389
|
+
...clone(profile.selector),
|
|
390
|
+
operation_id: proposal.operation_id,
|
|
391
|
+
initiator_id: proposal.initiator_id,
|
|
392
|
+
aeb_requirement_ref: proposal.aeb.requirement_ref,
|
|
393
|
+
},
|
|
394
|
+
receipt,
|
|
395
|
+
observedAction: clone(proposal.action),
|
|
396
|
+
admissibility: proposalAdmissibility(proposal, record),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function execute(
|
|
401
|
+
input: { proposal: unknown; receipt: unknown; evaluation: unknown },
|
|
402
|
+
effect: (input: { action: JsonObject; proposal: ProposalToEffectProposal; authorization: JsonObject }) => unknown | Promise<unknown>,
|
|
403
|
+
): Promise<JsonObject> {
|
|
404
|
+
if (typeof effect !== 'function') throw new Error('proposal_effect_required');
|
|
405
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
406
|
+
const evaluation = await verifyEvaluation(proposal, input?.evaluation);
|
|
407
|
+
if (!evaluation.valid || !evaluation.record) {
|
|
408
|
+
return refusal(evaluation.reason || 'aeb_evaluation_refused', { aeb: evaluation.checked ?? null });
|
|
409
|
+
}
|
|
410
|
+
const preparedGateInput = gateInput(proposal, profile, input.receipt, evaluation.record);
|
|
411
|
+
const preflight = await options.gate.check({ ...preparedGateInput, consumptionMode: 'none' });
|
|
412
|
+
if (preflight.allow !== true) {
|
|
413
|
+
return refusal(preflight.reason || 'gate_refused', { authorization: preflight });
|
|
414
|
+
}
|
|
415
|
+
if (preflight.reason === 'not_guarded' || preflight.requirement?.receipt_required !== true) {
|
|
416
|
+
return refusal('gate_profile_not_receipt_guarded', { authorization: preflight });
|
|
417
|
+
}
|
|
418
|
+
const reservation = await authorizeAebExecutionDurable(evaluation.record, {
|
|
419
|
+
verified: true,
|
|
420
|
+
local_authorization: true,
|
|
421
|
+
store: options.aeb.store,
|
|
422
|
+
});
|
|
423
|
+
if (!reservation.invoke_allowed || !reservation.reservation_key) {
|
|
424
|
+
return refusal(
|
|
425
|
+
reservation.reason === 'consumption_conflict' ? 'aeb_consumption_conflict' : reservation.reason,
|
|
426
|
+
{ aeb: reservation },
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const key = reservation.reservation_key;
|
|
430
|
+
try {
|
|
431
|
+
const result = await options.gate.run(preparedGateInput, async (authorization) => effect({
|
|
432
|
+
action: clone(proposal.action),
|
|
433
|
+
proposal: clone(proposal),
|
|
434
|
+
authorization: clone(authorization),
|
|
435
|
+
}));
|
|
436
|
+
if (result?.ok !== true) {
|
|
437
|
+
await options.aeb.store.release(key);
|
|
438
|
+
return refusal(result?.authorization?.reason || result?.reason || 'gate_refused', {
|
|
439
|
+
authorization: result?.authorization ?? null,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const committed = await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
|
|
443
|
+
if (committed.state !== 'CONSUMED') {
|
|
444
|
+
const error: any = new Error('aeb_consumption_commit_failed');
|
|
445
|
+
error.code = 'EMILIA_PROPOSAL_TO_EFFECT_COMMIT_FAILED';
|
|
446
|
+
error.proposalToEffect = { outcome: 'executed', reservation_key: key };
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
return { ...result, proposal: clone(proposal), aeb: committed };
|
|
450
|
+
} catch (error: any) {
|
|
451
|
+
const outcome = error?.proposalToEffect?.outcome ?? error?.emiliaGateOutcome?.outcome;
|
|
452
|
+
if (outcome === 'executed') {
|
|
453
|
+
await reconcileAebExecutionDurable(options.aeb.store, key, 'COMMITTED');
|
|
454
|
+
} else if (outcome !== 'indeterminate') {
|
|
455
|
+
await options.aeb.store.release(key);
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function reconcile(input: {
|
|
462
|
+
proposal: unknown;
|
|
463
|
+
evaluation: unknown;
|
|
464
|
+
provider_evidence: unknown;
|
|
465
|
+
}): Promise<JsonObject> {
|
|
466
|
+
const { proposal } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
467
|
+
if (!isPlainObject(input?.evaluation)) return refusal('aeb_evaluation_missing');
|
|
468
|
+
const record = input.evaluation as unknown as AebEvaluationRecord;
|
|
469
|
+
if (record.operation_id !== proposal.operation_id
|
|
470
|
+
|| record.consumption_nonce !== proposal.aeb.consumption_nonce
|
|
471
|
+
|| record.initiator_id !== proposal.initiator_id
|
|
472
|
+
|| record.requirement_ref !== proposal.aeb.requirement_ref
|
|
473
|
+
|| record.caid !== proposal.caid) {
|
|
474
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
475
|
+
}
|
|
476
|
+
const artifacts = await options.aeb.resolve_artifacts({ proposal: clone(proposal), evaluation: clone(record) });
|
|
477
|
+
const historical = verifyAebEvaluation(record, {
|
|
478
|
+
config: options.aeb.config,
|
|
479
|
+
adapters: options.aeb.adapters,
|
|
480
|
+
artifacts,
|
|
481
|
+
now: record.evaluated_at,
|
|
482
|
+
});
|
|
483
|
+
if (!historical.valid) return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
484
|
+
if (record.verdict !== 'SATISFIED'
|
|
485
|
+
|| record.authority_constraints?.one_time_consumption !== true) {
|
|
486
|
+
return refusal('aeb_evaluation_refused', { aeb: historical });
|
|
487
|
+
}
|
|
488
|
+
if (record.composition?.action_digest !== expectedAebCompositionDigest(proposal)) {
|
|
489
|
+
return refusal('aeb_evaluation_binding_mismatch');
|
|
490
|
+
}
|
|
491
|
+
let provider: ProposalToEffectProviderVerification;
|
|
492
|
+
try {
|
|
493
|
+
provider = await options.aeb.verify_provider_evidence({
|
|
494
|
+
evidence: clone(input.provider_evidence),
|
|
495
|
+
expected: {
|
|
496
|
+
operation_id: proposal.operation_id,
|
|
497
|
+
caid: proposal.caid,
|
|
498
|
+
action_digest: proposal.aeb_action_digest,
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
} catch {
|
|
502
|
+
return refusal('provider_evidence_unverified');
|
|
503
|
+
}
|
|
504
|
+
if (!provider?.valid || (provider.outcome !== 'COMMITTED' && provider.outcome !== 'NOT_COMMITTED')) {
|
|
505
|
+
return refusal(provider?.reason || 'provider_evidence_unverified');
|
|
506
|
+
}
|
|
507
|
+
const key = aebReservationKey(record);
|
|
508
|
+
const reconciled = await reconcileAebExecutionDurable(options.aeb.store, key, provider.outcome);
|
|
509
|
+
if (reconciled.state === 'RECONCILIATION_REQUIRED') {
|
|
510
|
+
return refusal(reconciled.reason, { state: reconciled.state });
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
state: reconciled.state,
|
|
515
|
+
outcome: provider.outcome,
|
|
516
|
+
evidence_digest: provider.evidence_digest ?? null,
|
|
517
|
+
reservation_key: key,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function beginApproval(input: {
|
|
522
|
+
proposal: unknown;
|
|
523
|
+
approver_id: string;
|
|
524
|
+
idempotency_key: string;
|
|
525
|
+
requester_authorization: string | (() => string | Promise<string>);
|
|
526
|
+
fetch_impl?: FetchLike;
|
|
527
|
+
}): Promise<JsonObject> {
|
|
528
|
+
const { proposal, profile } = verifyProposal(input?.proposal);
|
|
529
|
+
return beginReceiptApproval({
|
|
530
|
+
authorization: proposal.authorization,
|
|
531
|
+
trustedAuthorization: profile.authorization,
|
|
532
|
+
challenge: proposal.challenge,
|
|
533
|
+
action: proposal.action,
|
|
534
|
+
approver_id: input.approver_id,
|
|
535
|
+
idempotency_key: input.idempotency_key,
|
|
536
|
+
requesterAuthorization: input.requester_authorization,
|
|
537
|
+
fetchImpl: input.fetch_impl,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function pollApproval(input: {
|
|
542
|
+
proposal: unknown;
|
|
543
|
+
request_id: string;
|
|
544
|
+
poll_token: string;
|
|
545
|
+
fetch_impl?: FetchLike;
|
|
546
|
+
}): Promise<JsonObject> {
|
|
547
|
+
const { proposal, profile } = verifyProposal(input?.proposal, { allowExpired: true });
|
|
548
|
+
return pollReceiptApproval({
|
|
549
|
+
authorization: proposal.authorization,
|
|
550
|
+
trustedAuthorization: profile.authorization,
|
|
551
|
+
request_id: input.request_id,
|
|
552
|
+
poll_token: input.poll_token,
|
|
553
|
+
fetchImpl: input.fetch_impl,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return Object.freeze({
|
|
558
|
+
prepare,
|
|
559
|
+
verifyProposal,
|
|
560
|
+
beginApproval,
|
|
561
|
+
pollApproval,
|
|
562
|
+
execute,
|
|
563
|
+
reconcile,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export default {
|
|
568
|
+
PROPOSAL_TO_EFFECT_VERSION,
|
|
569
|
+
proposalToEffectConsumptionNonce,
|
|
570
|
+
createProposalToEffect,
|
|
571
|
+
};
|