@emilia-protocol/gate 0.15.1 → 0.16.1
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 +52 -0
- package/README.md +17 -0
- package/authority-allocation.js +2 -0
- package/consequence-actuator.js +3 -0
- package/discovery-permit-resolver.js +3 -0
- package/dist/authority-allocation.d.ts +200 -0
- package/dist/authority-allocation.d.ts.map +1 -0
- package/dist/authority-allocation.js +984 -0
- package/dist/authority-allocation.js.map +1 -0
- package/dist/consequence-actuator.d.ts +226 -0
- package/dist/consequence-actuator.d.ts.map +1 -0
- package/dist/consequence-actuator.js +685 -0
- package/dist/consequence-actuator.js.map +1 -0
- package/dist/discovery-permit-resolver.d.ts +46 -0
- package/dist/discovery-permit-resolver.d.ts.map +1 -0
- package/dist/discovery-permit-resolver.js +428 -0
- package/dist/discovery-permit-resolver.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -1
- package/dist/proposal-to-effect-postgres.js +9 -5
- package/dist/proposal-to-effect-postgres.js.map +1 -1
- package/package.json +17 -2
- package/src/authority-allocation.ts +1268 -0
- package/src/consequence-actuator.ts +1122 -0
- package/src/discovery-permit-resolver.ts +496 -0
- package/src/index.ts +3 -0
- package/src/proposal-to-effect-postgres.ts +9 -5
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Complete-mediation boundary for provider effects.
|
|
5
|
+
*
|
|
6
|
+
* Gate presents a short-lived signed execution envelope. The actuator verifies
|
|
7
|
+
* every binding under immutable local pins, atomically reserves the envelope,
|
|
8
|
+
* and only then enters a provider callback that owns its credential. Provider
|
|
9
|
+
* credentials are deliberately absent from every public type in this module.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
|
|
12
|
+
import { canonicalize } from './execution-binding.js';
|
|
13
|
+
export const CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION = 'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
|
|
14
|
+
export const CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
15
|
+
export const CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN = 'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
|
|
16
|
+
export const DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS = 60_000;
|
|
17
|
+
export const DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS = 2_000;
|
|
18
|
+
export const CONSEQUENCE_ACTUATOR_STORE_TABLE = 'public.consequence_actuator_envelopes';
|
|
19
|
+
export const CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE = 'consequence_actuator_executor';
|
|
20
|
+
export const CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE = 'consequence_actuator_store_owner';
|
|
21
|
+
export const CONSEQUENCE_ACTUATOR_SQL = deepFreeze({
|
|
22
|
+
reserve: `SELECT envelope_digest
|
|
23
|
+
FROM consequence_actuator_private.reserve_envelope(
|
|
24
|
+
$1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
|
|
25
|
+
$7::text, $8::text, $9::text, $10::timestamptz, $11::timestamptz,
|
|
26
|
+
$12::text
|
|
27
|
+
)`,
|
|
28
|
+
consume: `SELECT envelope_digest
|
|
29
|
+
FROM consequence_actuator_private.consume_envelope(
|
|
30
|
+
$1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
|
|
31
|
+
$7::text, $8::text, $9::text, $10::text, $11::text
|
|
32
|
+
)`,
|
|
33
|
+
});
|
|
34
|
+
const MAX_CONFIGURED_TTL_MS = 5 * 60_000;
|
|
35
|
+
const MAX_CLOCK_SKEW_MS = 30_000;
|
|
36
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
37
|
+
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}$/;
|
|
38
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
|
|
39
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
40
|
+
const ENVELOPE_KEYS = ['payload', 'signature'];
|
|
41
|
+
const PAYLOAD_KEYS = [
|
|
42
|
+
'@version',
|
|
43
|
+
'issuer_id',
|
|
44
|
+
'tenant_id',
|
|
45
|
+
'attempt_id',
|
|
46
|
+
'action_digest',
|
|
47
|
+
'caid',
|
|
48
|
+
'provider_account_id',
|
|
49
|
+
'target_digest',
|
|
50
|
+
'operation',
|
|
51
|
+
'idempotency_key',
|
|
52
|
+
'nonce',
|
|
53
|
+
'issued_at',
|
|
54
|
+
'expires_at',
|
|
55
|
+
];
|
|
56
|
+
const SIGNATURE_KEYS = ['algorithm', 'key_id', 'value'];
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function exactKeys(value, expected) {
|
|
61
|
+
const keys = Reflect.ownKeys(value);
|
|
62
|
+
return keys.length === expected.length
|
|
63
|
+
&& keys.every((key) => typeof key === 'string' && expected.includes(key));
|
|
64
|
+
}
|
|
65
|
+
function deepFreeze(value) {
|
|
66
|
+
if (value === null || typeof value !== 'object')
|
|
67
|
+
return value;
|
|
68
|
+
const stack = [value];
|
|
69
|
+
const seen = new WeakSet();
|
|
70
|
+
while (stack.length > 0) {
|
|
71
|
+
const current = stack.pop();
|
|
72
|
+
if (seen.has(current))
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(current);
|
|
75
|
+
for (const child of Object.values(current)) {
|
|
76
|
+
if (child !== null && typeof child === 'object')
|
|
77
|
+
stack.push(child);
|
|
78
|
+
}
|
|
79
|
+
Object.freeze(current);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function canonicalClone(value) {
|
|
84
|
+
const canonical = canonicalize(value);
|
|
85
|
+
return {
|
|
86
|
+
canonical,
|
|
87
|
+
value: deepFreeze(JSON.parse(canonical)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function validIdentifier(value) {
|
|
91
|
+
return typeof value === 'string'
|
|
92
|
+
&& IDENTIFIER_PATTERN.test(value)
|
|
93
|
+
&& Buffer.byteLength(value, 'utf8') <= 256;
|
|
94
|
+
}
|
|
95
|
+
function validDigest(value) {
|
|
96
|
+
return typeof value === 'string' && DIGEST_PATTERN.test(value);
|
|
97
|
+
}
|
|
98
|
+
function canonicalInstant(value) {
|
|
99
|
+
if (typeof value !== 'string')
|
|
100
|
+
return null;
|
|
101
|
+
const milliseconds = Date.parse(value);
|
|
102
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0)
|
|
103
|
+
return null;
|
|
104
|
+
return new Date(milliseconds).toISOString() === value ? milliseconds : null;
|
|
105
|
+
}
|
|
106
|
+
function decodeCanonicalBase64Url(value, minimumBytes, maximumBytes) {
|
|
107
|
+
if (typeof value !== 'string' || !BASE64URL_PATTERN.test(value))
|
|
108
|
+
return null;
|
|
109
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
110
|
+
if (bytes.length < minimumBytes
|
|
111
|
+
|| bytes.length > maximumBytes
|
|
112
|
+
|| bytes.toString('base64url') !== value) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return bytes;
|
|
116
|
+
}
|
|
117
|
+
function nowMilliseconds(now) {
|
|
118
|
+
const value = typeof now === 'function' ? now() : (now ?? Date.now());
|
|
119
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
120
|
+
throw new TypeError('consequence actuator clock must return a non-negative safe integer');
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
function normalizePrivateKey(value) {
|
|
125
|
+
const key = typeof value === 'object' && value !== null && 'type' in value
|
|
126
|
+
? value
|
|
127
|
+
: createPrivateKey(value);
|
|
128
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
|
|
129
|
+
throw new TypeError('execution envelopes require an Ed25519 private key');
|
|
130
|
+
}
|
|
131
|
+
return key;
|
|
132
|
+
}
|
|
133
|
+
function isKeyObject(value) {
|
|
134
|
+
return typeof value === 'object'
|
|
135
|
+
&& value !== null
|
|
136
|
+
&& 'type' in value
|
|
137
|
+
&& ['private', 'public', 'secret'].includes(value.type);
|
|
138
|
+
}
|
|
139
|
+
function normalizePublicKey(value) {
|
|
140
|
+
let imported;
|
|
141
|
+
if (isKeyObject(value)) {
|
|
142
|
+
if (value.type !== 'public') {
|
|
143
|
+
throw new TypeError('the actuator must receive a public verification key');
|
|
144
|
+
}
|
|
145
|
+
imported = value;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
imported = createPublicKey(value);
|
|
149
|
+
}
|
|
150
|
+
if (imported.type !== 'public'
|
|
151
|
+
|| imported.asymmetricKeyType !== 'ed25519') {
|
|
152
|
+
throw new TypeError('the actuator verification pin must be Ed25519');
|
|
153
|
+
}
|
|
154
|
+
const der = imported.export({ type: 'spki', format: 'der' });
|
|
155
|
+
return createPublicKey({ key: Buffer.from(der), type: 'spki', format: 'der' });
|
|
156
|
+
}
|
|
157
|
+
function publicKeyFingerprint(key) {
|
|
158
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
159
|
+
return `sha256:${createHash('sha256').update(der).digest('hex')}`;
|
|
160
|
+
}
|
|
161
|
+
function signatureInput(canonicalPayload) {
|
|
162
|
+
return Buffer.concat([
|
|
163
|
+
Buffer.from(CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN, 'utf8'),
|
|
164
|
+
Buffer.from([0]),
|
|
165
|
+
Buffer.from(canonicalPayload, 'utf8'),
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
function validatePayloadShape(value) {
|
|
169
|
+
if (!isRecord(value) || !exactKeys(value, PAYLOAD_KEYS))
|
|
170
|
+
return false;
|
|
171
|
+
const issuedAt = canonicalInstant(value.issued_at);
|
|
172
|
+
const expiresAt = canonicalInstant(value.expires_at);
|
|
173
|
+
return value['@version'] === CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION
|
|
174
|
+
&& validIdentifier(value.issuer_id)
|
|
175
|
+
&& validIdentifier(value.tenant_id)
|
|
176
|
+
&& validIdentifier(value.attempt_id)
|
|
177
|
+
&& validDigest(value.action_digest)
|
|
178
|
+
&& typeof value.caid === 'string'
|
|
179
|
+
&& CAID_PATTERN.test(value.caid)
|
|
180
|
+
&& validIdentifier(value.provider_account_id)
|
|
181
|
+
&& validDigest(value.target_digest)
|
|
182
|
+
&& validIdentifier(value.operation)
|
|
183
|
+
&& validIdentifier(value.idempotency_key)
|
|
184
|
+
&& decodeCanonicalBase64Url(value.nonce, 16, 64) !== null
|
|
185
|
+
&& issuedAt !== null
|
|
186
|
+
&& expiresAt !== null
|
|
187
|
+
&& expiresAt > issuedAt;
|
|
188
|
+
}
|
|
189
|
+
function normalizePins(pins) {
|
|
190
|
+
if (!isRecord(pins)) {
|
|
191
|
+
throw new TypeError('consequence actuator pins are required');
|
|
192
|
+
}
|
|
193
|
+
if (!validIdentifier(pins.tenantId)
|
|
194
|
+
|| typeof pins.caid !== 'string'
|
|
195
|
+
|| !CAID_PATTERN.test(pins.caid)
|
|
196
|
+
|| !validIdentifier(pins.providerAccountId)
|
|
197
|
+
|| !validDigest(pins.targetDigest)
|
|
198
|
+
|| !validIdentifier(pins.operation)
|
|
199
|
+
|| !validIdentifier(pins.envelopeIssuerId)
|
|
200
|
+
|| !validIdentifier(pins.envelopeKeyId)) {
|
|
201
|
+
throw new TypeError('consequence actuator pins are malformed');
|
|
202
|
+
}
|
|
203
|
+
const maxEnvelopeTtlMs = pins.maxEnvelopeTtlMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS;
|
|
204
|
+
const clockSkewMs = pins.clockSkewMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS;
|
|
205
|
+
if (!Number.isSafeInteger(maxEnvelopeTtlMs)
|
|
206
|
+
|| maxEnvelopeTtlMs < 1
|
|
207
|
+
|| maxEnvelopeTtlMs > MAX_CONFIGURED_TTL_MS) {
|
|
208
|
+
throw new TypeError('maxEnvelopeTtlMs must be between 1 millisecond and 5 minutes');
|
|
209
|
+
}
|
|
210
|
+
if (!Number.isSafeInteger(clockSkewMs)
|
|
211
|
+
|| clockSkewMs < 0
|
|
212
|
+
|| clockSkewMs > MAX_CLOCK_SKEW_MS) {
|
|
213
|
+
throw new TypeError('clockSkewMs must be between 0 and 30 seconds');
|
|
214
|
+
}
|
|
215
|
+
const verificationKey = normalizePublicKey(pins.envelopePublicKey);
|
|
216
|
+
const visible = deepFreeze({
|
|
217
|
+
tenantId: pins.tenantId,
|
|
218
|
+
caid: pins.caid,
|
|
219
|
+
providerAccountId: pins.providerAccountId,
|
|
220
|
+
targetDigest: pins.targetDigest,
|
|
221
|
+
operation: pins.operation,
|
|
222
|
+
envelopeIssuerId: pins.envelopeIssuerId,
|
|
223
|
+
envelopeKeyId: pins.envelopeKeyId,
|
|
224
|
+
envelopePublicKeyFingerprint: publicKeyFingerprint(verificationKey),
|
|
225
|
+
maxEnvelopeTtlMs,
|
|
226
|
+
clockSkewMs,
|
|
227
|
+
});
|
|
228
|
+
return { visible, verificationKey };
|
|
229
|
+
}
|
|
230
|
+
function verifyWithNormalizedPins(envelope, pins, expected, now) {
|
|
231
|
+
let cloned;
|
|
232
|
+
let canonicalEnvelope;
|
|
233
|
+
try {
|
|
234
|
+
const result = canonicalClone(envelope);
|
|
235
|
+
cloned = result.value;
|
|
236
|
+
canonicalEnvelope = result.canonical;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
240
|
+
}
|
|
241
|
+
if (!isRecord(cloned) || !exactKeys(cloned, ENVELOPE_KEYS)) {
|
|
242
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
243
|
+
}
|
|
244
|
+
if (!isRecord(cloned.payload)) {
|
|
245
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
246
|
+
}
|
|
247
|
+
if (cloned.payload['@version'] !== CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION) {
|
|
248
|
+
return { ok: false, reason: 'unsupported_version' };
|
|
249
|
+
}
|
|
250
|
+
if (!validatePayloadShape(cloned.payload)) {
|
|
251
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
252
|
+
}
|
|
253
|
+
if (!isRecord(cloned.signature)
|
|
254
|
+
|| !exactKeys(cloned.signature, SIGNATURE_KEYS)
|
|
255
|
+
|| cloned.signature.algorithm !== CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM
|
|
256
|
+
|| !validIdentifier(cloned.signature.key_id)) {
|
|
257
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
258
|
+
}
|
|
259
|
+
const signatureBytes = decodeCanonicalBase64Url(cloned.signature.value, 64, 64);
|
|
260
|
+
if (signatureBytes === null) {
|
|
261
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
262
|
+
}
|
|
263
|
+
const payload = cloned.payload;
|
|
264
|
+
const signature = cloned.signature;
|
|
265
|
+
if (signature.key_id !== pins.visible.envelopeKeyId) {
|
|
266
|
+
return { ok: false, reason: 'signer_key_mismatch' };
|
|
267
|
+
}
|
|
268
|
+
const canonicalPayload = canonicalize(payload);
|
|
269
|
+
let validSignature = false;
|
|
270
|
+
try {
|
|
271
|
+
validSignature = verify(null, signatureInput(canonicalPayload), pins.verificationKey, signatureBytes);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
validSignature = false;
|
|
275
|
+
}
|
|
276
|
+
if (!validSignature) {
|
|
277
|
+
return { ok: false, reason: 'signature_invalid' };
|
|
278
|
+
}
|
|
279
|
+
if (payload.issuer_id !== pins.visible.envelopeIssuerId) {
|
|
280
|
+
return { ok: false, reason: 'issuer_mismatch' };
|
|
281
|
+
}
|
|
282
|
+
if (payload.tenant_id !== pins.visible.tenantId) {
|
|
283
|
+
return { ok: false, reason: 'tenant_mismatch' };
|
|
284
|
+
}
|
|
285
|
+
if (payload.caid !== pins.visible.caid) {
|
|
286
|
+
return { ok: false, reason: 'caid_mismatch' };
|
|
287
|
+
}
|
|
288
|
+
if (payload.provider_account_id !== pins.visible.providerAccountId) {
|
|
289
|
+
return { ok: false, reason: 'provider_account_mismatch' };
|
|
290
|
+
}
|
|
291
|
+
if (payload.target_digest !== pins.visible.targetDigest) {
|
|
292
|
+
return { ok: false, reason: 'target_mismatch' };
|
|
293
|
+
}
|
|
294
|
+
if (payload.operation !== pins.visible.operation) {
|
|
295
|
+
return { ok: false, reason: 'operation_mismatch' };
|
|
296
|
+
}
|
|
297
|
+
if (payload.attempt_id !== expected.attemptId) {
|
|
298
|
+
return { ok: false, reason: 'attempt_mismatch' };
|
|
299
|
+
}
|
|
300
|
+
if (payload.action_digest !== expected.actionDigest) {
|
|
301
|
+
return { ok: false, reason: 'action_digest_mismatch' };
|
|
302
|
+
}
|
|
303
|
+
if (payload.idempotency_key !== expected.idempotencyKey) {
|
|
304
|
+
return { ok: false, reason: 'idempotency_key_mismatch' };
|
|
305
|
+
}
|
|
306
|
+
let currentTime;
|
|
307
|
+
try {
|
|
308
|
+
currentTime = nowMilliseconds(now);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
312
|
+
}
|
|
313
|
+
const issuedAt = Date.parse(payload.issued_at);
|
|
314
|
+
const expiresAt = Date.parse(payload.expires_at);
|
|
315
|
+
if (issuedAt > currentTime + pins.visible.clockSkewMs) {
|
|
316
|
+
return { ok: false, reason: 'envelope_not_yet_valid' };
|
|
317
|
+
}
|
|
318
|
+
if (expiresAt <= currentTime) {
|
|
319
|
+
return { ok: false, reason: 'envelope_expired' };
|
|
320
|
+
}
|
|
321
|
+
if (expiresAt - issuedAt > pins.visible.maxEnvelopeTtlMs) {
|
|
322
|
+
return { ok: false, reason: 'envelope_ttl_exceeded' };
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
ok: true,
|
|
326
|
+
payload,
|
|
327
|
+
envelopeDigest: `sha256:${createHash('sha256')
|
|
328
|
+
.update(canonicalEnvelope)
|
|
329
|
+
.digest('hex')}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function reservationFrom(payload, envelopeDigest) {
|
|
333
|
+
return deepFreeze({
|
|
334
|
+
tenantId: payload.tenant_id,
|
|
335
|
+
attemptId: payload.attempt_id,
|
|
336
|
+
actionDigest: payload.action_digest,
|
|
337
|
+
caid: payload.caid,
|
|
338
|
+
providerAccountId: payload.provider_account_id,
|
|
339
|
+
targetDigest: payload.target_digest,
|
|
340
|
+
operation: payload.operation,
|
|
341
|
+
idempotencyKey: payload.idempotency_key,
|
|
342
|
+
nonce: payload.nonce,
|
|
343
|
+
issuedAt: payload.issued_at,
|
|
344
|
+
expiresAt: payload.expires_at,
|
|
345
|
+
envelopeDigest,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function refusal(reason, invoked, envelopeDigest) {
|
|
349
|
+
return deepFreeze({
|
|
350
|
+
ok: false,
|
|
351
|
+
invoked,
|
|
352
|
+
reason,
|
|
353
|
+
...(envelopeDigest === undefined ? {} : { envelopeDigest }),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/** Create a closed Ed25519 execution envelope for an already-authorized effect. */
|
|
357
|
+
export function signConsequenceExecutionEnvelope(payload, options) {
|
|
358
|
+
const cloned = canonicalClone(payload);
|
|
359
|
+
if (!validatePayloadShape(cloned.value)) {
|
|
360
|
+
throw new TypeError('execution envelope payload is malformed');
|
|
361
|
+
}
|
|
362
|
+
if (!validIdentifier(options?.keyId)) {
|
|
363
|
+
throw new TypeError('execution envelope keyId is malformed');
|
|
364
|
+
}
|
|
365
|
+
const signature = sign(null, signatureInput(cloned.canonical), normalizePrivateKey(options.privateKey)).toString('base64url');
|
|
366
|
+
return deepFreeze({
|
|
367
|
+
payload: cloned.value,
|
|
368
|
+
signature: {
|
|
369
|
+
algorithm: CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM,
|
|
370
|
+
key_id: options.keyId,
|
|
371
|
+
value: signature,
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Verify an envelope without invoking a provider or mutating replay state. */
|
|
376
|
+
export function verifyConsequenceExecutionEnvelope(envelope, options) {
|
|
377
|
+
try {
|
|
378
|
+
return verifyWithNormalizedPins(envelope, normalizePins(options.pins), options.expected, options.now);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Credential-owning actuator. `perform` captures the provider credential in
|
|
386
|
+
* the actuator process; callers cannot submit or replace that credential.
|
|
387
|
+
*/
|
|
388
|
+
export class ConsequenceActuator {
|
|
389
|
+
pins;
|
|
390
|
+
#normalizedPins;
|
|
391
|
+
#reserve;
|
|
392
|
+
#consume;
|
|
393
|
+
#perform;
|
|
394
|
+
#now;
|
|
395
|
+
constructor(options) {
|
|
396
|
+
if (!isRecord(options)
|
|
397
|
+
|| !isRecord(options.store)
|
|
398
|
+
|| typeof options.store.reserve !== 'function'
|
|
399
|
+
|| typeof options.store.consume !== 'function'
|
|
400
|
+
|| typeof options.perform !== 'function') {
|
|
401
|
+
throw new TypeError('consequence actuator requires reserve/consume storage and a provider callback');
|
|
402
|
+
}
|
|
403
|
+
const productionCapable = options.store.durable === true
|
|
404
|
+
&& options.store.atomic === true
|
|
405
|
+
&& options.store.ownershipFenced === true
|
|
406
|
+
&& options.store.permanentConsumption === true;
|
|
407
|
+
const explicitTestStore = options.store.testOnly === true
|
|
408
|
+
&& options.testOnly === true;
|
|
409
|
+
if (!productionCapable && !explicitTestStore) {
|
|
410
|
+
throw new TypeError('consequence actuator requires durable, atomic, ownership-fenced storage with permanent consumption; a non-production store requires testOnly: true');
|
|
411
|
+
}
|
|
412
|
+
this.#normalizedPins = normalizePins(options.pins);
|
|
413
|
+
this.pins = this.#normalizedPins.visible;
|
|
414
|
+
this.#reserve = options.store.reserve.bind(options.store);
|
|
415
|
+
this.#consume = options.store.consume.bind(options.store);
|
|
416
|
+
this.#perform = options.perform;
|
|
417
|
+
this.#now = options.now;
|
|
418
|
+
Object.freeze(this);
|
|
419
|
+
}
|
|
420
|
+
async execute(input) {
|
|
421
|
+
const verified = verifyWithNormalizedPins(input?.envelope, this.#normalizedPins, {
|
|
422
|
+
attemptId: input?.attemptId,
|
|
423
|
+
actionDigest: input?.actionDigest,
|
|
424
|
+
idempotencyKey: input?.idempotencyKey,
|
|
425
|
+
}, this.#now);
|
|
426
|
+
if (!verified.ok) {
|
|
427
|
+
return refusal(verified.reason, false);
|
|
428
|
+
}
|
|
429
|
+
const reservation = reservationFrom(verified.payload, verified.envelopeDigest);
|
|
430
|
+
let reserved = false;
|
|
431
|
+
try {
|
|
432
|
+
reserved = await this.#reserve(reservation);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return refusal('store_reserve_failed', false, verified.envelopeDigest);
|
|
436
|
+
}
|
|
437
|
+
if (reserved !== true) {
|
|
438
|
+
return refusal('envelope_replayed', false, verified.envelopeDigest);
|
|
439
|
+
}
|
|
440
|
+
let providerResult;
|
|
441
|
+
try {
|
|
442
|
+
providerResult = await this.#perform(verified.payload);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
try {
|
|
446
|
+
const consumed = await this.#consume(deepFreeze({ ...reservation, outcome: 'INDETERMINATE' }));
|
|
447
|
+
if (consumed !== true) {
|
|
448
|
+
return refusal('store_consume_unconfirmed', true, verified.envelopeDigest);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return refusal('store_consume_unconfirmed', true, verified.envelopeDigest);
|
|
453
|
+
}
|
|
454
|
+
return refusal('provider_outcome_indeterminate', true, verified.envelopeDigest);
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const consumed = await this.#consume(deepFreeze({ ...reservation, outcome: 'COMMITTED' }));
|
|
458
|
+
if (consumed !== true) {
|
|
459
|
+
return refusal('store_consume_unconfirmed', true, verified.envelopeDigest);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return refusal('store_consume_unconfirmed', true, verified.envelopeDigest);
|
|
464
|
+
}
|
|
465
|
+
return deepFreeze({
|
|
466
|
+
ok: true,
|
|
467
|
+
invoked: true,
|
|
468
|
+
result: providerResult,
|
|
469
|
+
envelopeDigest: verified.envelopeDigest,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function reservationKey(tenantId, nonce) {
|
|
474
|
+
return canonicalize([tenantId, nonce]);
|
|
475
|
+
}
|
|
476
|
+
function idempotencyKey(reservation) {
|
|
477
|
+
return canonicalize([
|
|
478
|
+
reservation.tenantId,
|
|
479
|
+
reservation.providerAccountId,
|
|
480
|
+
reservation.operation,
|
|
481
|
+
reservation.idempotencyKey,
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
function sameReservation(left, right) {
|
|
485
|
+
const identity = (value) => ({
|
|
486
|
+
tenantId: value.tenantId,
|
|
487
|
+
attemptId: value.attemptId,
|
|
488
|
+
actionDigest: value.actionDigest,
|
|
489
|
+
caid: value.caid,
|
|
490
|
+
providerAccountId: value.providerAccountId,
|
|
491
|
+
targetDigest: value.targetDigest,
|
|
492
|
+
operation: value.operation,
|
|
493
|
+
idempotencyKey: value.idempotencyKey,
|
|
494
|
+
nonce: value.nonce,
|
|
495
|
+
issuedAt: value.issuedAt,
|
|
496
|
+
expiresAt: value.expiresAt,
|
|
497
|
+
envelopeDigest: value.envelopeDigest,
|
|
498
|
+
});
|
|
499
|
+
return canonicalize(identity(left)) === canonicalize(identity(right));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Race-safe, process-local reference store for tests. Map mutations occur
|
|
503
|
+
* before each async method yields, so concurrent calls have one reservation
|
|
504
|
+
* winner. Production must use the RPC-only durable store from the migration.
|
|
505
|
+
*/
|
|
506
|
+
export class MemoryConsequenceActuatorStore {
|
|
507
|
+
testOnly = true;
|
|
508
|
+
durable = false;
|
|
509
|
+
atomic = true;
|
|
510
|
+
ownershipFenced = false;
|
|
511
|
+
permanentConsumption = false;
|
|
512
|
+
#records = new Map();
|
|
513
|
+
#idempotency = new Map();
|
|
514
|
+
#now;
|
|
515
|
+
constructor(options = {}) {
|
|
516
|
+
this.#now = options.now;
|
|
517
|
+
}
|
|
518
|
+
async reserve(reservation) {
|
|
519
|
+
const key = reservationKey(reservation.tenantId, reservation.nonce);
|
|
520
|
+
const operationKey = idempotencyKey(reservation);
|
|
521
|
+
if (this.#records.has(key) || this.#idempotency.has(operationKey)) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
const reservedAt = new Date(nowMilliseconds(this.#now)).toISOString();
|
|
525
|
+
const snapshot = deepFreeze({
|
|
526
|
+
...reservation,
|
|
527
|
+
state: 'RESERVED',
|
|
528
|
+
outcome: null,
|
|
529
|
+
reservedAt,
|
|
530
|
+
consumedAt: null,
|
|
531
|
+
});
|
|
532
|
+
this.#records.set(key, snapshot);
|
|
533
|
+
this.#idempotency.set(operationKey, key);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
async consume(consumption) {
|
|
537
|
+
const key = reservationKey(consumption.tenantId, consumption.nonce);
|
|
538
|
+
const current = this.#records.get(key);
|
|
539
|
+
if (current === undefined
|
|
540
|
+
|| current.state !== 'RESERVED'
|
|
541
|
+
|| !sameReservation(current, consumption)
|
|
542
|
+
|| !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
this.#records.set(key, deepFreeze({
|
|
546
|
+
...current,
|
|
547
|
+
state: 'CONSUMED',
|
|
548
|
+
outcome: consumption.outcome,
|
|
549
|
+
consumedAt: new Date(nowMilliseconds(this.#now)).toISOString(),
|
|
550
|
+
}));
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
snapshot(tenantId, nonce) {
|
|
554
|
+
const snapshot = this.#records.get(reservationKey(tenantId, nonce));
|
|
555
|
+
if (snapshot === undefined)
|
|
556
|
+
return null;
|
|
557
|
+
return canonicalClone(snapshot).value;
|
|
558
|
+
}
|
|
559
|
+
get size() {
|
|
560
|
+
return this.#records.size;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
export function createMemoryConsequenceActuatorStore(options = {}) {
|
|
564
|
+
return new MemoryConsequenceActuatorStore(options);
|
|
565
|
+
}
|
|
566
|
+
const FORBIDDEN_EXECUTOR_PRINCIPALS = new Set([
|
|
567
|
+
'anon',
|
|
568
|
+
'authenticated',
|
|
569
|
+
'postgres',
|
|
570
|
+
'service_role',
|
|
571
|
+
CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE,
|
|
572
|
+
CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE,
|
|
573
|
+
]);
|
|
574
|
+
function validStoreBinding(reservation) {
|
|
575
|
+
const issuedAt = canonicalInstant(reservation?.issuedAt);
|
|
576
|
+
const expiresAt = canonicalInstant(reservation?.expiresAt);
|
|
577
|
+
return validIdentifier(reservation?.tenantId)
|
|
578
|
+
&& validIdentifier(reservation?.attemptId)
|
|
579
|
+
&& validDigest(reservation?.actionDigest)
|
|
580
|
+
&& typeof reservation?.caid === 'string'
|
|
581
|
+
&& CAID_PATTERN.test(reservation.caid)
|
|
582
|
+
&& validIdentifier(reservation?.providerAccountId)
|
|
583
|
+
&& validDigest(reservation?.targetDigest)
|
|
584
|
+
&& validIdentifier(reservation?.operation)
|
|
585
|
+
&& validIdentifier(reservation?.idempotencyKey)
|
|
586
|
+
&& decodeCanonicalBase64Url(reservation?.nonce, 16, 64) !== null
|
|
587
|
+
&& issuedAt !== null
|
|
588
|
+
&& expiresAt !== null
|
|
589
|
+
&& expiresAt > issuedAt
|
|
590
|
+
&& validDigest(reservation?.envelopeDigest);
|
|
591
|
+
}
|
|
592
|
+
function exactPostgresAcknowledgement(result, expectedEnvelopeDigest) {
|
|
593
|
+
if (result !== null
|
|
594
|
+
&& typeof result === 'object'
|
|
595
|
+
&& result.rowCount === 0
|
|
596
|
+
&& Array.isArray(result.rows)
|
|
597
|
+
&& result.rows.length === 0) {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
if (result === null
|
|
601
|
+
|| typeof result !== 'object'
|
|
602
|
+
|| result.rowCount !== 1
|
|
603
|
+
|| !Array.isArray(result.rows)
|
|
604
|
+
|| result.rows.length !== 1
|
|
605
|
+
|| !isRecord(result.rows[0])
|
|
606
|
+
|| result.rows[0].envelope_digest !== expectedEnvelopeDigest) {
|
|
607
|
+
throw new Error('consequence actuator store returned an ambiguous acknowledgement');
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Production adapter for the RPC-only PostgreSQL store. The supplied pool must
|
|
613
|
+
* be dedicated to one tenant-mapped executor login; the adapter never emits
|
|
614
|
+
* direct table SQL.
|
|
615
|
+
*/
|
|
616
|
+
export class PostgresConsequenceActuatorStore {
|
|
617
|
+
durable = true;
|
|
618
|
+
atomic = true;
|
|
619
|
+
ownershipFenced = true;
|
|
620
|
+
permanentConsumption = true;
|
|
621
|
+
tenantId;
|
|
622
|
+
executorPrincipal;
|
|
623
|
+
#query;
|
|
624
|
+
constructor(options) {
|
|
625
|
+
if (!isRecord(options)
|
|
626
|
+
|| !validIdentifier(options.tenantId)
|
|
627
|
+
|| !validIdentifier(options.executorPrincipal)
|
|
628
|
+
|| FORBIDDEN_EXECUTOR_PRINCIPALS.has(options.executorPrincipal)
|
|
629
|
+
|| !isRecord(options.executorPool)
|
|
630
|
+
|| options.executorPool.principal !== options.executorPrincipal
|
|
631
|
+
|| typeof options.executorPool.query !== 'function') {
|
|
632
|
+
throw new TypeError('a dedicated tenant executor principal and matching pool are required');
|
|
633
|
+
}
|
|
634
|
+
this.tenantId = options.tenantId;
|
|
635
|
+
this.executorPrincipal = options.executorPrincipal;
|
|
636
|
+
this.#query = options.executorPool.query.bind(options.executorPool);
|
|
637
|
+
Object.freeze(this);
|
|
638
|
+
}
|
|
639
|
+
async reserve(reservation) {
|
|
640
|
+
if (!validStoreBinding(reservation)
|
|
641
|
+
|| reservation.tenantId !== this.tenantId) {
|
|
642
|
+
throw new TypeError('consequence actuator reservation does not match the store tenant');
|
|
643
|
+
}
|
|
644
|
+
const result = await this.#query(CONSEQUENCE_ACTUATOR_SQL.reserve, [
|
|
645
|
+
reservation.tenantId,
|
|
646
|
+
reservation.attemptId,
|
|
647
|
+
reservation.actionDigest,
|
|
648
|
+
reservation.caid,
|
|
649
|
+
reservation.providerAccountId,
|
|
650
|
+
reservation.targetDigest,
|
|
651
|
+
reservation.operation,
|
|
652
|
+
reservation.idempotencyKey,
|
|
653
|
+
reservation.nonce,
|
|
654
|
+
reservation.issuedAt,
|
|
655
|
+
reservation.expiresAt,
|
|
656
|
+
reservation.envelopeDigest,
|
|
657
|
+
]);
|
|
658
|
+
return exactPostgresAcknowledgement(result, reservation.envelopeDigest);
|
|
659
|
+
}
|
|
660
|
+
async consume(consumption) {
|
|
661
|
+
if (!validStoreBinding(consumption)
|
|
662
|
+
|| consumption.tenantId !== this.tenantId
|
|
663
|
+
|| !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)) {
|
|
664
|
+
throw new TypeError('consequence actuator consumption does not match the store tenant');
|
|
665
|
+
}
|
|
666
|
+
const result = await this.#query(CONSEQUENCE_ACTUATOR_SQL.consume, [
|
|
667
|
+
consumption.tenantId,
|
|
668
|
+
consumption.attemptId,
|
|
669
|
+
consumption.actionDigest,
|
|
670
|
+
consumption.caid,
|
|
671
|
+
consumption.providerAccountId,
|
|
672
|
+
consumption.targetDigest,
|
|
673
|
+
consumption.operation,
|
|
674
|
+
consumption.idempotencyKey,
|
|
675
|
+
consumption.nonce,
|
|
676
|
+
consumption.envelopeDigest,
|
|
677
|
+
consumption.outcome,
|
|
678
|
+
]);
|
|
679
|
+
return exactPostgresAcknowledgement(result, consumption.envelopeDigest);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
export function createPostgresConsequenceActuatorStore(options) {
|
|
683
|
+
return new PostgresConsequenceActuatorStore(options);
|
|
684
|
+
}
|
|
685
|
+
//# sourceMappingURL=consequence-actuator.js.map
|