@emilia-protocol/gate 0.15.2 → 0.17.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 +62 -0
- package/README.md +31 -0
- package/authority-allocation.js +2 -0
- package/autonomy-control-plane-profile.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/autonomy-control-plane-profile.d.ts +18 -0
- package/dist/autonomy-control-plane-profile.d.ts.map +1 -0
- package/dist/autonomy-control-plane-profile.js +352 -0
- package/dist/autonomy-control-plane-profile.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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +22 -2
- package/src/authority-allocation.ts +1268 -0
- package/src/autonomy-control-plane-profile.ts +371 -0
- package/src/consequence-actuator.ts +1122 -0
- package/src/discovery-permit-resolver.ts +496 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
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
|
+
|
|
12
|
+
import {
|
|
13
|
+
createHash,
|
|
14
|
+
createPrivateKey,
|
|
15
|
+
createPublicKey,
|
|
16
|
+
sign,
|
|
17
|
+
verify,
|
|
18
|
+
type KeyObject,
|
|
19
|
+
} from 'node:crypto';
|
|
20
|
+
import { canonicalize } from './execution-binding.js';
|
|
21
|
+
|
|
22
|
+
export const CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION =
|
|
23
|
+
'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
|
|
24
|
+
export const CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
25
|
+
export const CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN =
|
|
26
|
+
'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
|
|
27
|
+
export const DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS = 60_000;
|
|
28
|
+
export const DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS = 2_000;
|
|
29
|
+
export const CONSEQUENCE_ACTUATOR_STORE_TABLE =
|
|
30
|
+
'public.consequence_actuator_envelopes';
|
|
31
|
+
export const CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE =
|
|
32
|
+
'consequence_actuator_executor';
|
|
33
|
+
export const CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE =
|
|
34
|
+
'consequence_actuator_store_owner';
|
|
35
|
+
export const CONSEQUENCE_ACTUATOR_SQL = deepFreeze({
|
|
36
|
+
reserve: `SELECT envelope_digest
|
|
37
|
+
FROM consequence_actuator_private.reserve_envelope(
|
|
38
|
+
$1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
|
|
39
|
+
$7::text, $8::text, $9::text, $10::timestamptz, $11::timestamptz,
|
|
40
|
+
$12::text
|
|
41
|
+
)`,
|
|
42
|
+
consume: `SELECT envelope_digest
|
|
43
|
+
FROM consequence_actuator_private.consume_envelope(
|
|
44
|
+
$1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
|
|
45
|
+
$7::text, $8::text, $9::text, $10::text, $11::text
|
|
46
|
+
)`,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const MAX_CONFIGURED_TTL_MS = 5 * 60_000;
|
|
50
|
+
const MAX_CLOCK_SKEW_MS = 30_000;
|
|
51
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
52
|
+
const CAID_PATTERN =
|
|
53
|
+
/^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}$/;
|
|
54
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
|
|
55
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
56
|
+
const ENVELOPE_KEYS = ['payload', 'signature'] as const;
|
|
57
|
+
const PAYLOAD_KEYS = [
|
|
58
|
+
'@version',
|
|
59
|
+
'issuer_id',
|
|
60
|
+
'tenant_id',
|
|
61
|
+
'attempt_id',
|
|
62
|
+
'action_digest',
|
|
63
|
+
'caid',
|
|
64
|
+
'provider_account_id',
|
|
65
|
+
'target_digest',
|
|
66
|
+
'operation',
|
|
67
|
+
'idempotency_key',
|
|
68
|
+
'nonce',
|
|
69
|
+
'issued_at',
|
|
70
|
+
'expires_at',
|
|
71
|
+
] as const;
|
|
72
|
+
const SIGNATURE_KEYS = ['algorithm', 'key_id', 'value'] as const;
|
|
73
|
+
|
|
74
|
+
type KeyMaterial = KeyObject | string | Buffer;
|
|
75
|
+
|
|
76
|
+
export interface ConsequenceExecutionEnvelopePayload {
|
|
77
|
+
'@version': typeof CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION;
|
|
78
|
+
issuer_id: string;
|
|
79
|
+
tenant_id: string;
|
|
80
|
+
attempt_id: string;
|
|
81
|
+
action_digest: string;
|
|
82
|
+
caid: string;
|
|
83
|
+
provider_account_id: string;
|
|
84
|
+
target_digest: string;
|
|
85
|
+
operation: string;
|
|
86
|
+
idempotency_key: string;
|
|
87
|
+
nonce: string;
|
|
88
|
+
issued_at: string;
|
|
89
|
+
expires_at: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ConsequenceExecutionEnvelopeSignature {
|
|
93
|
+
algorithm: typeof CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM;
|
|
94
|
+
key_id: string;
|
|
95
|
+
value: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SignedConsequenceExecutionEnvelope {
|
|
99
|
+
payload: ConsequenceExecutionEnvelopePayload;
|
|
100
|
+
signature: ConsequenceExecutionEnvelopeSignature;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ConsequenceActuatorPins {
|
|
104
|
+
tenantId: string;
|
|
105
|
+
caid: string;
|
|
106
|
+
providerAccountId: string;
|
|
107
|
+
targetDigest: string;
|
|
108
|
+
operation: string;
|
|
109
|
+
envelopeIssuerId: string;
|
|
110
|
+
envelopeKeyId: string;
|
|
111
|
+
envelopePublicKey: KeyMaterial;
|
|
112
|
+
maxEnvelopeTtlMs?: number;
|
|
113
|
+
clockSkewMs?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface VisibleConsequenceActuatorPins {
|
|
117
|
+
readonly tenantId: string;
|
|
118
|
+
readonly caid: string;
|
|
119
|
+
readonly providerAccountId: string;
|
|
120
|
+
readonly targetDigest: string;
|
|
121
|
+
readonly operation: string;
|
|
122
|
+
readonly envelopeIssuerId: string;
|
|
123
|
+
readonly envelopeKeyId: string;
|
|
124
|
+
readonly envelopePublicKeyFingerprint: string;
|
|
125
|
+
readonly maxEnvelopeTtlMs: number;
|
|
126
|
+
readonly clockSkewMs: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ConsequenceActuatorReservation {
|
|
130
|
+
readonly tenantId: string;
|
|
131
|
+
readonly attemptId: string;
|
|
132
|
+
readonly actionDigest: string;
|
|
133
|
+
readonly caid: string;
|
|
134
|
+
readonly providerAccountId: string;
|
|
135
|
+
readonly targetDigest: string;
|
|
136
|
+
readonly operation: string;
|
|
137
|
+
readonly idempotencyKey: string;
|
|
138
|
+
readonly nonce: string;
|
|
139
|
+
readonly issuedAt: string;
|
|
140
|
+
readonly expiresAt: string;
|
|
141
|
+
readonly envelopeDigest: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ConsequenceActuatorOutcome = 'COMMITTED' | 'INDETERMINATE';
|
|
145
|
+
|
|
146
|
+
export interface ConsequenceActuatorConsumption
|
|
147
|
+
extends ConsequenceActuatorReservation {
|
|
148
|
+
readonly outcome: ConsequenceActuatorOutcome;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ConsequenceActuatorStore {
|
|
152
|
+
/** Explicit marker for the built-in non-production test store. */
|
|
153
|
+
readonly testOnly?: true;
|
|
154
|
+
readonly durable: boolean;
|
|
155
|
+
readonly atomic: boolean;
|
|
156
|
+
readonly ownershipFenced: boolean;
|
|
157
|
+
readonly permanentConsumption: boolean;
|
|
158
|
+
reserve(reservation: ConsequenceActuatorReservation): Promise<boolean>;
|
|
159
|
+
consume(consumption: ConsequenceActuatorConsumption): Promise<boolean>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ConsequenceActuatorPgQueryResult {
|
|
163
|
+
readonly rowCount: number | null;
|
|
164
|
+
readonly rows: readonly unknown[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A dedicated pool wrapper must label the login principal configured in its
|
|
169
|
+
* connection string. PostgreSQL independently enforces that SESSION_USER is a
|
|
170
|
+
* tenant-mapped member of consequence_actuator_executor.
|
|
171
|
+
*/
|
|
172
|
+
export interface DedicatedConsequenceActuatorExecutorPool {
|
|
173
|
+
readonly principal: string;
|
|
174
|
+
query(
|
|
175
|
+
text: string,
|
|
176
|
+
values: readonly unknown[],
|
|
177
|
+
): Promise<ConsequenceActuatorPgQueryResult>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface PostgresConsequenceActuatorStoreOptions {
|
|
181
|
+
readonly tenantId: string;
|
|
182
|
+
readonly executorPrincipal: string;
|
|
183
|
+
readonly executorPool: DedicatedConsequenceActuatorExecutorPool;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ConsequenceActuatorExecutionInput {
|
|
187
|
+
envelope: unknown;
|
|
188
|
+
attemptId: string;
|
|
189
|
+
actionDigest: string;
|
|
190
|
+
idempotencyKey: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type ConsequenceActuatorRefusalReason =
|
|
194
|
+
| 'malformed_envelope'
|
|
195
|
+
| 'unsupported_version'
|
|
196
|
+
| 'signer_key_mismatch'
|
|
197
|
+
| 'issuer_mismatch'
|
|
198
|
+
| 'tenant_mismatch'
|
|
199
|
+
| 'caid_mismatch'
|
|
200
|
+
| 'provider_account_mismatch'
|
|
201
|
+
| 'target_mismatch'
|
|
202
|
+
| 'operation_mismatch'
|
|
203
|
+
| 'attempt_mismatch'
|
|
204
|
+
| 'action_digest_mismatch'
|
|
205
|
+
| 'idempotency_key_mismatch'
|
|
206
|
+
| 'envelope_not_yet_valid'
|
|
207
|
+
| 'envelope_expired'
|
|
208
|
+
| 'envelope_ttl_exceeded'
|
|
209
|
+
| 'signature_invalid'
|
|
210
|
+
| 'store_reserve_failed'
|
|
211
|
+
| 'envelope_replayed'
|
|
212
|
+
| 'provider_outcome_indeterminate'
|
|
213
|
+
| 'store_consume_unconfirmed';
|
|
214
|
+
|
|
215
|
+
export type ConsequenceActuatorExecutionResult<TResult> =
|
|
216
|
+
| {
|
|
217
|
+
readonly ok: true;
|
|
218
|
+
readonly invoked: true;
|
|
219
|
+
readonly result: TResult;
|
|
220
|
+
readonly envelopeDigest: string;
|
|
221
|
+
}
|
|
222
|
+
| {
|
|
223
|
+
readonly ok: false;
|
|
224
|
+
readonly invoked: boolean;
|
|
225
|
+
readonly reason: ConsequenceActuatorRefusalReason;
|
|
226
|
+
readonly envelopeDigest?: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export type ConsequenceEnvelopeVerification =
|
|
230
|
+
| {
|
|
231
|
+
readonly ok: true;
|
|
232
|
+
readonly payload: Readonly<ConsequenceExecutionEnvelopePayload>;
|
|
233
|
+
readonly envelopeDigest: string;
|
|
234
|
+
}
|
|
235
|
+
| {
|
|
236
|
+
readonly ok: false;
|
|
237
|
+
readonly reason: Exclude<
|
|
238
|
+
ConsequenceActuatorRefusalReason,
|
|
239
|
+
| 'store_reserve_failed'
|
|
240
|
+
| 'envelope_replayed'
|
|
241
|
+
| 'provider_outcome_indeterminate'
|
|
242
|
+
| 'store_consume_unconfirmed'
|
|
243
|
+
>;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
interface NormalizedPins {
|
|
247
|
+
visible: VisibleConsequenceActuatorPins;
|
|
248
|
+
verificationKey: KeyObject;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface VerifyExpectedBinding {
|
|
252
|
+
attemptId: string;
|
|
253
|
+
actionDigest: string;
|
|
254
|
+
idempotencyKey: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface VerifyOptions {
|
|
258
|
+
pins: ConsequenceActuatorPins;
|
|
259
|
+
expected: VerifyExpectedBinding;
|
|
260
|
+
now?: number | (() => number);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface ConsequenceActuatorOptions<TResult> {
|
|
264
|
+
pins: ConsequenceActuatorPins;
|
|
265
|
+
store: ConsequenceActuatorStore;
|
|
266
|
+
readonly testOnly?: true;
|
|
267
|
+
perform: (
|
|
268
|
+
binding: Readonly<ConsequenceExecutionEnvelopePayload>,
|
|
269
|
+
) => TResult | Promise<TResult>;
|
|
270
|
+
now?: number | (() => number);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface SignEnvelopeOptions {
|
|
274
|
+
privateKey: KeyMaterial;
|
|
275
|
+
keyId: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface MemoryConsequenceActuatorSnapshot
|
|
279
|
+
extends ConsequenceActuatorReservation {
|
|
280
|
+
readonly state: 'RESERVED' | 'CONSUMED';
|
|
281
|
+
readonly outcome: ConsequenceActuatorOutcome | null;
|
|
282
|
+
readonly reservedAt: string;
|
|
283
|
+
readonly consumedAt: string | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
287
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function exactKeys(
|
|
291
|
+
value: Record<string, unknown>,
|
|
292
|
+
expected: readonly string[],
|
|
293
|
+
): boolean {
|
|
294
|
+
const keys = Reflect.ownKeys(value);
|
|
295
|
+
return keys.length === expected.length
|
|
296
|
+
&& keys.every((key) => typeof key === 'string' && expected.includes(key));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function deepFreeze<T>(value: T): T {
|
|
300
|
+
if (value === null || typeof value !== 'object') return value;
|
|
301
|
+
const stack: object[] = [value];
|
|
302
|
+
const seen = new WeakSet<object>();
|
|
303
|
+
while (stack.length > 0) {
|
|
304
|
+
const current = stack.pop()!;
|
|
305
|
+
if (seen.has(current)) continue;
|
|
306
|
+
seen.add(current);
|
|
307
|
+
for (const child of Object.values(current)) {
|
|
308
|
+
if (child !== null && typeof child === 'object') stack.push(child);
|
|
309
|
+
}
|
|
310
|
+
Object.freeze(current);
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function canonicalClone(value: unknown): {
|
|
316
|
+
canonical: string;
|
|
317
|
+
value: unknown;
|
|
318
|
+
} {
|
|
319
|
+
const canonical = canonicalize(value);
|
|
320
|
+
return {
|
|
321
|
+
canonical,
|
|
322
|
+
value: deepFreeze(JSON.parse(canonical)),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function validIdentifier(value: unknown): value is string {
|
|
327
|
+
return typeof value === 'string'
|
|
328
|
+
&& IDENTIFIER_PATTERN.test(value)
|
|
329
|
+
&& Buffer.byteLength(value, 'utf8') <= 256;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validDigest(value: unknown): value is string {
|
|
333
|
+
return typeof value === 'string' && DIGEST_PATTERN.test(value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function canonicalInstant(value: unknown): number | null {
|
|
337
|
+
if (typeof value !== 'string') return null;
|
|
338
|
+
const milliseconds = Date.parse(value);
|
|
339
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) return null;
|
|
340
|
+
return new Date(milliseconds).toISOString() === value ? milliseconds : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function decodeCanonicalBase64Url(
|
|
344
|
+
value: unknown,
|
|
345
|
+
minimumBytes: number,
|
|
346
|
+
maximumBytes: number,
|
|
347
|
+
): Buffer | null {
|
|
348
|
+
if (typeof value !== 'string' || !BASE64URL_PATTERN.test(value)) return null;
|
|
349
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
350
|
+
if (
|
|
351
|
+
bytes.length < minimumBytes
|
|
352
|
+
|| bytes.length > maximumBytes
|
|
353
|
+
|| bytes.toString('base64url') !== value
|
|
354
|
+
) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return bytes;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function nowMilliseconds(now: number | (() => number) | undefined): number {
|
|
361
|
+
const value = typeof now === 'function' ? now() : (now ?? Date.now());
|
|
362
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
363
|
+
throw new TypeError(
|
|
364
|
+
'consequence actuator clock must return a non-negative safe integer',
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normalizePrivateKey(value: KeyMaterial): KeyObject {
|
|
371
|
+
const key =
|
|
372
|
+
typeof value === 'object' && value !== null && 'type' in value
|
|
373
|
+
? value as KeyObject
|
|
374
|
+
: createPrivateKey(value);
|
|
375
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
|
|
376
|
+
throw new TypeError('execution envelopes require an Ed25519 private key');
|
|
377
|
+
}
|
|
378
|
+
return key;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isKeyObject(value: KeyMaterial): value is KeyObject {
|
|
382
|
+
return typeof value === 'object'
|
|
383
|
+
&& value !== null
|
|
384
|
+
&& 'type' in value
|
|
385
|
+
&& ['private', 'public', 'secret'].includes((value as KeyObject).type);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizePublicKey(value: KeyMaterial): KeyObject {
|
|
389
|
+
let imported: KeyObject;
|
|
390
|
+
if (isKeyObject(value)) {
|
|
391
|
+
if (value.type !== 'public') {
|
|
392
|
+
throw new TypeError(
|
|
393
|
+
'the actuator must receive a public verification key',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
imported = value;
|
|
397
|
+
} else {
|
|
398
|
+
imported = createPublicKey(value);
|
|
399
|
+
}
|
|
400
|
+
if (
|
|
401
|
+
imported.type !== 'public'
|
|
402
|
+
|| imported.asymmetricKeyType !== 'ed25519'
|
|
403
|
+
) {
|
|
404
|
+
throw new TypeError('the actuator verification pin must be Ed25519');
|
|
405
|
+
}
|
|
406
|
+
const der = imported.export({ type: 'spki', format: 'der' });
|
|
407
|
+
return createPublicKey({ key: Buffer.from(der), type: 'spki', format: 'der' });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function publicKeyFingerprint(key: KeyObject): string {
|
|
411
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
412
|
+
return `sha256:${createHash('sha256').update(der).digest('hex')}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function signatureInput(canonicalPayload: string): Buffer {
|
|
416
|
+
return Buffer.concat([
|
|
417
|
+
Buffer.from(CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN, 'utf8'),
|
|
418
|
+
Buffer.from([0]),
|
|
419
|
+
Buffer.from(canonicalPayload, 'utf8'),
|
|
420
|
+
]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function validatePayloadShape(
|
|
424
|
+
value: unknown,
|
|
425
|
+
): value is ConsequenceExecutionEnvelopePayload {
|
|
426
|
+
if (!isRecord(value) || !exactKeys(value, PAYLOAD_KEYS)) return false;
|
|
427
|
+
const issuedAt = canonicalInstant(value.issued_at);
|
|
428
|
+
const expiresAt = canonicalInstant(value.expires_at);
|
|
429
|
+
return value['@version'] === CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION
|
|
430
|
+
&& validIdentifier(value.issuer_id)
|
|
431
|
+
&& validIdentifier(value.tenant_id)
|
|
432
|
+
&& validIdentifier(value.attempt_id)
|
|
433
|
+
&& validDigest(value.action_digest)
|
|
434
|
+
&& typeof value.caid === 'string'
|
|
435
|
+
&& CAID_PATTERN.test(value.caid)
|
|
436
|
+
&& validIdentifier(value.provider_account_id)
|
|
437
|
+
&& validDigest(value.target_digest)
|
|
438
|
+
&& validIdentifier(value.operation)
|
|
439
|
+
&& validIdentifier(value.idempotency_key)
|
|
440
|
+
&& decodeCanonicalBase64Url(value.nonce, 16, 64) !== null
|
|
441
|
+
&& issuedAt !== null
|
|
442
|
+
&& expiresAt !== null
|
|
443
|
+
&& expiresAt > issuedAt;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizePins(pins: ConsequenceActuatorPins): NormalizedPins {
|
|
447
|
+
if (!isRecord(pins)) {
|
|
448
|
+
throw new TypeError('consequence actuator pins are required');
|
|
449
|
+
}
|
|
450
|
+
if (
|
|
451
|
+
!validIdentifier(pins.tenantId)
|
|
452
|
+
|| typeof pins.caid !== 'string'
|
|
453
|
+
|| !CAID_PATTERN.test(pins.caid)
|
|
454
|
+
|| !validIdentifier(pins.providerAccountId)
|
|
455
|
+
|| !validDigest(pins.targetDigest)
|
|
456
|
+
|| !validIdentifier(pins.operation)
|
|
457
|
+
|| !validIdentifier(pins.envelopeIssuerId)
|
|
458
|
+
|| !validIdentifier(pins.envelopeKeyId)
|
|
459
|
+
) {
|
|
460
|
+
throw new TypeError('consequence actuator pins are malformed');
|
|
461
|
+
}
|
|
462
|
+
const maxEnvelopeTtlMs =
|
|
463
|
+
pins.maxEnvelopeTtlMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS;
|
|
464
|
+
const clockSkewMs =
|
|
465
|
+
pins.clockSkewMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS;
|
|
466
|
+
if (
|
|
467
|
+
!Number.isSafeInteger(maxEnvelopeTtlMs)
|
|
468
|
+
|| maxEnvelopeTtlMs < 1
|
|
469
|
+
|| maxEnvelopeTtlMs > MAX_CONFIGURED_TTL_MS
|
|
470
|
+
) {
|
|
471
|
+
throw new TypeError(
|
|
472
|
+
'maxEnvelopeTtlMs must be between 1 millisecond and 5 minutes',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (
|
|
476
|
+
!Number.isSafeInteger(clockSkewMs)
|
|
477
|
+
|| clockSkewMs < 0
|
|
478
|
+
|| clockSkewMs > MAX_CLOCK_SKEW_MS
|
|
479
|
+
) {
|
|
480
|
+
throw new TypeError('clockSkewMs must be between 0 and 30 seconds');
|
|
481
|
+
}
|
|
482
|
+
const verificationKey = normalizePublicKey(pins.envelopePublicKey);
|
|
483
|
+
const visible = deepFreeze({
|
|
484
|
+
tenantId: pins.tenantId,
|
|
485
|
+
caid: pins.caid,
|
|
486
|
+
providerAccountId: pins.providerAccountId,
|
|
487
|
+
targetDigest: pins.targetDigest,
|
|
488
|
+
operation: pins.operation,
|
|
489
|
+
envelopeIssuerId: pins.envelopeIssuerId,
|
|
490
|
+
envelopeKeyId: pins.envelopeKeyId,
|
|
491
|
+
envelopePublicKeyFingerprint: publicKeyFingerprint(verificationKey),
|
|
492
|
+
maxEnvelopeTtlMs,
|
|
493
|
+
clockSkewMs,
|
|
494
|
+
});
|
|
495
|
+
return { visible, verificationKey };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function verifyWithNormalizedPins(
|
|
499
|
+
envelope: unknown,
|
|
500
|
+
pins: NormalizedPins,
|
|
501
|
+
expected: VerifyExpectedBinding,
|
|
502
|
+
now: number | (() => number) | undefined,
|
|
503
|
+
): ConsequenceEnvelopeVerification {
|
|
504
|
+
let cloned: unknown;
|
|
505
|
+
let canonicalEnvelope: string;
|
|
506
|
+
try {
|
|
507
|
+
const result = canonicalClone(envelope);
|
|
508
|
+
cloned = result.value;
|
|
509
|
+
canonicalEnvelope = result.canonical;
|
|
510
|
+
} catch {
|
|
511
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
512
|
+
}
|
|
513
|
+
if (!isRecord(cloned) || !exactKeys(cloned, ENVELOPE_KEYS)) {
|
|
514
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
515
|
+
}
|
|
516
|
+
if (!isRecord(cloned.payload)) {
|
|
517
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
518
|
+
}
|
|
519
|
+
if (cloned.payload['@version'] !== CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION) {
|
|
520
|
+
return { ok: false, reason: 'unsupported_version' };
|
|
521
|
+
}
|
|
522
|
+
if (!validatePayloadShape(cloned.payload)) {
|
|
523
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
524
|
+
}
|
|
525
|
+
if (
|
|
526
|
+
!isRecord(cloned.signature)
|
|
527
|
+
|| !exactKeys(cloned.signature, SIGNATURE_KEYS)
|
|
528
|
+
|| cloned.signature.algorithm !== CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM
|
|
529
|
+
|| !validIdentifier(cloned.signature.key_id)
|
|
530
|
+
) {
|
|
531
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
532
|
+
}
|
|
533
|
+
const signatureBytes = decodeCanonicalBase64Url(
|
|
534
|
+
cloned.signature.value,
|
|
535
|
+
64,
|
|
536
|
+
64,
|
|
537
|
+
);
|
|
538
|
+
if (signatureBytes === null) {
|
|
539
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const payload = cloned.payload;
|
|
543
|
+
const signature = cloned.signature;
|
|
544
|
+
if (signature.key_id !== pins.visible.envelopeKeyId) {
|
|
545
|
+
return { ok: false, reason: 'signer_key_mismatch' };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const canonicalPayload = canonicalize(payload);
|
|
549
|
+
let validSignature = false;
|
|
550
|
+
try {
|
|
551
|
+
validSignature = verify(
|
|
552
|
+
null,
|
|
553
|
+
signatureInput(canonicalPayload),
|
|
554
|
+
pins.verificationKey,
|
|
555
|
+
signatureBytes,
|
|
556
|
+
);
|
|
557
|
+
} catch {
|
|
558
|
+
validSignature = false;
|
|
559
|
+
}
|
|
560
|
+
if (!validSignature) {
|
|
561
|
+
return { ok: false, reason: 'signature_invalid' };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (payload.issuer_id !== pins.visible.envelopeIssuerId) {
|
|
565
|
+
return { ok: false, reason: 'issuer_mismatch' };
|
|
566
|
+
}
|
|
567
|
+
if (payload.tenant_id !== pins.visible.tenantId) {
|
|
568
|
+
return { ok: false, reason: 'tenant_mismatch' };
|
|
569
|
+
}
|
|
570
|
+
if (payload.caid !== pins.visible.caid) {
|
|
571
|
+
return { ok: false, reason: 'caid_mismatch' };
|
|
572
|
+
}
|
|
573
|
+
if (payload.provider_account_id !== pins.visible.providerAccountId) {
|
|
574
|
+
return { ok: false, reason: 'provider_account_mismatch' };
|
|
575
|
+
}
|
|
576
|
+
if (payload.target_digest !== pins.visible.targetDigest) {
|
|
577
|
+
return { ok: false, reason: 'target_mismatch' };
|
|
578
|
+
}
|
|
579
|
+
if (payload.operation !== pins.visible.operation) {
|
|
580
|
+
return { ok: false, reason: 'operation_mismatch' };
|
|
581
|
+
}
|
|
582
|
+
if (payload.attempt_id !== expected.attemptId) {
|
|
583
|
+
return { ok: false, reason: 'attempt_mismatch' };
|
|
584
|
+
}
|
|
585
|
+
if (payload.action_digest !== expected.actionDigest) {
|
|
586
|
+
return { ok: false, reason: 'action_digest_mismatch' };
|
|
587
|
+
}
|
|
588
|
+
if (payload.idempotency_key !== expected.idempotencyKey) {
|
|
589
|
+
return { ok: false, reason: 'idempotency_key_mismatch' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let currentTime: number;
|
|
593
|
+
try {
|
|
594
|
+
currentTime = nowMilliseconds(now);
|
|
595
|
+
} catch {
|
|
596
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
597
|
+
}
|
|
598
|
+
const issuedAt = Date.parse(payload.issued_at);
|
|
599
|
+
const expiresAt = Date.parse(payload.expires_at);
|
|
600
|
+
if (issuedAt > currentTime + pins.visible.clockSkewMs) {
|
|
601
|
+
return { ok: false, reason: 'envelope_not_yet_valid' };
|
|
602
|
+
}
|
|
603
|
+
if (expiresAt <= currentTime) {
|
|
604
|
+
return { ok: false, reason: 'envelope_expired' };
|
|
605
|
+
}
|
|
606
|
+
if (expiresAt - issuedAt > pins.visible.maxEnvelopeTtlMs) {
|
|
607
|
+
return { ok: false, reason: 'envelope_ttl_exceeded' };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
ok: true,
|
|
612
|
+
payload,
|
|
613
|
+
envelopeDigest: `sha256:${createHash('sha256')
|
|
614
|
+
.update(canonicalEnvelope)
|
|
615
|
+
.digest('hex')}`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function reservationFrom(
|
|
620
|
+
payload: Readonly<ConsequenceExecutionEnvelopePayload>,
|
|
621
|
+
envelopeDigest: string,
|
|
622
|
+
): ConsequenceActuatorReservation {
|
|
623
|
+
return deepFreeze({
|
|
624
|
+
tenantId: payload.tenant_id,
|
|
625
|
+
attemptId: payload.attempt_id,
|
|
626
|
+
actionDigest: payload.action_digest,
|
|
627
|
+
caid: payload.caid,
|
|
628
|
+
providerAccountId: payload.provider_account_id,
|
|
629
|
+
targetDigest: payload.target_digest,
|
|
630
|
+
operation: payload.operation,
|
|
631
|
+
idempotencyKey: payload.idempotency_key,
|
|
632
|
+
nonce: payload.nonce,
|
|
633
|
+
issuedAt: payload.issued_at,
|
|
634
|
+
expiresAt: payload.expires_at,
|
|
635
|
+
envelopeDigest,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function refusal(
|
|
640
|
+
reason: ConsequenceActuatorRefusalReason,
|
|
641
|
+
invoked: boolean,
|
|
642
|
+
envelopeDigest?: string,
|
|
643
|
+
): ConsequenceActuatorExecutionResult<never> {
|
|
644
|
+
return deepFreeze({
|
|
645
|
+
ok: false,
|
|
646
|
+
invoked,
|
|
647
|
+
reason,
|
|
648
|
+
...(envelopeDigest === undefined ? {} : { envelopeDigest }),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Create a closed Ed25519 execution envelope for an already-authorized effect. */
|
|
653
|
+
export function signConsequenceExecutionEnvelope(
|
|
654
|
+
payload: ConsequenceExecutionEnvelopePayload,
|
|
655
|
+
options: SignEnvelopeOptions,
|
|
656
|
+
): SignedConsequenceExecutionEnvelope {
|
|
657
|
+
const cloned = canonicalClone(payload);
|
|
658
|
+
if (!validatePayloadShape(cloned.value)) {
|
|
659
|
+
throw new TypeError('execution envelope payload is malformed');
|
|
660
|
+
}
|
|
661
|
+
if (!validIdentifier(options?.keyId)) {
|
|
662
|
+
throw new TypeError('execution envelope keyId is malformed');
|
|
663
|
+
}
|
|
664
|
+
const signature = sign(
|
|
665
|
+
null,
|
|
666
|
+
signatureInput(cloned.canonical),
|
|
667
|
+
normalizePrivateKey(options.privateKey),
|
|
668
|
+
).toString('base64url');
|
|
669
|
+
return deepFreeze({
|
|
670
|
+
payload: cloned.value,
|
|
671
|
+
signature: {
|
|
672
|
+
algorithm: CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM,
|
|
673
|
+
key_id: options.keyId,
|
|
674
|
+
value: signature,
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Verify an envelope without invoking a provider or mutating replay state. */
|
|
680
|
+
export function verifyConsequenceExecutionEnvelope(
|
|
681
|
+
envelope: unknown,
|
|
682
|
+
options: VerifyOptions,
|
|
683
|
+
): ConsequenceEnvelopeVerification {
|
|
684
|
+
try {
|
|
685
|
+
return verifyWithNormalizedPins(
|
|
686
|
+
envelope,
|
|
687
|
+
normalizePins(options.pins),
|
|
688
|
+
options.expected,
|
|
689
|
+
options.now,
|
|
690
|
+
);
|
|
691
|
+
} catch {
|
|
692
|
+
return { ok: false, reason: 'malformed_envelope' };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Credential-owning actuator. `perform` captures the provider credential in
|
|
698
|
+
* the actuator process; callers cannot submit or replace that credential.
|
|
699
|
+
*/
|
|
700
|
+
export class ConsequenceActuator<TResult = unknown> {
|
|
701
|
+
readonly pins: VisibleConsequenceActuatorPins;
|
|
702
|
+
|
|
703
|
+
readonly #normalizedPins: NormalizedPins;
|
|
704
|
+
readonly #reserve: ConsequenceActuatorStore['reserve'];
|
|
705
|
+
readonly #consume: ConsequenceActuatorStore['consume'];
|
|
706
|
+
readonly #perform: (
|
|
707
|
+
binding: Readonly<ConsequenceExecutionEnvelopePayload>,
|
|
708
|
+
) => TResult | Promise<TResult>;
|
|
709
|
+
readonly #now: number | (() => number) | undefined;
|
|
710
|
+
|
|
711
|
+
constructor(options: ConsequenceActuatorOptions<TResult>) {
|
|
712
|
+
if (
|
|
713
|
+
!isRecord(options)
|
|
714
|
+
|| !isRecord(options.store)
|
|
715
|
+
|| typeof options.store.reserve !== 'function'
|
|
716
|
+
|| typeof options.store.consume !== 'function'
|
|
717
|
+
|| typeof options.perform !== 'function'
|
|
718
|
+
) {
|
|
719
|
+
throw new TypeError(
|
|
720
|
+
'consequence actuator requires reserve/consume storage and a provider callback',
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
const productionCapable = options.store.durable === true
|
|
724
|
+
&& options.store.atomic === true
|
|
725
|
+
&& options.store.ownershipFenced === true
|
|
726
|
+
&& options.store.permanentConsumption === true;
|
|
727
|
+
const explicitTestStore = options.store.testOnly === true
|
|
728
|
+
&& options.testOnly === true;
|
|
729
|
+
if (!productionCapable && !explicitTestStore) {
|
|
730
|
+
throw new TypeError(
|
|
731
|
+
'consequence actuator requires durable, atomic, ownership-fenced storage with permanent consumption; a non-production store requires testOnly: true',
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
this.#normalizedPins = normalizePins(options.pins);
|
|
735
|
+
this.pins = this.#normalizedPins.visible;
|
|
736
|
+
this.#reserve = options.store.reserve.bind(options.store);
|
|
737
|
+
this.#consume = options.store.consume.bind(options.store);
|
|
738
|
+
this.#perform = options.perform;
|
|
739
|
+
this.#now = options.now;
|
|
740
|
+
Object.freeze(this);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async execute(
|
|
744
|
+
input: ConsequenceActuatorExecutionInput,
|
|
745
|
+
): Promise<ConsequenceActuatorExecutionResult<TResult>> {
|
|
746
|
+
const verified = verifyWithNormalizedPins(
|
|
747
|
+
input?.envelope,
|
|
748
|
+
this.#normalizedPins,
|
|
749
|
+
{
|
|
750
|
+
attemptId: input?.attemptId,
|
|
751
|
+
actionDigest: input?.actionDigest,
|
|
752
|
+
idempotencyKey: input?.idempotencyKey,
|
|
753
|
+
},
|
|
754
|
+
this.#now,
|
|
755
|
+
);
|
|
756
|
+
if (!verified.ok) {
|
|
757
|
+
return refusal(verified.reason, false);
|
|
758
|
+
}
|
|
759
|
+
const reservation = reservationFrom(
|
|
760
|
+
verified.payload,
|
|
761
|
+
verified.envelopeDigest,
|
|
762
|
+
);
|
|
763
|
+
let reserved = false;
|
|
764
|
+
try {
|
|
765
|
+
reserved = await this.#reserve(reservation);
|
|
766
|
+
} catch {
|
|
767
|
+
return refusal(
|
|
768
|
+
'store_reserve_failed',
|
|
769
|
+
false,
|
|
770
|
+
verified.envelopeDigest,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (reserved !== true) {
|
|
774
|
+
return refusal(
|
|
775
|
+
'envelope_replayed',
|
|
776
|
+
false,
|
|
777
|
+
verified.envelopeDigest,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let providerResult: TResult;
|
|
782
|
+
try {
|
|
783
|
+
providerResult = await this.#perform(verified.payload);
|
|
784
|
+
} catch {
|
|
785
|
+
try {
|
|
786
|
+
const consumed = await this.#consume(
|
|
787
|
+
deepFreeze({ ...reservation, outcome: 'INDETERMINATE' as const }),
|
|
788
|
+
);
|
|
789
|
+
if (consumed !== true) {
|
|
790
|
+
return refusal(
|
|
791
|
+
'store_consume_unconfirmed',
|
|
792
|
+
true,
|
|
793
|
+
verified.envelopeDigest,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
} catch {
|
|
797
|
+
return refusal(
|
|
798
|
+
'store_consume_unconfirmed',
|
|
799
|
+
true,
|
|
800
|
+
verified.envelopeDigest,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
return refusal(
|
|
804
|
+
'provider_outcome_indeterminate',
|
|
805
|
+
true,
|
|
806
|
+
verified.envelopeDigest,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
const consumed = await this.#consume(
|
|
812
|
+
deepFreeze({ ...reservation, outcome: 'COMMITTED' as const }),
|
|
813
|
+
);
|
|
814
|
+
if (consumed !== true) {
|
|
815
|
+
return refusal(
|
|
816
|
+
'store_consume_unconfirmed',
|
|
817
|
+
true,
|
|
818
|
+
verified.envelopeDigest,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch {
|
|
822
|
+
return refusal(
|
|
823
|
+
'store_consume_unconfirmed',
|
|
824
|
+
true,
|
|
825
|
+
verified.envelopeDigest,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return deepFreeze({
|
|
829
|
+
ok: true,
|
|
830
|
+
invoked: true,
|
|
831
|
+
result: providerResult,
|
|
832
|
+
envelopeDigest: verified.envelopeDigest,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function reservationKey(
|
|
838
|
+
tenantId: string,
|
|
839
|
+
nonce: string,
|
|
840
|
+
): string {
|
|
841
|
+
return canonicalize([tenantId, nonce]);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function idempotencyKey(
|
|
845
|
+
reservation: ConsequenceActuatorReservation,
|
|
846
|
+
): string {
|
|
847
|
+
return canonicalize([
|
|
848
|
+
reservation.tenantId,
|
|
849
|
+
reservation.providerAccountId,
|
|
850
|
+
reservation.operation,
|
|
851
|
+
reservation.idempotencyKey,
|
|
852
|
+
]);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function sameReservation(
|
|
856
|
+
left: ConsequenceActuatorReservation,
|
|
857
|
+
right: ConsequenceActuatorReservation,
|
|
858
|
+
): boolean {
|
|
859
|
+
const identity = (value: ConsequenceActuatorReservation) => ({
|
|
860
|
+
tenantId: value.tenantId,
|
|
861
|
+
attemptId: value.attemptId,
|
|
862
|
+
actionDigest: value.actionDigest,
|
|
863
|
+
caid: value.caid,
|
|
864
|
+
providerAccountId: value.providerAccountId,
|
|
865
|
+
targetDigest: value.targetDigest,
|
|
866
|
+
operation: value.operation,
|
|
867
|
+
idempotencyKey: value.idempotencyKey,
|
|
868
|
+
nonce: value.nonce,
|
|
869
|
+
issuedAt: value.issuedAt,
|
|
870
|
+
expiresAt: value.expiresAt,
|
|
871
|
+
envelopeDigest: value.envelopeDigest,
|
|
872
|
+
});
|
|
873
|
+
return canonicalize(identity(left)) === canonicalize(identity(right));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Race-safe, process-local reference store for tests. Map mutations occur
|
|
878
|
+
* before each async method yields, so concurrent calls have one reservation
|
|
879
|
+
* winner. Production must use the RPC-only durable store from the migration.
|
|
880
|
+
*/
|
|
881
|
+
export class MemoryConsequenceActuatorStore
|
|
882
|
+
implements ConsequenceActuatorStore {
|
|
883
|
+
readonly testOnly = true as const;
|
|
884
|
+
readonly durable = false;
|
|
885
|
+
readonly atomic = true;
|
|
886
|
+
readonly ownershipFenced = false;
|
|
887
|
+
readonly permanentConsumption = false;
|
|
888
|
+
|
|
889
|
+
readonly #records = new Map<string, MemoryConsequenceActuatorSnapshot>();
|
|
890
|
+
readonly #idempotency = new Map<string, string>();
|
|
891
|
+
readonly #now: number | (() => number) | undefined;
|
|
892
|
+
|
|
893
|
+
constructor(options: { now?: number | (() => number) } = {}) {
|
|
894
|
+
this.#now = options.now;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async reserve(
|
|
898
|
+
reservation: ConsequenceActuatorReservation,
|
|
899
|
+
): Promise<boolean> {
|
|
900
|
+
const key = reservationKey(reservation.tenantId, reservation.nonce);
|
|
901
|
+
const operationKey = idempotencyKey(reservation);
|
|
902
|
+
if (this.#records.has(key) || this.#idempotency.has(operationKey)) {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
const reservedAt = new Date(nowMilliseconds(this.#now)).toISOString();
|
|
906
|
+
const snapshot = deepFreeze({
|
|
907
|
+
...reservation,
|
|
908
|
+
state: 'RESERVED' as const,
|
|
909
|
+
outcome: null,
|
|
910
|
+
reservedAt,
|
|
911
|
+
consumedAt: null,
|
|
912
|
+
});
|
|
913
|
+
this.#records.set(key, snapshot);
|
|
914
|
+
this.#idempotency.set(operationKey, key);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async consume(
|
|
919
|
+
consumption: ConsequenceActuatorConsumption,
|
|
920
|
+
): Promise<boolean> {
|
|
921
|
+
const key = reservationKey(consumption.tenantId, consumption.nonce);
|
|
922
|
+
const current = this.#records.get(key);
|
|
923
|
+
if (
|
|
924
|
+
current === undefined
|
|
925
|
+
|| current.state !== 'RESERVED'
|
|
926
|
+
|| !sameReservation(current, consumption)
|
|
927
|
+
|| !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)
|
|
928
|
+
) {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
this.#records.set(key, deepFreeze({
|
|
932
|
+
...current,
|
|
933
|
+
state: 'CONSUMED' as const,
|
|
934
|
+
outcome: consumption.outcome,
|
|
935
|
+
consumedAt: new Date(nowMilliseconds(this.#now)).toISOString(),
|
|
936
|
+
}));
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
snapshot(
|
|
941
|
+
tenantId: string,
|
|
942
|
+
nonce: string,
|
|
943
|
+
): MemoryConsequenceActuatorSnapshot | null {
|
|
944
|
+
const snapshot = this.#records.get(reservationKey(tenantId, nonce));
|
|
945
|
+
if (snapshot === undefined) return null;
|
|
946
|
+
return canonicalClone(snapshot).value as MemoryConsequenceActuatorSnapshot;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
get size(): number {
|
|
950
|
+
return this.#records.size;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export function createMemoryConsequenceActuatorStore(
|
|
955
|
+
options: { now?: number | (() => number) } = {},
|
|
956
|
+
): MemoryConsequenceActuatorStore {
|
|
957
|
+
return new MemoryConsequenceActuatorStore(options);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const FORBIDDEN_EXECUTOR_PRINCIPALS = new Set([
|
|
961
|
+
'anon',
|
|
962
|
+
'authenticated',
|
|
963
|
+
'postgres',
|
|
964
|
+
'service_role',
|
|
965
|
+
CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE,
|
|
966
|
+
CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE,
|
|
967
|
+
]);
|
|
968
|
+
|
|
969
|
+
function validStoreBinding(
|
|
970
|
+
reservation: ConsequenceActuatorReservation,
|
|
971
|
+
): boolean {
|
|
972
|
+
const issuedAt = canonicalInstant(reservation?.issuedAt);
|
|
973
|
+
const expiresAt = canonicalInstant(reservation?.expiresAt);
|
|
974
|
+
return validIdentifier(reservation?.tenantId)
|
|
975
|
+
&& validIdentifier(reservation?.attemptId)
|
|
976
|
+
&& validDigest(reservation?.actionDigest)
|
|
977
|
+
&& typeof reservation?.caid === 'string'
|
|
978
|
+
&& CAID_PATTERN.test(reservation.caid)
|
|
979
|
+
&& validIdentifier(reservation?.providerAccountId)
|
|
980
|
+
&& validDigest(reservation?.targetDigest)
|
|
981
|
+
&& validIdentifier(reservation?.operation)
|
|
982
|
+
&& validIdentifier(reservation?.idempotencyKey)
|
|
983
|
+
&& decodeCanonicalBase64Url(reservation?.nonce, 16, 64) !== null
|
|
984
|
+
&& issuedAt !== null
|
|
985
|
+
&& expiresAt !== null
|
|
986
|
+
&& expiresAt > issuedAt
|
|
987
|
+
&& validDigest(reservation?.envelopeDigest);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function exactPostgresAcknowledgement(
|
|
991
|
+
result: ConsequenceActuatorPgQueryResult,
|
|
992
|
+
expectedEnvelopeDigest: string,
|
|
993
|
+
): boolean {
|
|
994
|
+
if (
|
|
995
|
+
result !== null
|
|
996
|
+
&& typeof result === 'object'
|
|
997
|
+
&& result.rowCount === 0
|
|
998
|
+
&& Array.isArray(result.rows)
|
|
999
|
+
&& result.rows.length === 0
|
|
1000
|
+
) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
if (
|
|
1004
|
+
result === null
|
|
1005
|
+
|| typeof result !== 'object'
|
|
1006
|
+
|| result.rowCount !== 1
|
|
1007
|
+
|| !Array.isArray(result.rows)
|
|
1008
|
+
|| result.rows.length !== 1
|
|
1009
|
+
|| !isRecord(result.rows[0])
|
|
1010
|
+
|| result.rows[0].envelope_digest !== expectedEnvelopeDigest
|
|
1011
|
+
) {
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
'consequence actuator store returned an ambiguous acknowledgement',
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Production adapter for the RPC-only PostgreSQL store. The supplied pool must
|
|
1021
|
+
* be dedicated to one tenant-mapped executor login; the adapter never emits
|
|
1022
|
+
* direct table SQL.
|
|
1023
|
+
*/
|
|
1024
|
+
export class PostgresConsequenceActuatorStore
|
|
1025
|
+
implements ConsequenceActuatorStore {
|
|
1026
|
+
readonly durable = true;
|
|
1027
|
+
readonly atomic = true;
|
|
1028
|
+
readonly ownershipFenced = true;
|
|
1029
|
+
readonly permanentConsumption = true;
|
|
1030
|
+
readonly tenantId: string;
|
|
1031
|
+
readonly executorPrincipal: string;
|
|
1032
|
+
|
|
1033
|
+
readonly #query: DedicatedConsequenceActuatorExecutorPool['query'];
|
|
1034
|
+
|
|
1035
|
+
constructor(options: PostgresConsequenceActuatorStoreOptions) {
|
|
1036
|
+
if (
|
|
1037
|
+
!isRecord(options)
|
|
1038
|
+
|| !validIdentifier(options.tenantId)
|
|
1039
|
+
|| !validIdentifier(options.executorPrincipal)
|
|
1040
|
+
|| FORBIDDEN_EXECUTOR_PRINCIPALS.has(options.executorPrincipal)
|
|
1041
|
+
|| !isRecord(options.executorPool)
|
|
1042
|
+
|| options.executorPool.principal !== options.executorPrincipal
|
|
1043
|
+
|| typeof options.executorPool.query !== 'function'
|
|
1044
|
+
) {
|
|
1045
|
+
throw new TypeError(
|
|
1046
|
+
'a dedicated tenant executor principal and matching pool are required',
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
this.tenantId = options.tenantId;
|
|
1050
|
+
this.executorPrincipal = options.executorPrincipal;
|
|
1051
|
+
this.#query = options.executorPool.query.bind(options.executorPool);
|
|
1052
|
+
Object.freeze(this);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async reserve(
|
|
1056
|
+
reservation: ConsequenceActuatorReservation,
|
|
1057
|
+
): Promise<boolean> {
|
|
1058
|
+
if (
|
|
1059
|
+
!validStoreBinding(reservation)
|
|
1060
|
+
|| reservation.tenantId !== this.tenantId
|
|
1061
|
+
) {
|
|
1062
|
+
throw new TypeError(
|
|
1063
|
+
'consequence actuator reservation does not match the store tenant',
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const result = await this.#query(
|
|
1067
|
+
CONSEQUENCE_ACTUATOR_SQL.reserve,
|
|
1068
|
+
[
|
|
1069
|
+
reservation.tenantId,
|
|
1070
|
+
reservation.attemptId,
|
|
1071
|
+
reservation.actionDigest,
|
|
1072
|
+
reservation.caid,
|
|
1073
|
+
reservation.providerAccountId,
|
|
1074
|
+
reservation.targetDigest,
|
|
1075
|
+
reservation.operation,
|
|
1076
|
+
reservation.idempotencyKey,
|
|
1077
|
+
reservation.nonce,
|
|
1078
|
+
reservation.issuedAt,
|
|
1079
|
+
reservation.expiresAt,
|
|
1080
|
+
reservation.envelopeDigest,
|
|
1081
|
+
],
|
|
1082
|
+
);
|
|
1083
|
+
return exactPostgresAcknowledgement(result, reservation.envelopeDigest);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async consume(
|
|
1087
|
+
consumption: ConsequenceActuatorConsumption,
|
|
1088
|
+
): Promise<boolean> {
|
|
1089
|
+
if (
|
|
1090
|
+
!validStoreBinding(consumption)
|
|
1091
|
+
|| consumption.tenantId !== this.tenantId
|
|
1092
|
+
|| !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)
|
|
1093
|
+
) {
|
|
1094
|
+
throw new TypeError(
|
|
1095
|
+
'consequence actuator consumption does not match the store tenant',
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
const result = await this.#query(
|
|
1099
|
+
CONSEQUENCE_ACTUATOR_SQL.consume,
|
|
1100
|
+
[
|
|
1101
|
+
consumption.tenantId,
|
|
1102
|
+
consumption.attemptId,
|
|
1103
|
+
consumption.actionDigest,
|
|
1104
|
+
consumption.caid,
|
|
1105
|
+
consumption.providerAccountId,
|
|
1106
|
+
consumption.targetDigest,
|
|
1107
|
+
consumption.operation,
|
|
1108
|
+
consumption.idempotencyKey,
|
|
1109
|
+
consumption.nonce,
|
|
1110
|
+
consumption.envelopeDigest,
|
|
1111
|
+
consumption.outcome,
|
|
1112
|
+
],
|
|
1113
|
+
);
|
|
1114
|
+
return exactPostgresAcknowledgement(result, consumption.envelopeDigest);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function createPostgresConsequenceActuatorStore(
|
|
1119
|
+
options: PostgresConsequenceActuatorStoreOptions,
|
|
1120
|
+
): PostgresConsequenceActuatorStore {
|
|
1121
|
+
return new PostgresConsequenceActuatorStore(options);
|
|
1122
|
+
}
|