@emilia-protocol/gate 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +32 -1
  3. package/aeb-consumption-store.js +3 -0
  4. package/dist/aeb-consumption-store.d.ts +80 -0
  5. package/dist/aeb-consumption-store.d.ts.map +1 -0
  6. package/dist/aeb-consumption-store.js +430 -0
  7. package/dist/aeb-consumption-store.js.map +1 -0
  8. package/dist/capability-receipt.d.ts +8 -0
  9. package/dist/capability-receipt.d.ts.map +1 -1
  10. package/dist/capability-receipt.js +17 -0
  11. package/dist/capability-receipt.js.map +1 -1
  12. package/dist/index.d.ts +12 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +16 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/proposal-to-effect-postgres.d.ts +129 -0
  17. package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
  18. package/dist/proposal-to-effect-postgres.js +1511 -0
  19. package/dist/proposal-to-effect-postgres.js.map +1 -0
  20. package/dist/proposal-to-effect-status.d.ts +55 -0
  21. package/dist/proposal-to-effect-status.d.ts.map +1 -0
  22. package/dist/proposal-to-effect-status.js +280 -0
  23. package/dist/proposal-to-effect-status.js.map +1 -0
  24. package/dist/proposal-to-effect.d.ts +151 -3
  25. package/dist/proposal-to-effect.d.ts.map +1 -1
  26. package/dist/proposal-to-effect.js +589 -46
  27. package/dist/proposal-to-effect.js.map +1 -1
  28. package/dist/remedy-case-set-postgres.d.ts +73 -0
  29. package/dist/remedy-case-set-postgres.d.ts.map +1 -0
  30. package/dist/remedy-case-set-postgres.js +846 -0
  31. package/dist/remedy-case-set-postgres.js.map +1 -0
  32. package/dist/remedy-case-set.d.ts +53 -0
  33. package/dist/remedy-case-set.d.ts.map +1 -0
  34. package/dist/remedy-case-set.js +571 -0
  35. package/dist/remedy-case-set.js.map +1 -0
  36. package/dist/remedy-program-adapters.d.ts +137 -0
  37. package/dist/remedy-program-adapters.d.ts.map +1 -0
  38. package/dist/remedy-program-adapters.js +590 -0
  39. package/dist/remedy-program-adapters.js.map +1 -0
  40. package/dist/trust-program-revocation.js.map +1 -1
  41. package/package.json +278 -63
  42. package/proposal-to-effect-postgres.js +3 -0
  43. package/proposal-to-effect-status.js +2 -0
  44. package/remedy-case-set-postgres.js +3 -0
  45. package/remedy-case-set.js +3 -0
  46. package/remedy-program-adapters.js +3 -0
  47. package/src/aeb-consumption-store.ts +521 -0
  48. package/src/capability-receipt.ts +17 -0
  49. package/src/index.ts +52 -3
  50. package/src/proposal-to-effect-postgres.ts +1740 -0
  51. package/src/proposal-to-effect-status.ts +357 -0
  52. package/src/proposal-to-effect.ts +755 -50
  53. package/src/remedy-case-set-postgres.ts +1008 -0
  54. package/src/remedy-case-set.ts +603 -0
  55. package/src/remedy-program-adapters.ts +657 -0
  56. package/src/trust-program-revocation.ts +1 -1
@@ -0,0 +1,521 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PostgreSQL custody for AEB operation consumption and native replay fences.
4
+ *
5
+ * A reservation installs the operation key and every native replay key in one
6
+ * pinned transaction. Logical conflicts roll the transaction back; database
7
+ * errors propagate so callers fail closed. Commit and release are fenced by an
8
+ * opaque per-reservation token retained only by the store instance that won.
9
+ */
10
+ import crypto from 'node:crypto';
11
+ import type {
12
+ AebDurableConsumptionStore,
13
+ AebReservationResult,
14
+ } from '@emilia-protocol/verify/aeb-adapter-contract';
15
+
16
+ export const AEB_PG_CONSUMPTION_STORE_VERSION = 'EP-GATE-AEB-PG-CONSUMPTION-v1';
17
+ export const AEB_CONSUMPTION_OPERATION_TABLE = 'ep_aeb_consumption_operations';
18
+ export const AEB_CONSUMPTION_REPLAY_TABLE = 'ep_aeb_consumption_replay_fences';
19
+ export const AEB_CONSUMPTION_EXECUTOR_ROLE = 'ep_aeb_executor';
20
+ export const AEB_CONSUMPTION_RECOVERY_ROLE = 'ep_aeb_recovery';
21
+ export const AEB_CONSUMPTION_OWNER_ROLE = 'ep_aeb_store_owner';
22
+
23
+ /** Exact schema required by createPostgresAebDurableConsumptionStore(). */
24
+ export const AEB_CONSUMPTION_DDL = `CREATE TABLE IF NOT EXISTS ${AEB_CONSUMPTION_OPERATION_TABLE} (
25
+ tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
26
+ relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),
27
+ operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),
28
+ state TEXT NOT NULL CHECK (state IN ('RESERVED', 'CONSUMED')),
29
+ owner_token TEXT NULL CHECK (owner_token IS NULL OR octet_length(owner_token) BETWEEN 16 AND 512),
30
+ reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
31
+ consumed_at TIMESTAMPTZ NULL,
32
+ PRIMARY KEY (tenant_id, relying_party_id, operation_key),
33
+ CHECK (
34
+ (state = 'RESERVED' AND owner_token IS NOT NULL AND consumed_at IS NULL)
35
+ OR (state = 'CONSUMED' AND owner_token IS NULL AND consumed_at IS NOT NULL)
36
+ )
37
+ );
38
+ CREATE TABLE IF NOT EXISTS ${AEB_CONSUMPTION_REPLAY_TABLE} (
39
+ tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
40
+ relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),
41
+ replay_key TEXT NOT NULL CHECK (octet_length(replay_key) BETWEEN 1 AND 4096),
42
+ operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),
43
+ reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
44
+ PRIMARY KEY (tenant_id, relying_party_id, replay_key),
45
+ FOREIGN KEY (tenant_id, relying_party_id, operation_key)
46
+ REFERENCES ${AEB_CONSUMPTION_OPERATION_TABLE} (tenant_id, relying_party_id, operation_key)
47
+ ON DELETE CASCADE
48
+ );
49
+ CREATE INDEX IF NOT EXISTS ${AEB_CONSUMPTION_REPLAY_TABLE}_operation_idx
50
+ ON ${AEB_CONSUMPTION_REPLAY_TABLE} (tenant_id, relying_party_id, operation_key);
51
+ DO $roles$
52
+ BEGIN
53
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_EXECUTOR_ROLE}') THEN
54
+ CREATE ROLE ${AEB_CONSUMPTION_EXECUTOR_ROLE} NOLOGIN
55
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
56
+ END IF;
57
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_RECOVERY_ROLE}') THEN
58
+ CREATE ROLE ${AEB_CONSUMPTION_RECOVERY_ROLE} NOLOGIN
59
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
60
+ END IF;
61
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_OWNER_ROLE}') THEN
62
+ CREATE ROLE ${AEB_CONSUMPTION_OWNER_ROLE} NOLOGIN
63
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
64
+ END IF;
65
+ END
66
+ $roles$;
67
+ GRANT ${AEB_CONSUMPTION_OWNER_ROLE} TO CURRENT_USER;
68
+ CREATE SCHEMA IF NOT EXISTS ep_aeb_private;
69
+ REVOKE ALL ON SCHEMA ep_aeb_private FROM PUBLIC, anon, authenticated, service_role;
70
+ CREATE TABLE IF NOT EXISTS ep_aeb_private.tenant_principals (
71
+ principal_name NAME NOT NULL,
72
+ tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
73
+ can_execute BOOLEAN NOT NULL DEFAULT FALSE,
74
+ can_recover BOOLEAN NOT NULL DEFAULT FALSE,
75
+ PRIMARY KEY (principal_name, tenant_id),
76
+ CHECK (can_execute OR can_recover)
77
+ );
78
+ ALTER SCHEMA ep_aeb_private OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
79
+ ALTER TABLE ep_aeb_private.tenant_principals OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
80
+ ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
81
+ ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
82
+ ALTER TABLE ep_aeb_private.tenant_principals ENABLE ROW LEVEL SECURITY;
83
+ ALTER TABLE ep_aeb_private.tenant_principals FORCE ROW LEVEL SECURITY;
84
+ ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} ENABLE ROW LEVEL SECURITY;
85
+ ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} FORCE ROW LEVEL SECURITY;
86
+ ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} ENABLE ROW LEVEL SECURITY;
87
+ ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} FORCE ROW LEVEL SECURITY;
88
+ DROP POLICY IF EXISTS ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals;
89
+ CREATE POLICY ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals
90
+ TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
91
+ DROP POLICY IF EXISTS ep_aeb_operations_owner_only ON ${AEB_CONSUMPTION_OPERATION_TABLE};
92
+ CREATE POLICY ep_aeb_operations_owner_only ON ${AEB_CONSUMPTION_OPERATION_TABLE}
93
+ TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
94
+ DROP POLICY IF EXISTS ep_aeb_replay_owner_only ON ${AEB_CONSUMPTION_REPLAY_TABLE};
95
+ CREATE POLICY ep_aeb_replay_owner_only ON ${AEB_CONSUMPTION_REPLAY_TABLE}
96
+ TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
97
+ REVOKE ALL ON ep_aeb_private.tenant_principals FROM PUBLIC, anon, authenticated, service_role,
98
+ ${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
99
+ REVOKE ALL ON ${AEB_CONSUMPTION_OPERATION_TABLE} FROM PUBLIC, anon, authenticated, service_role,
100
+ ${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
101
+ REVOKE ALL ON ${AEB_CONSUMPTION_REPLAY_TABLE} FROM PUBLIC, anon, authenticated, service_role,
102
+ ${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
103
+ CREATE OR REPLACE FUNCTION ep_aeb_private.assert_tenant_principal(
104
+ p_tenant_id TEXT, p_recovery BOOLEAN
105
+ ) RETURNS VOID
106
+ LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = ''
107
+ AS $fn$
108
+ DECLARE v_role_ok BOOLEAN; v_binding_ok BOOLEAN;
109
+ BEGIN
110
+ v_role_ok := CASE WHEN p_recovery
111
+ THEN pg_catalog.pg_has_role(SESSION_USER, '${AEB_CONSUMPTION_RECOVERY_ROLE}', 'MEMBER')
112
+ ELSE pg_catalog.pg_has_role(SESSION_USER, '${AEB_CONSUMPTION_EXECUTOR_ROLE}', 'MEMBER')
113
+ END;
114
+ SELECT EXISTS (
115
+ SELECT 1 FROM ep_aeb_private.tenant_principals AS principals
116
+ WHERE principals.principal_name = SESSION_USER
117
+ AND principals.tenant_id = p_tenant_id
118
+ AND CASE WHEN p_recovery THEN principals.can_recover ELSE principals.can_execute END
119
+ ) INTO v_binding_ok;
120
+ IF v_role_ok IS NOT TRUE OR v_binding_ok IS NOT TRUE THEN
121
+ RAISE EXCEPTION 'AEB_TENANT_PRINCIPAL_REFUSED' USING ERRCODE = '42501';
122
+ END IF;
123
+ END
124
+ $fn$;
125
+ CREATE OR REPLACE FUNCTION ep_aeb_private.reserve_operation(
126
+ p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
127
+ ) RETURNS TABLE(operation_key TEXT)
128
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
129
+ AS $fn$
130
+ BEGIN
131
+ PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
132
+ RETURN QUERY INSERT INTO public.${AEB_CONSUMPTION_OPERATION_TABLE}
133
+ (tenant_id, relying_party_id, operation_key, state, owner_token)
134
+ VALUES (p_tenant_id, p_relying_party_id, p_operation_key, 'RESERVED', p_owner_token)
135
+ ON CONFLICT ON CONSTRAINT ep_aeb_consumption_operations_pkey DO NOTHING
136
+ RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
137
+ END
138
+ $fn$;
139
+ CREATE OR REPLACE FUNCTION ep_aeb_private.reserve_replay_keys(
140
+ p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_replay_keys TEXT[]
141
+ ) RETURNS TABLE(replay_key TEXT)
142
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
143
+ AS $fn$
144
+ BEGIN
145
+ PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
146
+ RETURN QUERY INSERT INTO public.${AEB_CONSUMPTION_REPLAY_TABLE}
147
+ (tenant_id, relying_party_id, replay_key, operation_key)
148
+ SELECT p_tenant_id, p_relying_party_id, requested.replay_key, p_operation_key
149
+ FROM pg_catalog.unnest(p_replay_keys) AS requested(replay_key)
150
+ ORDER BY requested.replay_key
151
+ ON CONFLICT ON CONSTRAINT ep_aeb_consumption_replay_fences_pkey DO NOTHING
152
+ RETURNING ${AEB_CONSUMPTION_REPLAY_TABLE}.replay_key;
153
+ END
154
+ $fn$;
155
+ CREATE OR REPLACE FUNCTION ep_aeb_private.commit_operation(
156
+ p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
157
+ ) RETURNS TABLE(operation_key TEXT)
158
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
159
+ AS $fn$
160
+ BEGIN
161
+ PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
162
+ RETURN QUERY UPDATE public.${AEB_CONSUMPTION_OPERATION_TABLE}
163
+ SET state = 'CONSUMED', owner_token = NULL, consumed_at = pg_catalog.transaction_timestamp()
164
+ WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
165
+ AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
166
+ AND state = 'RESERVED' AND owner_token = p_owner_token
167
+ RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
168
+ END
169
+ $fn$;
170
+ CREATE OR REPLACE FUNCTION ep_aeb_private.claim_operation(
171
+ p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
172
+ ) RETURNS TABLE(operation_key TEXT)
173
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
174
+ AS $fn$
175
+ BEGIN
176
+ PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, TRUE);
177
+ RETURN QUERY UPDATE public.${AEB_CONSUMPTION_OPERATION_TABLE}
178
+ SET owner_token = p_owner_token
179
+ WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
180
+ AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
181
+ AND state = 'RESERVED'
182
+ RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
183
+ END
184
+ $fn$;
185
+ CREATE OR REPLACE FUNCTION ep_aeb_private.release_operation(
186
+ p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
187
+ ) RETURNS TABLE(operation_key TEXT)
188
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
189
+ AS $fn$
190
+ BEGIN
191
+ PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
192
+ RETURN QUERY DELETE FROM public.${AEB_CONSUMPTION_OPERATION_TABLE}
193
+ WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
194
+ AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
195
+ AND state = 'RESERVED' AND owner_token = p_owner_token
196
+ RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
197
+ END
198
+ $fn$;
199
+ ALTER FUNCTION ep_aeb_private.assert_tenant_principal(TEXT, BOOLEAN)
200
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
201
+ ALTER FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT)
202
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
203
+ ALTER FUNCTION ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[])
204
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
205
+ ALTER FUNCTION ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT)
206
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
207
+ ALTER FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)
208
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
209
+ ALTER FUNCTION ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)
210
+ OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
211
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ep_aeb_private
212
+ FROM PUBLIC, anon, authenticated, service_role;
213
+ GRANT USAGE ON SCHEMA ep_aeb_private TO ${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
214
+ GRANT EXECUTE ON FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT),
215
+ ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[]),
216
+ ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT),
217
+ ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)
218
+ TO ${AEB_CONSUMPTION_EXECUTOR_ROLE};
219
+ GRANT EXECUTE ON FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)
220
+ TO ${AEB_CONSUMPTION_RECOVERY_ROLE};
221
+ REVOKE ${AEB_CONSUMPTION_OWNER_ROLE} FROM CURRENT_USER;`;
222
+
223
+ /** Exact statements issued by the store, exported for audit and deterministic fakes. */
224
+ export const AEB_CONSUMPTION_SQL = Object.freeze({
225
+ reserveOperation: `SELECT operation_key FROM ep_aeb_private.reserve_operation($1::text, $2::text, $3::text, $4::text)`,
226
+ reserveReplayKeys: `SELECT replay_key FROM ep_aeb_private.reserve_replay_keys($1::text, $2::text, $3::text, $4::text[])`,
227
+ commitOperation: `SELECT operation_key FROM ep_aeb_private.commit_operation($1::text, $2::text, $3::text, $4::text)`,
228
+ claimOperation: `SELECT operation_key FROM ep_aeb_private.claim_operation($1::text, $2::text, $3::text, $4::text)`,
229
+ releaseOperation: `SELECT operation_key FROM ep_aeb_private.release_operation($1::text, $2::text, $3::text, $4::text)`,
230
+ });
231
+
232
+ type QueryResult = {
233
+ rowCount: number | null;
234
+ rows?: Record<string, unknown>[];
235
+ };
236
+
237
+ export type AebConsumptionPgClient = {
238
+ query: (text: string, params?: any[]) => Promise<QueryResult>;
239
+ release: () => void;
240
+ };
241
+
242
+ export type AebConsumptionPgPool = {
243
+ connect: () => Promise<AebConsumptionPgClient>;
244
+ };
245
+
246
+ export interface PostgresAebDurableConsumptionStoreOptions {
247
+ /** Pool authenticated as a tenant-bound member of ep_aeb_executor. */
248
+ pool?: AebConsumptionPgPool;
249
+ /** Distinct pool authenticated as a tenant-bound member of ep_aeb_recovery. */
250
+ recoveryPool?: AebConsumptionPgPool;
251
+ tenantId?: string;
252
+ relyingPartyId?: string;
253
+ /** Must return an unpredictable opaque string in production. */
254
+ ownerTokenFactory?: () => string;
255
+ /** Verify a caller credential bound to the exact reservation being claimed. */
256
+ authorizeRecoveryClaim?: AebRecoveryClaimAuthorizer;
257
+ }
258
+
259
+ export interface AebRecoveryClaimAuthorization {
260
+ authorization: unknown;
261
+ tenantId: string;
262
+ relyingPartyId: string;
263
+ operationKey: string;
264
+ requiredState: 'RESERVED';
265
+ }
266
+
267
+ export type AebRecoveryClaimAuthorizer = (
268
+ claim: Readonly<AebRecoveryClaimAuthorization>,
269
+ ) => boolean | Promise<boolean>;
270
+
271
+ export interface PostgresAebDurableConsumptionStore extends AebDurableConsumptionStore {
272
+ recoveryClaimSupported: true;
273
+ /**
274
+ * Rotate ownership of an existing RESERVED row after external authorization.
275
+ * The stored and replacement owner tokens are never returned or passed to
276
+ * the authorizer.
277
+ */
278
+ claimReservation(key: string, authorization: unknown): Promise<boolean>;
279
+ }
280
+
281
+ const BEGIN_WRITE = 'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE';
282
+ const COMMIT = 'COMMIT';
283
+ const ROLLBACK = 'ROLLBACK';
284
+
285
+ class ReservationConflict extends Error {
286
+ readonly result: Exclude<AebReservationResult, 'RESERVED'>;
287
+
288
+ constructor(result: Exclude<AebReservationResult, 'RESERVED'>) {
289
+ super(result);
290
+ this.result = result;
291
+ }
292
+ }
293
+
294
+ function defaultOwnerToken() {
295
+ return crypto.randomBytes(32).toString('base64url');
296
+ }
297
+
298
+ function assertText(
299
+ value: unknown,
300
+ label: string,
301
+ maximumBytes: number,
302
+ ): asserts value is string {
303
+ if (typeof value !== 'string'
304
+ || Buffer.byteLength(value, 'utf8') < 1
305
+ || Buffer.byteLength(value, 'utf8') > maximumBytes
306
+ || /[\u0000-\u001f\u007f]/.test(value)) {
307
+ throw new TypeError(`AEB consumption ${label} is invalid`);
308
+ }
309
+ }
310
+
311
+ function assertOwnerToken(value: unknown): asserts value is string {
312
+ if (typeof value !== 'string'
313
+ || Buffer.byteLength(value, 'utf8') < 16
314
+ || Buffer.byteLength(value, 'utf8') > 512
315
+ || /[\u0000-\u001f\u007f]/.test(value)) {
316
+ throw new TypeError('AEB consumption ownerTokenFactory must return an opaque string of 16 to 512 bytes');
317
+ }
318
+ }
319
+
320
+ function exactRowCount(result: QueryResult, operation: string) {
321
+ if (!result
322
+ || !Number.isSafeInteger(result.rowCount)
323
+ || (result.rowCount as number) < 0) {
324
+ throw new Error(`${operation}: malformed PostgreSQL result`);
325
+ }
326
+ return result.rowCount as number;
327
+ }
328
+
329
+ /**
330
+ * Create the durable AEB store consumed by authorizeAebExecutionDurable().
331
+ * The pool must return a pinned node-postgres-style client for each transaction.
332
+ */
333
+ export function createPostgresAebDurableConsumptionStore({
334
+ pool,
335
+ recoveryPool,
336
+ tenantId,
337
+ relyingPartyId,
338
+ ownerTokenFactory = defaultOwnerToken,
339
+ authorizeRecoveryClaim,
340
+ }: PostgresAebDurableConsumptionStoreOptions = {}): PostgresAebDurableConsumptionStore {
341
+ if (!pool || typeof pool.connect !== 'function') {
342
+ throw new TypeError('createPostgresAebDurableConsumptionStore requires an ep_aeb_executor pg pool');
343
+ }
344
+ if (!recoveryPool || typeof recoveryPool.connect !== 'function' || recoveryPool === pool) {
345
+ throw new TypeError('AEB consumption requires a distinct ep_aeb_recovery pg pool');
346
+ }
347
+ assertText(tenantId, 'tenantId', 512);
348
+ assertText(relyingPartyId, 'relyingPartyId', 512);
349
+ if (typeof ownerTokenFactory !== 'function') {
350
+ throw new TypeError('AEB consumption ownerTokenFactory must be a function');
351
+ }
352
+ if (typeof authorizeRecoveryClaim !== 'function') {
353
+ throw new TypeError('AEB consumption requires an authorizeRecoveryClaim callback');
354
+ }
355
+
356
+ async function transaction<T>(
357
+ activePool: AebConsumptionPgPool,
358
+ work: (client: AebConsumptionPgClient) => Promise<T>,
359
+ ): Promise<T> {
360
+ const client = await activePool.connect();
361
+ if (!client || typeof client.query !== 'function' || typeof client.release !== 'function') {
362
+ throw new TypeError('AEB consumption pg pool returned an invalid client');
363
+ }
364
+ let began = false;
365
+ try {
366
+ await client.query(BEGIN_WRITE);
367
+ began = true;
368
+ const result = await work(client);
369
+ await client.query(COMMIT);
370
+ began = false;
371
+ return result;
372
+ } catch (error) {
373
+ if (began) {
374
+ try {
375
+ await client.query(ROLLBACK);
376
+ } catch (rollbackError) {
377
+ throw new AggregateError(
378
+ [error, rollbackError],
379
+ 'AEB consumption transaction and rollback both failed',
380
+ );
381
+ }
382
+ }
383
+ throw error;
384
+ } finally {
385
+ client.release();
386
+ }
387
+ }
388
+
389
+ const ownedReservations = new Map<string, string>();
390
+
391
+ const store: PostgresAebDurableConsumptionStore = {
392
+ durable: true,
393
+ ownershipFenced: true,
394
+ permanentConsumption: true,
395
+ atomicReplayFenced: true,
396
+ recoveryClaimSupported: true,
397
+
398
+ async reserve(key, replayKeys = []): Promise<AebReservationResult> {
399
+ assertText(key, 'operation key', 4096);
400
+ if (!Array.isArray(replayKeys)) {
401
+ throw new TypeError('AEB consumption replay keys must be an array');
402
+ }
403
+ for (const replayKey of replayKeys) assertText(replayKey, 'native replay key', 4096);
404
+ // Stable lock acquisition order avoids crossed replay-key insert order
405
+ // becoming a preventable PostgreSQL deadlock under concurrency.
406
+ const uniqueReplayKeys = [...new Set(replayKeys)].sort();
407
+ const ownerToken = ownerTokenFactory();
408
+ assertOwnerToken(ownerToken);
409
+
410
+ try {
411
+ await transaction(pool, async (client) => {
412
+ const operationRows = exactRowCount(
413
+ await client.query(AEB_CONSUMPTION_SQL.reserveOperation, [
414
+ tenantId, relyingPartyId, key, ownerToken,
415
+ ]),
416
+ 'reserve operation',
417
+ );
418
+ if (operationRows === 0) throw new ReservationConflict('CONSUMPTION_CONFLICT');
419
+ if (operationRows !== 1) throw new Error('reserve operation: unexpected PostgreSQL row count');
420
+
421
+ const replayRows = exactRowCount(
422
+ await client.query(AEB_CONSUMPTION_SQL.reserveReplayKeys, [
423
+ tenantId, relyingPartyId, key, uniqueReplayKeys,
424
+ ]),
425
+ 'reserve native replay keys',
426
+ );
427
+ if (replayRows !== uniqueReplayKeys.length) {
428
+ throw new ReservationConflict('NATIVE_REPLAY_CONFLICT');
429
+ }
430
+ });
431
+ } catch (error) {
432
+ if (error instanceof ReservationConflict) return error.result;
433
+ throw error;
434
+ }
435
+
436
+ ownedReservations.set(key, ownerToken);
437
+ return 'RESERVED';
438
+ },
439
+
440
+ async claimReservation(key, authorization): Promise<boolean> {
441
+ assertText(key, 'operation key', 4096);
442
+ // Recovery is a restart boundary, not an in-place owner rotation. The
443
+ // base AEB store API fences ownership by store instance (commit/release
444
+ // take only the operation key), so replacing a token already owned by
445
+ // this instance would let its stale caller inherit the new token.
446
+ if (ownedReservations.has(key)) return false;
447
+ const claim = Object.freeze({
448
+ authorization,
449
+ tenantId,
450
+ relyingPartyId,
451
+ operationKey: key,
452
+ requiredState: 'RESERVED' as const,
453
+ });
454
+ if (await authorizeRecoveryClaim(claim) !== true) return false;
455
+
456
+ const ownerToken = ownerTokenFactory();
457
+ assertOwnerToken(ownerToken);
458
+ const changed = await transaction(recoveryPool, async (client) => {
459
+ const rows = exactRowCount(
460
+ await client.query(AEB_CONSUMPTION_SQL.claimOperation, [
461
+ tenantId, relyingPartyId, key, ownerToken,
462
+ ]),
463
+ 'claim operation',
464
+ );
465
+ if (rows > 1) throw new Error('claim operation: unexpected PostgreSQL row count');
466
+ return rows === 1;
467
+ });
468
+ if (changed) ownedReservations.set(key, ownerToken);
469
+ return changed;
470
+ },
471
+
472
+ async commit(key): Promise<boolean> {
473
+ assertText(key, 'operation key', 4096);
474
+ const ownerToken = ownedReservations.get(key);
475
+ if (ownerToken === undefined) return false;
476
+ const changed = await transaction(pool, async (client) => {
477
+ const rows = exactRowCount(
478
+ await client.query(AEB_CONSUMPTION_SQL.commitOperation, [
479
+ tenantId, relyingPartyId, key, ownerToken,
480
+ ]),
481
+ 'commit operation',
482
+ );
483
+ if (rows > 1) throw new Error('commit operation: unexpected PostgreSQL row count');
484
+ return rows === 1;
485
+ });
486
+ ownedReservations.delete(key);
487
+ return changed;
488
+ },
489
+
490
+ async release(key): Promise<boolean> {
491
+ assertText(key, 'operation key', 4096);
492
+ const ownerToken = ownedReservations.get(key);
493
+ if (ownerToken === undefined) return false;
494
+ const changed = await transaction(pool, async (client) => {
495
+ const rows = exactRowCount(
496
+ await client.query(AEB_CONSUMPTION_SQL.releaseOperation, [
497
+ tenantId, relyingPartyId, key, ownerToken,
498
+ ]),
499
+ 'release operation',
500
+ );
501
+ if (rows > 1) throw new Error('release operation: unexpected PostgreSQL row count');
502
+ return rows === 1;
503
+ });
504
+ ownedReservations.delete(key);
505
+ return changed;
506
+ },
507
+ };
508
+
509
+ return Object.freeze(store);
510
+ }
511
+
512
+ export default {
513
+ AEB_PG_CONSUMPTION_STORE_VERSION,
514
+ AEB_CONSUMPTION_OPERATION_TABLE,
515
+ AEB_CONSUMPTION_REPLAY_TABLE,
516
+ AEB_CONSUMPTION_EXECUTOR_ROLE,
517
+ AEB_CONSUMPTION_RECOVERY_ROLE,
518
+ AEB_CONSUMPTION_DDL,
519
+ AEB_CONSUMPTION_SQL,
520
+ createPostgresAebDurableConsumptionStore,
521
+ };
@@ -619,6 +619,20 @@ function capabilityStateFromEnvelope(capabilityReceipt) {
619
619
  };
620
620
  }
621
621
 
622
+ /**
623
+ * Production capability-store contract. Methods alone are insufficient: an
624
+ * adapter must explicitly assert durable custody and reconciliation support.
625
+ */
626
+ export function isSecureCapabilityStore(store) {
627
+ if (!store || typeof store !== 'object') return false;
628
+ return store.durable === true
629
+ && store.reconciliationCapable === true
630
+ && typeof store.registerCapability === 'function'
631
+ && typeof store.reserveSpend === 'function'
632
+ && typeof store.commitSpend === 'function'
633
+ && typeof store.reconcileSpend === 'function';
634
+ }
635
+
622
636
  /**
623
637
  * An in-memory atomic reference store. It is intentionally marked non-durable
624
638
  * and is suitable only for tests; production callers must use an implementation
@@ -629,6 +643,7 @@ export function createMemoryCapabilityStore() {
629
643
  const operations = new Map();
630
644
  return {
631
645
  durable: false,
646
+ reconciliationCapable: true,
632
647
  registerCapability(capabilityReceipt) {
633
648
  const verified = verifyCapabilityReceipt(capabilityReceipt, { allowUntrustedIssuer: true });
634
649
  if (!verified.ok) return false;
@@ -763,6 +778,7 @@ export function createPostgresCapabilityStore({ transaction }: { transaction?: (
763
778
  if (typeof transaction !== 'function') throw new TypeError('createPostgresCapabilityStore requires a transaction(callback) function');
764
779
  return {
765
780
  durable: true,
781
+ reconciliationCapable: true,
766
782
  async registerCapability(capabilityReceipt) {
767
783
  const verified = verifyCapabilityReceipt(capabilityReceipt, { allowUntrustedIssuer: true });
768
784
  if (!verified.ok) return false;
@@ -1251,6 +1267,7 @@ export default {
1251
1267
  reconstructCapabilitySecret,
1252
1268
  createMemoryCapabilityStore,
1253
1269
  createPostgresCapabilityStore,
1270
+ isSecureCapabilityStore,
1254
1271
  executeWithCapability,
1255
1272
  executeWithThreshold,
1256
1273
  reconcileCapabilityOperation,
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ import {
90
90
  reconstructCapabilitySecret,
91
91
  createMemoryCapabilityStore,
92
92
  createPostgresCapabilityStore,
93
+ isSecureCapabilityStore,
93
94
  executeWithCapability,
94
95
  executeWithThreshold,
95
96
  reconcileCapabilityOperation,
@@ -224,6 +225,7 @@ export {
224
225
  reconstructCapabilitySecret,
225
226
  createMemoryCapabilityStore,
226
227
  createPostgresCapabilityStore,
228
+ isSecureCapabilityStore,
227
229
  executeWithCapability,
228
230
  executeWithThreshold,
229
231
  reconcileCapabilityOperation,
@@ -284,11 +286,48 @@ export {
284
286
  REMEDY_PROGRAM_POSTGRES_SQL,
285
287
  createRemedyProgramPostgresStore,
286
288
  } from './remedy-program-postgres.js';
289
+ export {
290
+ REMEDY_PROGRAM_EVIDENCE_VERSION,
291
+ REMEDY_PROGRAM_EVIDENCE_DOMAIN,
292
+ remedyProgramEvidenceDigest,
293
+ remedyProgramEvidenceSigningBytes,
294
+ createRemedyProgramAdapters,
295
+ } from './remedy-program-adapters.js';
296
+ export {
297
+ REMEDY_CASE_SET_VERSION,
298
+ createRemedyCaseSetCoordinator,
299
+ } from './remedy-case-set.js';
300
+ export {
301
+ REMEDY_CASE_SET_PG_STORE_VERSION,
302
+ REMEDY_CASE_SET_TABLE,
303
+ REMEDY_CASE_SET_EVENT_TABLE,
304
+ REMEDY_CASE_SET_MAX_STATE_BYTES,
305
+ REMEDY_CASE_SET_MAX_MANIFEST_BYTES,
306
+ REMEDY_CASE_SET_MAX_FORWARD_SKEW_MINUTES,
307
+ REMEDY_CASE_SET_POSTGRES_DDL,
308
+ REMEDY_CASE_SET_POSTGRES_SQL,
309
+ createRemedyCaseSetPostgresStore,
310
+ } from './remedy-case-set-postgres.js';
287
311
  export {
288
312
  PROPOSAL_TO_EFFECT_VERSION,
289
313
  proposalToEffectConsumptionNonce,
290
314
  createProposalToEffect,
291
315
  } from './proposal-to-effect.js';
316
+ export { createProposalToEffectStatusVerifier } from './proposal-to-effect-status.js';
317
+ export {
318
+ PROPOSAL_TO_EFFECT_POSTGRES_DDL,
319
+ PROPOSAL_TO_EFFECT_POSTGRES_SQL,
320
+ proposalToEffectAttemptDigest,
321
+ createProposalToEffectPostgresStore,
322
+ } from './proposal-to-effect-postgres.js';
323
+ export {
324
+ AEB_PG_CONSUMPTION_STORE_VERSION,
325
+ AEB_CONSUMPTION_OPERATION_TABLE,
326
+ AEB_CONSUMPTION_REPLAY_TABLE,
327
+ AEB_CONSUMPTION_DDL,
328
+ AEB_CONSUMPTION_SQL,
329
+ createPostgresAebDurableConsumptionStore,
330
+ } from './aeb-consumption-store.js';
292
331
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
293
332
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
294
333
  const CAPABILITY_FAILURE_STATUS = 409;
@@ -878,11 +917,13 @@ export function verifyBusinessAuthorization({ requirement, receipt, assurance, t
878
917
  * @param {object} [opts.store] durable, ownership-fenced, permanent consumption store
879
918
  * @param {object} [opts.capabilityStore] Marvel capability budget store. A
880
919
  * capability run reserves here before the effect and commits after it.
920
+ * Production stores must explicitly mark durable and reconciliation-capable
921
+ * custody and implement register/reserve/commit/reconcile operations.
881
922
  * @param {string[]} [opts.capabilityTrustedIssuerKeys] pinned capability
882
923
  * envelope issuer keys. Required when capabilityStore is configured.
883
924
  * @param {function|null} [opts.capabilityCaidResolver] relying-party-pinned resolver
884
925
  * for `urn:emilia:scope:caid-set-v1`. Missing resolver fails CAID scope closed.
885
- * @param {boolean} [opts.allowEphemeralStore=false] explicit test/demo opt-in for in-memory state
926
+ * @param {boolean} [opts.allowEphemeralStore=false] explicit test/demo opt-in for in-memory consumption or capability state
886
927
  * @param {object} [opts.log] evidence log (default in-memory, hash-chained)
887
928
  * @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
888
929
  * @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
@@ -960,8 +1001,15 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
960
1001
  }
961
1002
  if (capabilityStore && (typeof capabilityStore.registerCapability !== 'function'
962
1003
  || typeof capabilityStore.reserveSpend !== 'function'
963
- || typeof capabilityStore.commitSpend !== 'function')) {
964
- throw new Error('EMILIA Gate capabilityStore must implement registerCapability(), reserveSpend(), and commitSpend()');
1004
+ || typeof capabilityStore.commitSpend !== 'function'
1005
+ || typeof capabilityStore.reconcileSpend !== 'function')) {
1006
+ throw new Error('EMILIA Gate capabilityStore must implement registerCapability(), reserveSpend(), commitSpend(), and reconcileSpend()');
1007
+ }
1008
+ if (capabilityStore && !allowEphemeralStore && !isSecureCapabilityStore(capabilityStore)) {
1009
+ throw new Error(
1010
+ 'EMILIA Gate capabilityStore must be durable and reconciliation-capable. '
1011
+ + 'Pass allowEphemeralStore:true only for an explicit test/demo gate.',
1012
+ );
965
1013
  }
966
1014
  if (capabilityStore && (!Array.isArray(capabilityTrustedIssuerKeys)
967
1015
  || capabilityTrustedIssuerKeys.length === 0
@@ -2114,6 +2162,7 @@ export default {
2114
2162
  reconstructCapabilitySecret,
2115
2163
  createMemoryCapabilityStore,
2116
2164
  createPostgresCapabilityStore,
2165
+ isSecureCapabilityStore,
2117
2166
  executeWithCapability,
2118
2167
  executeWithThreshold,
2119
2168
  reconcileCapabilityOperation,