@emilia-protocol/gate 0.10.0 → 0.11.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 CHANGED
@@ -4,6 +4,39 @@
4
4
  All notable changes to `@emilia-protocol/gate` are documented here.
5
5
  This package follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.11.0 (2026-07-17)
8
+
9
+ ### Added
10
+
11
+ - Action Escrow modules for exact document/action binding, evidence
12
+ verification, a signed lifecycle state machine, durable Postgres state and
13
+ journal storage, licensed-custodian adapters, portable assurance packages,
14
+ and fail-closed release enforcement.
15
+ - Public package exports for `action-escrow`, `action-escrow-state`,
16
+ `action-escrow-postgres`, `action-escrow-custodian`,
17
+ `action-escrow-package`, and `action-escrow-verifiers`.
18
+
19
+ ### Security
20
+
21
+ - Release requires exact profile, party, final-document, material-term,
22
+ funding, milestone, action, and approval binding under relying-party-pinned
23
+ policy.
24
+ - Release approvals are fresh and action-specific, release is consumed once,
25
+ storage failure refuses, and an ambiguous provider effect enters
26
+ reconciliation instead of being retried.
27
+ - Release approvals now use the canonical `EP-RESOLUTION-v1` binding-moment
28
+ hash, relying-party-pinned option mapping, initiator, per-party nonce, and
29
+ evaluation time. The reference scenario uses real WebAuthn-shaped P-256
30
+ signatures rather than a resolution-like demo envelope.
31
+ - Runtime roles cannot also act as contract parties. Provider and effect
32
+ references are fenced to prevent substitution across actions or sessions.
33
+
34
+ ### Distribution
35
+
36
+ - Gate now depends on `@emilia-protocol/verify` 3.11.0 and
37
+ `@emilia-protocol/require-receipt` 0.6.1. All shipped imports remain within
38
+ declared package boundaries.
39
+
7
40
  ## 0.10.0 (2026-07-16)
8
41
 
9
42
  ### Added
package/README.md CHANGED
@@ -295,6 +295,26 @@ challenge. The Gate composes that and adds the lifecycle controls a firewall nee
295
295
  can establish a declared surface as `gated`; a passive network witness alone is always
296
296
  `witness_only`. Inventory completeness remains an explicit relying-party assumption.
297
297
 
298
+ ## Action Escrow
299
+
300
+ Action Escrow is the Gate profile for a two-party agreement whose downstream
301
+ release must obey the exact final document. The customer application supplies
302
+ the signed agreement, material terms, party acceptances, funding evidence,
303
+ milestone evidence, and action-specific release approvals. Gate verifies and
304
+ binds those inputs, advances a signed lifecycle, and consumes the release once.
305
+ Each release approval is a standard `EP-RESOLUTION-v1` WebAuthn record over a
306
+ canonical binding moment. Gate independently pins the approval option,
307
+ initiator, per-party nonce, evaluation time, exact action digest, and the
308
+ document and milestone-evidence digests rendered to the approver.
309
+
310
+ The public modules are `action-escrow`, `action-escrow-state`,
311
+ `action-escrow-postgres`, `action-escrow-custodian`,
312
+ `action-escrow-package`, and `action-escrow-verifiers`. A licensed external
313
+ provider holds or moves funds; EMILIA does not take custody, inspect work,
314
+ adjudicate disputes, or make an agreement legally enforceable. An ambiguous
315
+ provider outcome enters reconciliation and is never retried as though nothing
316
+ happened.
317
+
298
318
  ## Production custody
299
319
 
300
320
  The three things a serious buyer (CISO, auditor, insurer) asks after the demo:
@@ -0,0 +1,395 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Typed bridge between the Action Escrow kernel and an authenticated external
4
+ * custodian adapter. Provider observations are signed by the deployment
5
+ * operator because TLS API responses are not portable offline evidence.
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import { canonicalize, hashCanonical } from './execution-binding.js';
9
+ import { validateActionEscrowReleaseTemplate } from './action-escrow-verifiers.js';
10
+
11
+ export const ACTION_ESCROW_CUSTODIAN_OBSERVATION_VERSION =
12
+ 'EP-ACTION-ESCROW-CUSTODIAN-OBSERVATION-v1';
13
+ const DOMAIN = `${ACTION_ESCROW_CUSTODIAN_OBSERVATION_VERSION}\0`;
14
+ const HASH = /^sha256:[0-9a-f]{64}$/;
15
+
16
+ function isRecord(value) {
17
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
18
+ const prototype = Object.getPrototypeOf(value);
19
+ return prototype === Object.prototype || prototype === null;
20
+ }
21
+
22
+ function canonicalCopy(value) {
23
+ return JSON.parse(canonicalize(value));
24
+ }
25
+
26
+ function canonicalDigest(value) {
27
+ return `sha256:${hashCanonical(value)}`;
28
+ }
29
+
30
+ function validString(value, max = 512) {
31
+ return typeof value === 'string'
32
+ && value.length > 0
33
+ && value.length <= max
34
+ && !/[\u0000-\u001f\u007f]/.test(value);
35
+ }
36
+
37
+ function strictInstant(value) {
38
+ if (typeof value !== 'string') return false;
39
+ const parsed = Date.parse(value);
40
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
41
+ }
42
+
43
+ function deepFreeze(value) {
44
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
45
+ Object.freeze(value);
46
+ for (const child of Object.values(value)) deepFreeze(child);
47
+ return value;
48
+ }
49
+
50
+ function signingBytes(payload) {
51
+ return Buffer.concat([
52
+ Buffer.from(DOMAIN, 'utf8'),
53
+ Buffer.from(canonicalize(payload), 'utf8'),
54
+ ]);
55
+ }
56
+
57
+ function requestScope(request) {
58
+ const { request_digest: _digest, ...scope } = request;
59
+ // The digest identifies the reserved mutation. GET is only a reconciliation
60
+ // transport for that same POST and must not create a second request identity.
61
+ return { ...scope, method: 'POST' };
62
+ }
63
+
64
+ function normalizeKernelRequest(value, adapter) {
65
+ try {
66
+ if (!isRecord(value)
67
+ || (value.method !== 'POST' && value.method !== 'GET')
68
+ || value.provider_id !== adapter.provider
69
+ || value.profile?.provider_id !== adapter.provider
70
+ || !HASH.test(value.agreement_digest)
71
+ || !HASH.test(value.document_action_binding_digest)
72
+ || !HASH.test(value.release_action_digest)
73
+ || !HASH.test(value.parties_digest)
74
+ || !HASH.test(value.profile_digest)
75
+ || !HASH.test(value.document_digest)
76
+ || !HASH.test(value.request_digest)
77
+ || !validString(value.milestone_id, 256)
78
+ || !validString(value.idempotency_key, 256)
79
+ || !isRecord(value.release_action_template)) {
80
+ return null;
81
+ }
82
+ const template = validateActionEscrowReleaseTemplate(
83
+ value.release_action_template,
84
+ {
85
+ profileDigest: value.profile_digest,
86
+ agreementId: value.agreement_id,
87
+ agreementDigest: value.agreement_digest,
88
+ milestoneId: value.milestone_id,
89
+ documentDigest: value.document_digest,
90
+ },
91
+ );
92
+ if (!template
93
+ || template.custodian_provider !== adapter.provider
94
+ || template.custodian_environment !== adapter.environment
95
+ || canonicalDigest({
96
+ '@version': 'EP-ACTION-ESCROW-PROVIDER-REQUEST-v1',
97
+ ...requestScope(value),
98
+ }) !== value.request_digest) {
99
+ return null;
100
+ }
101
+ return canonicalCopy(value);
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function transactionMatches(transaction, request) {
108
+ if (!isRecord(transaction)
109
+ || transaction.transaction_id
110
+ !== request.release_action_template.custodian_transaction_id
111
+ || transaction.currency !== request.release_action_template.currency
112
+ || !Array.isArray(transaction.milestones)) {
113
+ return false;
114
+ }
115
+ const milestone = transaction.milestones.find(
116
+ (entry) => entry.provider_item_id
117
+ === request.release_action_template.custodian_milestone_id,
118
+ );
119
+ if (!milestone || !Array.isArray(milestone.schedules) || milestone.schedules.length !== 1) {
120
+ return false;
121
+ }
122
+ const schedule = milestone.schedules[0];
123
+ return schedule.amount === request.release_action_template.amount
124
+ && schedule.beneficiary_customer === request.release_action_template.destination_id;
125
+ }
126
+
127
+ function providerResultMatches(result, request, adapter, operation) {
128
+ return isRecord(result)
129
+ && result.provider === adapter.provider
130
+ && result.environment === adapter.environment
131
+ && result.operation === operation
132
+ && result.transaction_id
133
+ === request.release_action_template.custodian_transaction_id
134
+ && (operation === 'reconcile_transaction'
135
+ || (
136
+ result.milestone_id
137
+ === request.release_action_template.custodian_milestone_id
138
+ && result.effect_reference === request.idempotency_key
139
+ ));
140
+ }
141
+
142
+ function transactionFromResult(result) {
143
+ return isRecord(result?.transaction) ? result.transaction : null;
144
+ }
145
+
146
+ function observationStatus(result) {
147
+ if (result.kind === 'released') return 'released';
148
+ if (result.kind !== 'provider_action_required') return null;
149
+ if (result.provider_phase === 'not_accepted') return 'not_released';
150
+ if (result.provider_phase === 'accepted_pending_disbursement') return 'pending';
151
+ return null;
152
+ }
153
+
154
+ function normalizePrivateKey(value) {
155
+ const key = value instanceof crypto.KeyObject ? value : crypto.createPrivateKey(value);
156
+ if (key.asymmetricKeyType !== 'ed25519') {
157
+ throw new TypeError('custodian observation signer must use Ed25519');
158
+ }
159
+ return key;
160
+ }
161
+
162
+ /**
163
+ * The bridge implements the kernel's release/getRelease contract. It never
164
+ * claims that EMILIA holds funds or that the external provider is licensed.
165
+ */
166
+ export function createActionEscrowCustodianBridge({
167
+ adapter,
168
+ observationSigner,
169
+ now = () => new Date().toISOString(),
170
+ } = {}) {
171
+ if (!isRecord(adapter)
172
+ || adapter.kind !== 'external_custodian'
173
+ || !validString(adapter.provider, 128)
174
+ || !['sandbox', 'production'].includes(adapter.environment)
175
+ || typeof adapter.reconcileTransaction !== 'function'
176
+ || typeof adapter.releaseMilestone !== 'function'
177
+ || typeof adapter.requestMilestoneDisbursement !== 'function'
178
+ || !isRecord(observationSigner)
179
+ || !validString(observationSigner.key_id, 256)
180
+ || typeof now !== 'function') {
181
+ throw new TypeError('external custodian adapter and observation signer are required');
182
+ }
183
+ const privateKey = normalizePrivateKey(observationSigner.privateKey);
184
+
185
+ async function preflight(request) {
186
+ const reconciled = await adapter.reconcileTransaction({
187
+ transactionId: request.release_action_template.custodian_transaction_id,
188
+ });
189
+ if (!providerResultMatches(
190
+ reconciled,
191
+ request,
192
+ adapter,
193
+ 'reconcile_transaction',
194
+ )
195
+ || reconciled.kind !== 'reconciled'
196
+ || !transactionMatches(reconciled.transaction, request)) {
197
+ throw new Error('custodian transaction does not match the signed release action');
198
+ }
199
+ return reconciled.transaction;
200
+ }
201
+
202
+ function signObservation(request, status, result) {
203
+ const observedAt = now();
204
+ if (!strictInstant(observedAt)) throw new Error('invalid custodian observation clock');
205
+ const transaction = transactionFromResult(result);
206
+ const payload = {
207
+ '@version': ACTION_ESCROW_CUSTODIAN_OBSERVATION_VERSION,
208
+ provider_id: result.provider,
209
+ environment: result.environment,
210
+ statement_type: 'release',
211
+ status,
212
+ agreement_digest: request.agreement_digest,
213
+ document_action_binding_digest: request.document_action_binding_digest,
214
+ milestone_id: request.milestone_id,
215
+ release_action_digest: request.release_action_digest,
216
+ parties_digest: request.parties_digest,
217
+ profile_digest: request.profile_digest,
218
+ provider_idempotency_key: request.idempotency_key,
219
+ provider_request_digest: request.request_digest,
220
+ provider_effect_reference: result.effect_reference,
221
+ provider_transaction_id: result.transaction_id,
222
+ provider_milestone_id: result.milestone_id,
223
+ amount: request.release_action_template.amount,
224
+ currency: request.release_action_template.currency,
225
+ destination_id: request.release_action_template.destination_id,
226
+ provider_snapshot_digest: canonicalDigest(transaction),
227
+ observed_at: observedAt,
228
+ };
229
+ const signature = crypto.sign(null, signingBytes(payload), privateKey);
230
+ return deepFreeze({
231
+ payload,
232
+ signature: {
233
+ algorithm: 'Ed25519',
234
+ key_id: observationSigner.key_id,
235
+ value: signature.toString('base64url'),
236
+ },
237
+ });
238
+ }
239
+
240
+ return Object.freeze({
241
+ provider: adapter.provider,
242
+ environment: adapter.environment,
243
+ async release(untrustedRequest) {
244
+ const request = normalizeKernelRequest(untrustedRequest, adapter);
245
+ if (!request || request.method !== 'POST') {
246
+ throw new Error('invalid kernel release request');
247
+ }
248
+ await preflight(request);
249
+ const result = await adapter.releaseMilestone({
250
+ effectReference: request.idempotency_key,
251
+ transactionId: request.release_action_template.custodian_transaction_id,
252
+ milestoneId: request.release_action_template.custodian_milestone_id,
253
+ });
254
+ if (!providerResultMatches(
255
+ result,
256
+ request,
257
+ adapter,
258
+ 'release_milestone',
259
+ )
260
+ || !['released', 'release_submitted', 'provider_action_required'].includes(result.kind)
261
+ || (
262
+ ['released', 'release_submitted'].includes(result.kind)
263
+ && !transactionMatches(transactionFromResult(result), request)
264
+ )) {
265
+ throw new Error('custodian release outcome is not authoritative');
266
+ }
267
+ return { accepted: true };
268
+ },
269
+ async getRelease(untrustedRequest) {
270
+ const request = normalizeKernelRequest(untrustedRequest, adapter);
271
+ if (!request || request.method !== 'GET') {
272
+ throw new Error('invalid kernel reconciliation request');
273
+ }
274
+ await preflight(request);
275
+ const result = await adapter.requestMilestoneDisbursement({
276
+ effectReference: request.idempotency_key,
277
+ transactionId: request.release_action_template.custodian_transaction_id,
278
+ milestoneId: request.release_action_template.custodian_milestone_id,
279
+ });
280
+ const status = observationStatus(result);
281
+ const transaction = transactionFromResult(result);
282
+ if (!providerResultMatches(
283
+ result,
284
+ request,
285
+ adapter,
286
+ 'request_milestone_disbursement',
287
+ )
288
+ || !status
289
+ || !transaction
290
+ || !transactionMatches(transaction, request)) {
291
+ throw new Error('custodian release state is indeterminate');
292
+ }
293
+ return {
294
+ authenticated: true,
295
+ statement: signObservation(request, status, result),
296
+ };
297
+ },
298
+ });
299
+ }
300
+
301
+ export function createActionEscrowCustodianStatementVerifier({
302
+ operatorKeys,
303
+ providerId,
304
+ environment,
305
+ } = {}) {
306
+ if (!isRecord(operatorKeys)
307
+ || !validString(providerId, 128)
308
+ || !['sandbox', 'production'].includes(environment)) {
309
+ throw new TypeError('pinned operator keys and provider identity are required');
310
+ }
311
+ const pins = canonicalCopy(operatorKeys);
312
+ return async function verifyStatement(statement, expected) {
313
+ try {
314
+ if (!isRecord(statement)
315
+ || !isRecord(statement.payload)
316
+ || !isRecord(statement.signature)
317
+ || statement.signature.algorithm !== 'Ed25519'
318
+ || !validString(statement.signature.key_id, 256)
319
+ || !validString(statement.signature.value, 1024)) {
320
+ return { valid: false, reason: 'malformed_custodian_observation' };
321
+ }
322
+ const pin = pins[statement.signature.key_id];
323
+ if (!isRecord(pin) || typeof pin.public_key !== 'string') {
324
+ return { valid: false, reason: 'custodian_operator_key_not_pinned' };
325
+ }
326
+ const publicKey = crypto.createPublicKey({
327
+ key: Buffer.from(pin.public_key, 'base64url'),
328
+ type: 'spki',
329
+ format: 'der',
330
+ });
331
+ if (publicKey.asymmetricKeyType !== 'ed25519'
332
+ || !crypto.verify(
333
+ null,
334
+ signingBytes(statement.payload),
335
+ publicKey,
336
+ Buffer.from(statement.signature.value, 'base64url'),
337
+ )) {
338
+ return { valid: false, reason: 'custodian_observation_signature_invalid' };
339
+ }
340
+ const payload = statement.payload;
341
+ const exact = payload['@version'] === ACTION_ESCROW_CUSTODIAN_OBSERVATION_VERSION
342
+ && payload.provider_id === providerId
343
+ && payload.environment === environment
344
+ && payload.statement_type === expected.statement_type
345
+ && payload.agreement_digest === expected.agreement_digest
346
+ && payload.document_action_binding_digest
347
+ === expected.document_action_binding_digest
348
+ && payload.milestone_id === expected.milestone_id
349
+ && payload.release_action_digest === expected.release_action_digest
350
+ && payload.parties_digest === expected.parties_digest
351
+ && payload.profile_digest === expected.profile_digest
352
+ && payload.provider_idempotency_key === expected.provider_idempotency_key
353
+ && payload.provider_request_digest === expected.provider_request_digest
354
+ && payload.provider_effect_reference === expected.provider_idempotency_key
355
+ && payload.provider_transaction_id === expected.provider_transaction_id
356
+ && payload.provider_milestone_id === expected.provider_milestone_id
357
+ && payload.amount === expected.amount
358
+ && payload.currency === expected.currency
359
+ && payload.destination_id === expected.destination_id
360
+ && ['released', 'not_released', 'pending'].includes(payload.status)
361
+ && HASH.test(payload.provider_snapshot_digest)
362
+ && strictInstant(payload.observed_at);
363
+ if (!exact) return { valid: false, reason: 'custodian_observation_binding_mismatch' };
364
+ return {
365
+ valid: true,
366
+ authenticated: true,
367
+ statement_type: payload.statement_type,
368
+ status: payload.status,
369
+ statement_digest: canonicalDigest(statement),
370
+ provider_id: payload.provider_id,
371
+ agreement_digest: payload.agreement_digest,
372
+ document_action_binding_digest: payload.document_action_binding_digest,
373
+ milestone_id: payload.milestone_id,
374
+ release_action_digest: payload.release_action_digest,
375
+ parties_digest: payload.parties_digest,
376
+ profile_digest: payload.profile_digest,
377
+ provider_idempotency_key: payload.provider_idempotency_key,
378
+ provider_request_digest: payload.provider_request_digest,
379
+ provider_transaction_id: payload.provider_transaction_id,
380
+ provider_milestone_id: payload.provider_milestone_id,
381
+ amount: payload.amount,
382
+ currency: payload.currency,
383
+ destination_id: payload.destination_id,
384
+ };
385
+ } catch {
386
+ return { valid: false, reason: 'malformed_custodian_observation' };
387
+ }
388
+ };
389
+ }
390
+
391
+ export default Object.freeze({
392
+ ACTION_ESCROW_CUSTODIAN_OBSERVATION_VERSION,
393
+ createActionEscrowCustodianBridge,
394
+ createActionEscrowCustodianStatementVerifier,
395
+ });