@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.
- package/CHANGELOG.md +32 -1
- package/README.md +32 -1
- package/aeb-consumption-store.js +3 -0
- package/dist/aeb-consumption-store.d.ts +80 -0
- package/dist/aeb-consumption-store.d.ts.map +1 -0
- package/dist/aeb-consumption-store.js +430 -0
- package/dist/aeb-consumption-store.js.map +1 -0
- package/dist/capability-receipt.d.ts +8 -0
- package/dist/capability-receipt.d.ts.map +1 -1
- package/dist/capability-receipt.js +17 -0
- package/dist/capability-receipt.js.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts +129 -0
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
- package/dist/proposal-to-effect-postgres.js +1511 -0
- package/dist/proposal-to-effect-postgres.js.map +1 -0
- package/dist/proposal-to-effect-status.d.ts +55 -0
- package/dist/proposal-to-effect-status.d.ts.map +1 -0
- package/dist/proposal-to-effect-status.js +280 -0
- package/dist/proposal-to-effect-status.js.map +1 -0
- package/dist/proposal-to-effect.d.ts +151 -3
- package/dist/proposal-to-effect.d.ts.map +1 -1
- package/dist/proposal-to-effect.js +589 -46
- package/dist/proposal-to-effect.js.map +1 -1
- package/dist/remedy-case-set-postgres.d.ts +73 -0
- package/dist/remedy-case-set-postgres.d.ts.map +1 -0
- package/dist/remedy-case-set-postgres.js +846 -0
- package/dist/remedy-case-set-postgres.js.map +1 -0
- package/dist/remedy-case-set.d.ts +53 -0
- package/dist/remedy-case-set.d.ts.map +1 -0
- package/dist/remedy-case-set.js +571 -0
- package/dist/remedy-case-set.js.map +1 -0
- package/dist/remedy-program-adapters.d.ts +137 -0
- package/dist/remedy-program-adapters.d.ts.map +1 -0
- package/dist/remedy-program-adapters.js +590 -0
- package/dist/remedy-program-adapters.js.map +1 -0
- package/dist/trust-program-revocation.js.map +1 -1
- package/package.json +278 -63
- package/proposal-to-effect-postgres.js +3 -0
- package/proposal-to-effect-status.js +2 -0
- package/remedy-case-set-postgres.js +3 -0
- package/remedy-case-set.js +3 -0
- package/remedy-program-adapters.js +3 -0
- package/src/aeb-consumption-store.ts +521 -0
- package/src/capability-receipt.ts +17 -0
- package/src/index.ts +52 -3
- package/src/proposal-to-effect-postgres.ts +1740 -0
- package/src/proposal-to-effect-status.ts +357 -0
- package/src/proposal-to-effect.ts +755 -50
- package/src/remedy-case-set-postgres.ts +1008 -0
- package/src/remedy-case-set.ts +603 -0
- package/src/remedy-program-adapters.ts +657 -0
- package/src/trust-program-revocation.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
All notable changes to `@emilia-protocol/gate` are documented here.
|
|
5
5
|
This package follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
-
## 0.
|
|
7
|
+
## 0.15.0 (2026-07-22)
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
@@ -15,6 +15,23 @@ This package follows [Semantic Versioning](https://semver.org/).
|
|
|
15
15
|
- An executable end-to-end example and profile vectors covering exact-action
|
|
16
16
|
mutation, stale evidence, one-time replay, indeterminate effects, and
|
|
17
17
|
post-effect commit failure.
|
|
18
|
+
- `./aeb-consumption-store`, a tenant- and relying-party-namespaced PostgreSQL
|
|
19
|
+
store that atomically fences the AEB operation and all native replay units,
|
|
20
|
+
with opaque owner tokens and authorized restart recovery.
|
|
21
|
+
- `./proposal-to-effect-status`, which verifies server-resolved
|
|
22
|
+
`EP-STATUS-v1` heads and separately requires authenticated local consumption
|
|
23
|
+
state before the atomic execution reservation.
|
|
24
|
+
- `./proposal-to-effect-postgres`, a private-schema PostgreSQL store for
|
|
25
|
+
consequence attempts with HMAC-hidden owner capabilities, tenant/provider
|
|
26
|
+
namespaces, owner-fenced transitions, immutable terminal states, exact
|
|
27
|
+
provider-evidence reconciliation, database leases, disjoint executor and
|
|
28
|
+
recovery roles, tenant-principal bindings, and stale-only restart recovery.
|
|
29
|
+
- Concrete Remedy Program evidence adapters for signed disputes,
|
|
30
|
+
authorizations, provider outcomes, Action Escrow state, and late revocation.
|
|
31
|
+
- A heterogeneous remedy case-set coordinator that completes only after every
|
|
32
|
+
exact child state and signed remedy receipt verifies.
|
|
33
|
+
- A PostgreSQL remedy case-set store with tenant RLS, immutable manifests,
|
|
34
|
+
append-only state events, database-clock custody, and owner/revision CAS.
|
|
18
35
|
|
|
19
36
|
### Security
|
|
20
37
|
|
|
@@ -25,6 +42,20 @@ This package follows [Semantic Versioning](https://semver.org/).
|
|
|
25
42
|
- Once an effect may have executed, failed bookkeeping cannot release its AEB
|
|
26
43
|
reservation. The operation remains frozen until authenticated provider
|
|
27
44
|
evidence proves `COMMITTED` or `NOT_COMMITTED`.
|
|
45
|
+
- A committed effect consumes AEB replay authority before its consequence row
|
|
46
|
+
becomes terminal. `repairAeb` converges legacy or crash-window terminal rows
|
|
47
|
+
without invoking an effect, and opaque attempt-owner capabilities are kept
|
|
48
|
+
out of enumerable results and errors.
|
|
49
|
+
- A recovered worker receives a rotated owner capability; the stale worker can
|
|
50
|
+
no longer transition or reconcile the attempt. An in-flight attempt is
|
|
51
|
+
conservatively recovered as `INDETERMINATE`, never retried as unexecuted.
|
|
52
|
+
- AEB production consumption state is RPC-only behind tenant-bound, no-bypass
|
|
53
|
+
executor and recovery roles with physically separate pools. Supabase
|
|
54
|
+
`service_role` and both runtime roles have no direct table authority;
|
|
55
|
+
in-memory stores remain test-only.
|
|
56
|
+
- Remedy case-set state and append-only history are likewise RPC-only behind a
|
|
57
|
+
tenant-bound no-bypass executor; generic service credentials cannot rewrite
|
|
58
|
+
current or historical remedy state.
|
|
28
59
|
|
|
29
60
|
## 0.13.0 (2026-07-20)
|
|
30
61
|
|
package/README.md
CHANGED
|
@@ -68,9 +68,19 @@ format. The proposal is an unsigned, short-lived request; authority remains in
|
|
|
68
68
|
|
|
69
69
|
```js
|
|
70
70
|
import { createProposalToEffect } from '@emilia-protocol/gate/proposal-to-effect';
|
|
71
|
+
import { createProposalToEffectPostgresStore } from '@emilia-protocol/gate/proposal-to-effect-postgres';
|
|
71
72
|
|
|
72
73
|
const controller = createProposalToEffect({
|
|
73
74
|
gate,
|
|
75
|
+
proposal_integrity: { hmac_sha256_key: serverHeld32ByteKey },
|
|
76
|
+
consequence: {
|
|
77
|
+
tenant_id: tenantId,
|
|
78
|
+
provider_id: providerId,
|
|
79
|
+
provider_account_id: providerAccountId,
|
|
80
|
+
environment,
|
|
81
|
+
executor_id: executorId,
|
|
82
|
+
store: durableConsequenceAttemptStore,
|
|
83
|
+
},
|
|
74
84
|
profiles: {
|
|
75
85
|
'payment-release': {
|
|
76
86
|
id: 'payment-release',
|
|
@@ -94,6 +104,8 @@ const controller = createProposalToEffect({
|
|
|
94
104
|
adapters: pinnedAebAdapters,
|
|
95
105
|
store: durableOperationStore,
|
|
96
106
|
resolve_artifacts,
|
|
107
|
+
currentStatusResolver,
|
|
108
|
+
statusVerifier,
|
|
97
109
|
verify_provider_evidence,
|
|
98
110
|
},
|
|
99
111
|
});
|
|
@@ -113,10 +125,29 @@ The controller re-verifies the signed AEB evaluation, runs Gate policy,
|
|
|
113
125
|
reserves the operation before invoking the effect, and consumes it after
|
|
114
126
|
success. An uncertain provider result remains reserved; only authenticated
|
|
115
127
|
provider evidence bound to the same operation, CAID, and action digest can
|
|
116
|
-
reconcile it.
|
|
128
|
+
reconcile it. Owner capabilities are not serialized in results or errors;
|
|
129
|
+
same-process services retrieve them with `getReconciliationHandle(object)`,
|
|
130
|
+
while restarted services use the PostgreSQL store's authorized recovery path.
|
|
131
|
+
Production PostgreSQL wiring uses different executor and recovery pools. Each
|
|
132
|
+
database login is explicitly bound to a tenant and receives only its matching
|
|
133
|
+
non-login group role; `service_role` receives neither RPC surface. Recovery is
|
|
134
|
+
possible only after the database lease is stale, rotates the owner capability,
|
|
135
|
+
and preserves `INDETERMINATE` whenever provider execution may have started.
|
|
136
|
+
`repairAeb()` converges a durable terminal attempt with a stranded AEB
|
|
137
|
+
reservation without invoking the effect again. See
|
|
117
138
|
[`docs/protocol/proposal-to-effect-profile-v1.md`](../../docs/protocol/proposal-to-effect-profile-v1.md)
|
|
118
139
|
and run `node examples/proposal-to-effect/demo.mjs` from the repository root.
|
|
119
140
|
|
|
141
|
+
The AEB consumption store has its own disjoint PostgreSQL credentials:
|
|
142
|
+
`createPostgresAebDurableConsumptionStore()` requires an `ep_aeb_executor`
|
|
143
|
+
pool and a physically distinct `ep_aeb_recovery` pool. Each login must be
|
|
144
|
+
listed for the tenant in `ep_aeb_private.tenant_principals`. Neither runtime
|
|
145
|
+
role receives table privileges; reserve, commit, release, and recovery claim
|
|
146
|
+
are narrow security-definer functions. Supabase `service_role` receives no
|
|
147
|
+
table, schema, or function authority. Remedy case sets use the same custody
|
|
148
|
+
shape through a tenant-bound `ep_remedy_executor` login and
|
|
149
|
+
`ep_remedy_private.tenant_principals`.
|
|
150
|
+
|
|
120
151
|
## Three-plane deployment
|
|
121
152
|
|
|
122
153
|
High-consequence infrastructure separates three jobs instead of asking one
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AebDurableConsumptionStore } from '@emilia-protocol/verify/aeb-adapter-contract';
|
|
2
|
+
export declare const AEB_PG_CONSUMPTION_STORE_VERSION = "EP-GATE-AEB-PG-CONSUMPTION-v1";
|
|
3
|
+
export declare const AEB_CONSUMPTION_OPERATION_TABLE = "ep_aeb_consumption_operations";
|
|
4
|
+
export declare const AEB_CONSUMPTION_REPLAY_TABLE = "ep_aeb_consumption_replay_fences";
|
|
5
|
+
export declare const AEB_CONSUMPTION_EXECUTOR_ROLE = "ep_aeb_executor";
|
|
6
|
+
export declare const AEB_CONSUMPTION_RECOVERY_ROLE = "ep_aeb_recovery";
|
|
7
|
+
export declare const AEB_CONSUMPTION_OWNER_ROLE = "ep_aeb_store_owner";
|
|
8
|
+
/** Exact schema required by createPostgresAebDurableConsumptionStore(). */
|
|
9
|
+
export declare const AEB_CONSUMPTION_DDL = "CREATE TABLE IF NOT EXISTS ep_aeb_consumption_operations (\n tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),\n relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),\n operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),\n state TEXT NOT NULL CHECK (state IN ('RESERVED', 'CONSUMED')),\n owner_token TEXT NULL CHECK (owner_token IS NULL OR octet_length(owner_token) BETWEEN 16 AND 512),\n reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),\n consumed_at TIMESTAMPTZ NULL,\n PRIMARY KEY (tenant_id, relying_party_id, operation_key),\n CHECK (\n (state = 'RESERVED' AND owner_token IS NOT NULL AND consumed_at IS NULL)\n OR (state = 'CONSUMED' AND owner_token IS NULL AND consumed_at IS NOT NULL)\n )\n);\nCREATE TABLE IF NOT EXISTS ep_aeb_consumption_replay_fences (\n tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),\n relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),\n replay_key TEXT NOT NULL CHECK (octet_length(replay_key) BETWEEN 1 AND 4096),\n operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),\n reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),\n PRIMARY KEY (tenant_id, relying_party_id, replay_key),\n FOREIGN KEY (tenant_id, relying_party_id, operation_key)\n REFERENCES ep_aeb_consumption_operations (tenant_id, relying_party_id, operation_key)\n ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS ep_aeb_consumption_replay_fences_operation_idx\n ON ep_aeb_consumption_replay_fences (tenant_id, relying_party_id, operation_key);\nDO $roles$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'ep_aeb_executor') THEN\n CREATE ROLE ep_aeb_executor NOLOGIN\n NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'ep_aeb_recovery') THEN\n CREATE ROLE ep_aeb_recovery NOLOGIN\n NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'ep_aeb_store_owner') THEN\n CREATE ROLE ep_aeb_store_owner NOLOGIN\n NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;\n END IF;\nEND\n$roles$;\nGRANT ep_aeb_store_owner TO CURRENT_USER;\nCREATE SCHEMA IF NOT EXISTS ep_aeb_private;\nREVOKE ALL ON SCHEMA ep_aeb_private FROM PUBLIC, anon, authenticated, service_role;\nCREATE TABLE IF NOT EXISTS ep_aeb_private.tenant_principals (\n principal_name NAME NOT NULL,\n tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),\n can_execute BOOLEAN NOT NULL DEFAULT FALSE,\n can_recover BOOLEAN NOT NULL DEFAULT FALSE,\n PRIMARY KEY (principal_name, tenant_id),\n CHECK (can_execute OR can_recover)\n);\nALTER SCHEMA ep_aeb_private OWNER TO ep_aeb_store_owner;\nALTER TABLE ep_aeb_private.tenant_principals OWNER TO ep_aeb_store_owner;\nALTER TABLE ep_aeb_consumption_operations OWNER TO ep_aeb_store_owner;\nALTER TABLE ep_aeb_consumption_replay_fences OWNER TO ep_aeb_store_owner;\nALTER TABLE ep_aeb_private.tenant_principals ENABLE ROW LEVEL SECURITY;\nALTER TABLE ep_aeb_private.tenant_principals FORCE ROW LEVEL SECURITY;\nALTER TABLE ep_aeb_consumption_operations ENABLE ROW LEVEL SECURITY;\nALTER TABLE ep_aeb_consumption_operations FORCE ROW LEVEL SECURITY;\nALTER TABLE ep_aeb_consumption_replay_fences ENABLE ROW LEVEL SECURITY;\nALTER TABLE ep_aeb_consumption_replay_fences FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals;\nCREATE POLICY ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals\n TO ep_aeb_store_owner USING (TRUE) WITH CHECK (TRUE);\nDROP POLICY IF EXISTS ep_aeb_operations_owner_only ON ep_aeb_consumption_operations;\nCREATE POLICY ep_aeb_operations_owner_only ON ep_aeb_consumption_operations\n TO ep_aeb_store_owner USING (TRUE) WITH CHECK (TRUE);\nDROP POLICY IF EXISTS ep_aeb_replay_owner_only ON ep_aeb_consumption_replay_fences;\nCREATE POLICY ep_aeb_replay_owner_only ON ep_aeb_consumption_replay_fences\n TO ep_aeb_store_owner USING (TRUE) WITH CHECK (TRUE);\nREVOKE ALL ON ep_aeb_private.tenant_principals FROM PUBLIC, anon, authenticated, service_role,\n ep_aeb_executor, ep_aeb_recovery;\nREVOKE ALL ON ep_aeb_consumption_operations FROM PUBLIC, anon, authenticated, service_role,\n ep_aeb_executor, ep_aeb_recovery;\nREVOKE ALL ON ep_aeb_consumption_replay_fences FROM PUBLIC, anon, authenticated, service_role,\n ep_aeb_executor, ep_aeb_recovery;\nCREATE OR REPLACE FUNCTION ep_aeb_private.assert_tenant_principal(\n p_tenant_id TEXT, p_recovery BOOLEAN\n) RETURNS VOID\nLANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = ''\nAS $fn$\nDECLARE v_role_ok BOOLEAN; v_binding_ok BOOLEAN;\nBEGIN\n v_role_ok := CASE WHEN p_recovery\n THEN pg_catalog.pg_has_role(SESSION_USER, 'ep_aeb_recovery', 'MEMBER')\n ELSE pg_catalog.pg_has_role(SESSION_USER, 'ep_aeb_executor', 'MEMBER')\n END;\n SELECT EXISTS (\n SELECT 1 FROM ep_aeb_private.tenant_principals AS principals\n WHERE principals.principal_name = SESSION_USER\n AND principals.tenant_id = p_tenant_id\n AND CASE WHEN p_recovery THEN principals.can_recover ELSE principals.can_execute END\n ) INTO v_binding_ok;\n IF v_role_ok IS NOT TRUE OR v_binding_ok IS NOT TRUE THEN\n RAISE EXCEPTION 'AEB_TENANT_PRINCIPAL_REFUSED' USING ERRCODE = '42501';\n END IF;\nEND\n$fn$;\nCREATE OR REPLACE FUNCTION ep_aeb_private.reserve_operation(\n p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT\n) RETURNS TABLE(operation_key TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nBEGIN\n PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);\n RETURN QUERY INSERT INTO public.ep_aeb_consumption_operations\n (tenant_id, relying_party_id, operation_key, state, owner_token)\n VALUES (p_tenant_id, p_relying_party_id, p_operation_key, 'RESERVED', p_owner_token)\n ON CONFLICT ON CONSTRAINT ep_aeb_consumption_operations_pkey DO NOTHING\n RETURNING ep_aeb_consumption_operations.operation_key;\nEND\n$fn$;\nCREATE OR REPLACE FUNCTION ep_aeb_private.reserve_replay_keys(\n p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_replay_keys TEXT[]\n) RETURNS TABLE(replay_key TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nBEGIN\n PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);\n RETURN QUERY INSERT INTO public.ep_aeb_consumption_replay_fences\n (tenant_id, relying_party_id, replay_key, operation_key)\n SELECT p_tenant_id, p_relying_party_id, requested.replay_key, p_operation_key\n FROM pg_catalog.unnest(p_replay_keys) AS requested(replay_key)\n ORDER BY requested.replay_key\n ON CONFLICT ON CONSTRAINT ep_aeb_consumption_replay_fences_pkey DO NOTHING\n RETURNING ep_aeb_consumption_replay_fences.replay_key;\nEND\n$fn$;\nCREATE OR REPLACE FUNCTION ep_aeb_private.commit_operation(\n p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT\n) RETURNS TABLE(operation_key TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nBEGIN\n PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);\n RETURN QUERY UPDATE public.ep_aeb_consumption_operations\n SET state = 'CONSUMED', owner_token = NULL, consumed_at = pg_catalog.transaction_timestamp()\n WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id\n AND ep_aeb_consumption_operations.operation_key = p_operation_key\n AND state = 'RESERVED' AND owner_token = p_owner_token\n RETURNING ep_aeb_consumption_operations.operation_key;\nEND\n$fn$;\nCREATE OR REPLACE FUNCTION ep_aeb_private.claim_operation(\n p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT\n) RETURNS TABLE(operation_key TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nBEGIN\n PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, TRUE);\n RETURN QUERY UPDATE public.ep_aeb_consumption_operations\n SET owner_token = p_owner_token\n WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id\n AND ep_aeb_consumption_operations.operation_key = p_operation_key\n AND state = 'RESERVED'\n RETURNING ep_aeb_consumption_operations.operation_key;\nEND\n$fn$;\nCREATE OR REPLACE FUNCTION ep_aeb_private.release_operation(\n p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT\n) RETURNS TABLE(operation_key TEXT)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nBEGIN\n PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);\n RETURN QUERY DELETE FROM public.ep_aeb_consumption_operations\n WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id\n AND ep_aeb_consumption_operations.operation_key = p_operation_key\n AND state = 'RESERVED' AND owner_token = p_owner_token\n RETURNING ep_aeb_consumption_operations.operation_key;\nEND\n$fn$;\nALTER FUNCTION ep_aeb_private.assert_tenant_principal(TEXT, BOOLEAN)\n OWNER TO ep_aeb_store_owner;\nALTER FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT)\n OWNER TO ep_aeb_store_owner;\nALTER FUNCTION ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[])\n OWNER TO ep_aeb_store_owner;\nALTER FUNCTION ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT)\n OWNER TO ep_aeb_store_owner;\nALTER FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)\n OWNER TO ep_aeb_store_owner;\nALTER FUNCTION ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)\n OWNER TO ep_aeb_store_owner;\nREVOKE ALL ON ALL FUNCTIONS IN SCHEMA ep_aeb_private\n FROM PUBLIC, anon, authenticated, service_role;\nGRANT USAGE ON SCHEMA ep_aeb_private TO ep_aeb_executor, ep_aeb_recovery;\nGRANT EXECUTE ON FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT),\n ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[]),\n ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT),\n ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)\n TO ep_aeb_executor;\nGRANT EXECUTE ON FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)\n TO ep_aeb_recovery;\nREVOKE ep_aeb_store_owner FROM CURRENT_USER;";
|
|
10
|
+
/** Exact statements issued by the store, exported for audit and deterministic fakes. */
|
|
11
|
+
export declare const AEB_CONSUMPTION_SQL: Readonly<{
|
|
12
|
+
reserveOperation: "SELECT operation_key FROM ep_aeb_private.reserve_operation($1::text, $2::text, $3::text, $4::text)";
|
|
13
|
+
reserveReplayKeys: "SELECT replay_key FROM ep_aeb_private.reserve_replay_keys($1::text, $2::text, $3::text, $4::text[])";
|
|
14
|
+
commitOperation: "SELECT operation_key FROM ep_aeb_private.commit_operation($1::text, $2::text, $3::text, $4::text)";
|
|
15
|
+
claimOperation: "SELECT operation_key FROM ep_aeb_private.claim_operation($1::text, $2::text, $3::text, $4::text)";
|
|
16
|
+
releaseOperation: "SELECT operation_key FROM ep_aeb_private.release_operation($1::text, $2::text, $3::text, $4::text)";
|
|
17
|
+
}>;
|
|
18
|
+
type QueryResult = {
|
|
19
|
+
rowCount: number | null;
|
|
20
|
+
rows?: Record<string, unknown>[];
|
|
21
|
+
};
|
|
22
|
+
export type AebConsumptionPgClient = {
|
|
23
|
+
query: (text: string, params?: any[]) => Promise<QueryResult>;
|
|
24
|
+
release: () => void;
|
|
25
|
+
};
|
|
26
|
+
export type AebConsumptionPgPool = {
|
|
27
|
+
connect: () => Promise<AebConsumptionPgClient>;
|
|
28
|
+
};
|
|
29
|
+
export interface PostgresAebDurableConsumptionStoreOptions {
|
|
30
|
+
/** Pool authenticated as a tenant-bound member of ep_aeb_executor. */
|
|
31
|
+
pool?: AebConsumptionPgPool;
|
|
32
|
+
/** Distinct pool authenticated as a tenant-bound member of ep_aeb_recovery. */
|
|
33
|
+
recoveryPool?: AebConsumptionPgPool;
|
|
34
|
+
tenantId?: string;
|
|
35
|
+
relyingPartyId?: string;
|
|
36
|
+
/** Must return an unpredictable opaque string in production. */
|
|
37
|
+
ownerTokenFactory?: () => string;
|
|
38
|
+
/** Verify a caller credential bound to the exact reservation being claimed. */
|
|
39
|
+
authorizeRecoveryClaim?: AebRecoveryClaimAuthorizer;
|
|
40
|
+
}
|
|
41
|
+
export interface AebRecoveryClaimAuthorization {
|
|
42
|
+
authorization: unknown;
|
|
43
|
+
tenantId: string;
|
|
44
|
+
relyingPartyId: string;
|
|
45
|
+
operationKey: string;
|
|
46
|
+
requiredState: 'RESERVED';
|
|
47
|
+
}
|
|
48
|
+
export type AebRecoveryClaimAuthorizer = (claim: Readonly<AebRecoveryClaimAuthorization>) => boolean | Promise<boolean>;
|
|
49
|
+
export interface PostgresAebDurableConsumptionStore extends AebDurableConsumptionStore {
|
|
50
|
+
recoveryClaimSupported: true;
|
|
51
|
+
/**
|
|
52
|
+
* Rotate ownership of an existing RESERVED row after external authorization.
|
|
53
|
+
* The stored and replacement owner tokens are never returned or passed to
|
|
54
|
+
* the authorizer.
|
|
55
|
+
*/
|
|
56
|
+
claimReservation(key: string, authorization: unknown): Promise<boolean>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create the durable AEB store consumed by authorizeAebExecutionDurable().
|
|
60
|
+
* The pool must return a pinned node-postgres-style client for each transaction.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createPostgresAebDurableConsumptionStore({ pool, recoveryPool, tenantId, relyingPartyId, ownerTokenFactory, authorizeRecoveryClaim, }?: PostgresAebDurableConsumptionStoreOptions): PostgresAebDurableConsumptionStore;
|
|
63
|
+
declare const _default: {
|
|
64
|
+
AEB_PG_CONSUMPTION_STORE_VERSION: string;
|
|
65
|
+
AEB_CONSUMPTION_OPERATION_TABLE: string;
|
|
66
|
+
AEB_CONSUMPTION_REPLAY_TABLE: string;
|
|
67
|
+
AEB_CONSUMPTION_EXECUTOR_ROLE: string;
|
|
68
|
+
AEB_CONSUMPTION_RECOVERY_ROLE: string;
|
|
69
|
+
AEB_CONSUMPTION_DDL: string;
|
|
70
|
+
AEB_CONSUMPTION_SQL: Readonly<{
|
|
71
|
+
reserveOperation: "SELECT operation_key FROM ep_aeb_private.reserve_operation($1::text, $2::text, $3::text, $4::text)";
|
|
72
|
+
reserveReplayKeys: "SELECT replay_key FROM ep_aeb_private.reserve_replay_keys($1::text, $2::text, $3::text, $4::text[])";
|
|
73
|
+
commitOperation: "SELECT operation_key FROM ep_aeb_private.commit_operation($1::text, $2::text, $3::text, $4::text)";
|
|
74
|
+
claimOperation: "SELECT operation_key FROM ep_aeb_private.claim_operation($1::text, $2::text, $3::text, $4::text)";
|
|
75
|
+
releaseOperation: "SELECT operation_key FROM ep_aeb_private.release_operation($1::text, $2::text, $3::text, $4::text)";
|
|
76
|
+
}>;
|
|
77
|
+
createPostgresAebDurableConsumptionStore: typeof createPostgresAebDurableConsumptionStore;
|
|
78
|
+
};
|
|
79
|
+
export default _default;
|
|
80
|
+
//# sourceMappingURL=aeb-consumption-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aeb-consumption-store.d.ts","sourceRoot":"","sources":["../src/aeb-consumption-store.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EACV,0BAA0B,EAE3B,MAAM,8CAA8C,CAAC;AAEtD,eAAO,MAAM,gCAAgC,kCAAkC,CAAC;AAChF,eAAO,MAAM,+BAA+B,kCAAkC,CAAC;AAC/E,eAAO,MAAM,4BAA4B,qCAAqC,CAAC;AAC/E,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAC/D,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAC/D,eAAO,MAAM,0BAA0B,uBAAuB,CAAC;AAE/D,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,uxUAqMwB,CAAC;AAEzD,wFAAwF;AACxF,eAAO,MAAM,mBAAmB;;;;;;EAM9B,CAAC;AAEH,KAAK,WAAW,GAAG;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,OAAO,CAAC,sBAAsB,CAAC,CAAC;CAChD,CAAC;AAEF,MAAM,WAAW,yCAAyC;IACxD,sEAAsE;IACtE,IAAI,CAAC,EAAE,oBAAoB,CAAC;IAC5B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,oBAAoB,CAAC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,MAAM,CAAC;IACjC,+EAA+E;IAC/E,sBAAsB,CAAC,EAAE,0BAA0B,CAAC;CACrD;AAED,MAAM,WAAW,6BAA6B;IAC5C,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,UAAU,CAAC;CAC3B;AAED,MAAM,MAAM,0BAA0B,GAAG,CACvC,KAAK,EAAE,QAAQ,CAAC,6BAA6B,CAAC,KAC3C,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC,MAAM,WAAW,kCAAmC,SAAQ,0BAA0B;IACpF,sBAAsB,EAAE,IAAI,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACzE;AAkDD;;;GAGG;AACH,wBAAgB,wCAAwC,CAAC,EACvD,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,cAAc,EACd,iBAAqC,EACrC,sBAAsB,GACvB,GAAE,yCAA8C,GAAG,kCAAkC,CA0KrF;;;;;;;;;;;;;;;;;AAED,wBASE"}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* PostgreSQL custody for AEB operation consumption and native replay fences.
|
|
5
|
+
*
|
|
6
|
+
* A reservation installs the operation key and every native replay key in one
|
|
7
|
+
* pinned transaction. Logical conflicts roll the transaction back; database
|
|
8
|
+
* errors propagate so callers fail closed. Commit and release are fenced by an
|
|
9
|
+
* opaque per-reservation token retained only by the store instance that won.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
export const AEB_PG_CONSUMPTION_STORE_VERSION = 'EP-GATE-AEB-PG-CONSUMPTION-v1';
|
|
13
|
+
export const AEB_CONSUMPTION_OPERATION_TABLE = 'ep_aeb_consumption_operations';
|
|
14
|
+
export const AEB_CONSUMPTION_REPLAY_TABLE = 'ep_aeb_consumption_replay_fences';
|
|
15
|
+
export const AEB_CONSUMPTION_EXECUTOR_ROLE = 'ep_aeb_executor';
|
|
16
|
+
export const AEB_CONSUMPTION_RECOVERY_ROLE = 'ep_aeb_recovery';
|
|
17
|
+
export const AEB_CONSUMPTION_OWNER_ROLE = 'ep_aeb_store_owner';
|
|
18
|
+
/** Exact schema required by createPostgresAebDurableConsumptionStore(). */
|
|
19
|
+
export const AEB_CONSUMPTION_DDL = `CREATE TABLE IF NOT EXISTS ${AEB_CONSUMPTION_OPERATION_TABLE} (
|
|
20
|
+
tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
|
|
21
|
+
relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),
|
|
22
|
+
operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),
|
|
23
|
+
state TEXT NOT NULL CHECK (state IN ('RESERVED', 'CONSUMED')),
|
|
24
|
+
owner_token TEXT NULL CHECK (owner_token IS NULL OR octet_length(owner_token) BETWEEN 16 AND 512),
|
|
25
|
+
reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
26
|
+
consumed_at TIMESTAMPTZ NULL,
|
|
27
|
+
PRIMARY KEY (tenant_id, relying_party_id, operation_key),
|
|
28
|
+
CHECK (
|
|
29
|
+
(state = 'RESERVED' AND owner_token IS NOT NULL AND consumed_at IS NULL)
|
|
30
|
+
OR (state = 'CONSUMED' AND owner_token IS NULL AND consumed_at IS NOT NULL)
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS ${AEB_CONSUMPTION_REPLAY_TABLE} (
|
|
34
|
+
tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
|
|
35
|
+
relying_party_id TEXT NOT NULL CHECK (octet_length(relying_party_id) BETWEEN 1 AND 512),
|
|
36
|
+
replay_key TEXT NOT NULL CHECK (octet_length(replay_key) BETWEEN 1 AND 4096),
|
|
37
|
+
operation_key TEXT NOT NULL CHECK (octet_length(operation_key) BETWEEN 1 AND 4096),
|
|
38
|
+
reserved_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
|
39
|
+
PRIMARY KEY (tenant_id, relying_party_id, replay_key),
|
|
40
|
+
FOREIGN KEY (tenant_id, relying_party_id, operation_key)
|
|
41
|
+
REFERENCES ${AEB_CONSUMPTION_OPERATION_TABLE} (tenant_id, relying_party_id, operation_key)
|
|
42
|
+
ON DELETE CASCADE
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS ${AEB_CONSUMPTION_REPLAY_TABLE}_operation_idx
|
|
45
|
+
ON ${AEB_CONSUMPTION_REPLAY_TABLE} (tenant_id, relying_party_id, operation_key);
|
|
46
|
+
DO $roles$
|
|
47
|
+
BEGIN
|
|
48
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_EXECUTOR_ROLE}') THEN
|
|
49
|
+
CREATE ROLE ${AEB_CONSUMPTION_EXECUTOR_ROLE} NOLOGIN
|
|
50
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
51
|
+
END IF;
|
|
52
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_RECOVERY_ROLE}') THEN
|
|
53
|
+
CREATE ROLE ${AEB_CONSUMPTION_RECOVERY_ROLE} NOLOGIN
|
|
54
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
55
|
+
END IF;
|
|
56
|
+
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '${AEB_CONSUMPTION_OWNER_ROLE}') THEN
|
|
57
|
+
CREATE ROLE ${AEB_CONSUMPTION_OWNER_ROLE} NOLOGIN
|
|
58
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
59
|
+
END IF;
|
|
60
|
+
END
|
|
61
|
+
$roles$;
|
|
62
|
+
GRANT ${AEB_CONSUMPTION_OWNER_ROLE} TO CURRENT_USER;
|
|
63
|
+
CREATE SCHEMA IF NOT EXISTS ep_aeb_private;
|
|
64
|
+
REVOKE ALL ON SCHEMA ep_aeb_private FROM PUBLIC, anon, authenticated, service_role;
|
|
65
|
+
CREATE TABLE IF NOT EXISTS ep_aeb_private.tenant_principals (
|
|
66
|
+
principal_name NAME NOT NULL,
|
|
67
|
+
tenant_id TEXT NOT NULL CHECK (octet_length(tenant_id) BETWEEN 1 AND 512),
|
|
68
|
+
can_execute BOOLEAN NOT NULL DEFAULT FALSE,
|
|
69
|
+
can_recover BOOLEAN NOT NULL DEFAULT FALSE,
|
|
70
|
+
PRIMARY KEY (principal_name, tenant_id),
|
|
71
|
+
CHECK (can_execute OR can_recover)
|
|
72
|
+
);
|
|
73
|
+
ALTER SCHEMA ep_aeb_private OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
74
|
+
ALTER TABLE ep_aeb_private.tenant_principals OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
75
|
+
ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
76
|
+
ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
77
|
+
ALTER TABLE ep_aeb_private.tenant_principals ENABLE ROW LEVEL SECURITY;
|
|
78
|
+
ALTER TABLE ep_aeb_private.tenant_principals FORCE ROW LEVEL SECURITY;
|
|
79
|
+
ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} ENABLE ROW LEVEL SECURITY;
|
|
80
|
+
ALTER TABLE ${AEB_CONSUMPTION_OPERATION_TABLE} FORCE ROW LEVEL SECURITY;
|
|
81
|
+
ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} ENABLE ROW LEVEL SECURITY;
|
|
82
|
+
ALTER TABLE ${AEB_CONSUMPTION_REPLAY_TABLE} FORCE ROW LEVEL SECURITY;
|
|
83
|
+
DROP POLICY IF EXISTS ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals;
|
|
84
|
+
CREATE POLICY ep_aeb_principals_owner_only ON ep_aeb_private.tenant_principals
|
|
85
|
+
TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
|
|
86
|
+
DROP POLICY IF EXISTS ep_aeb_operations_owner_only ON ${AEB_CONSUMPTION_OPERATION_TABLE};
|
|
87
|
+
CREATE POLICY ep_aeb_operations_owner_only ON ${AEB_CONSUMPTION_OPERATION_TABLE}
|
|
88
|
+
TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
|
|
89
|
+
DROP POLICY IF EXISTS ep_aeb_replay_owner_only ON ${AEB_CONSUMPTION_REPLAY_TABLE};
|
|
90
|
+
CREATE POLICY ep_aeb_replay_owner_only ON ${AEB_CONSUMPTION_REPLAY_TABLE}
|
|
91
|
+
TO ${AEB_CONSUMPTION_OWNER_ROLE} USING (TRUE) WITH CHECK (TRUE);
|
|
92
|
+
REVOKE ALL ON ep_aeb_private.tenant_principals FROM PUBLIC, anon, authenticated, service_role,
|
|
93
|
+
${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
|
|
94
|
+
REVOKE ALL ON ${AEB_CONSUMPTION_OPERATION_TABLE} FROM PUBLIC, anon, authenticated, service_role,
|
|
95
|
+
${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
|
|
96
|
+
REVOKE ALL ON ${AEB_CONSUMPTION_REPLAY_TABLE} FROM PUBLIC, anon, authenticated, service_role,
|
|
97
|
+
${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
|
|
98
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.assert_tenant_principal(
|
|
99
|
+
p_tenant_id TEXT, p_recovery BOOLEAN
|
|
100
|
+
) RETURNS VOID
|
|
101
|
+
LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = ''
|
|
102
|
+
AS $fn$
|
|
103
|
+
DECLARE v_role_ok BOOLEAN; v_binding_ok BOOLEAN;
|
|
104
|
+
BEGIN
|
|
105
|
+
v_role_ok := CASE WHEN p_recovery
|
|
106
|
+
THEN pg_catalog.pg_has_role(SESSION_USER, '${AEB_CONSUMPTION_RECOVERY_ROLE}', 'MEMBER')
|
|
107
|
+
ELSE pg_catalog.pg_has_role(SESSION_USER, '${AEB_CONSUMPTION_EXECUTOR_ROLE}', 'MEMBER')
|
|
108
|
+
END;
|
|
109
|
+
SELECT EXISTS (
|
|
110
|
+
SELECT 1 FROM ep_aeb_private.tenant_principals AS principals
|
|
111
|
+
WHERE principals.principal_name = SESSION_USER
|
|
112
|
+
AND principals.tenant_id = p_tenant_id
|
|
113
|
+
AND CASE WHEN p_recovery THEN principals.can_recover ELSE principals.can_execute END
|
|
114
|
+
) INTO v_binding_ok;
|
|
115
|
+
IF v_role_ok IS NOT TRUE OR v_binding_ok IS NOT TRUE THEN
|
|
116
|
+
RAISE EXCEPTION 'AEB_TENANT_PRINCIPAL_REFUSED' USING ERRCODE = '42501';
|
|
117
|
+
END IF;
|
|
118
|
+
END
|
|
119
|
+
$fn$;
|
|
120
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.reserve_operation(
|
|
121
|
+
p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
|
|
122
|
+
) RETURNS TABLE(operation_key TEXT)
|
|
123
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
124
|
+
AS $fn$
|
|
125
|
+
BEGIN
|
|
126
|
+
PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
127
|
+
RETURN QUERY INSERT INTO public.${AEB_CONSUMPTION_OPERATION_TABLE}
|
|
128
|
+
(tenant_id, relying_party_id, operation_key, state, owner_token)
|
|
129
|
+
VALUES (p_tenant_id, p_relying_party_id, p_operation_key, 'RESERVED', p_owner_token)
|
|
130
|
+
ON CONFLICT ON CONSTRAINT ep_aeb_consumption_operations_pkey DO NOTHING
|
|
131
|
+
RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
|
|
132
|
+
END
|
|
133
|
+
$fn$;
|
|
134
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.reserve_replay_keys(
|
|
135
|
+
p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_replay_keys TEXT[]
|
|
136
|
+
) RETURNS TABLE(replay_key TEXT)
|
|
137
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
138
|
+
AS $fn$
|
|
139
|
+
BEGIN
|
|
140
|
+
PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
141
|
+
RETURN QUERY INSERT INTO public.${AEB_CONSUMPTION_REPLAY_TABLE}
|
|
142
|
+
(tenant_id, relying_party_id, replay_key, operation_key)
|
|
143
|
+
SELECT p_tenant_id, p_relying_party_id, requested.replay_key, p_operation_key
|
|
144
|
+
FROM pg_catalog.unnest(p_replay_keys) AS requested(replay_key)
|
|
145
|
+
ORDER BY requested.replay_key
|
|
146
|
+
ON CONFLICT ON CONSTRAINT ep_aeb_consumption_replay_fences_pkey DO NOTHING
|
|
147
|
+
RETURNING ${AEB_CONSUMPTION_REPLAY_TABLE}.replay_key;
|
|
148
|
+
END
|
|
149
|
+
$fn$;
|
|
150
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.commit_operation(
|
|
151
|
+
p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
|
|
152
|
+
) RETURNS TABLE(operation_key TEXT)
|
|
153
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
154
|
+
AS $fn$
|
|
155
|
+
BEGIN
|
|
156
|
+
PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
157
|
+
RETURN QUERY UPDATE public.${AEB_CONSUMPTION_OPERATION_TABLE}
|
|
158
|
+
SET state = 'CONSUMED', owner_token = NULL, consumed_at = pg_catalog.transaction_timestamp()
|
|
159
|
+
WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
|
|
160
|
+
AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
|
|
161
|
+
AND state = 'RESERVED' AND owner_token = p_owner_token
|
|
162
|
+
RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
|
|
163
|
+
END
|
|
164
|
+
$fn$;
|
|
165
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.claim_operation(
|
|
166
|
+
p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
|
|
167
|
+
) RETURNS TABLE(operation_key TEXT)
|
|
168
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
169
|
+
AS $fn$
|
|
170
|
+
BEGIN
|
|
171
|
+
PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, TRUE);
|
|
172
|
+
RETURN QUERY UPDATE public.${AEB_CONSUMPTION_OPERATION_TABLE}
|
|
173
|
+
SET owner_token = p_owner_token
|
|
174
|
+
WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
|
|
175
|
+
AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
|
|
176
|
+
AND state = 'RESERVED'
|
|
177
|
+
RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
|
|
178
|
+
END
|
|
179
|
+
$fn$;
|
|
180
|
+
CREATE OR REPLACE FUNCTION ep_aeb_private.release_operation(
|
|
181
|
+
p_tenant_id TEXT, p_relying_party_id TEXT, p_operation_key TEXT, p_owner_token TEXT
|
|
182
|
+
) RETURNS TABLE(operation_key TEXT)
|
|
183
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
184
|
+
AS $fn$
|
|
185
|
+
BEGIN
|
|
186
|
+
PERFORM ep_aeb_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
187
|
+
RETURN QUERY DELETE FROM public.${AEB_CONSUMPTION_OPERATION_TABLE}
|
|
188
|
+
WHERE tenant_id = p_tenant_id AND relying_party_id = p_relying_party_id
|
|
189
|
+
AND ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key = p_operation_key
|
|
190
|
+
AND state = 'RESERVED' AND owner_token = p_owner_token
|
|
191
|
+
RETURNING ${AEB_CONSUMPTION_OPERATION_TABLE}.operation_key;
|
|
192
|
+
END
|
|
193
|
+
$fn$;
|
|
194
|
+
ALTER FUNCTION ep_aeb_private.assert_tenant_principal(TEXT, BOOLEAN)
|
|
195
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
196
|
+
ALTER FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT)
|
|
197
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
198
|
+
ALTER FUNCTION ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[])
|
|
199
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
200
|
+
ALTER FUNCTION ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT)
|
|
201
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
202
|
+
ALTER FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)
|
|
203
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
204
|
+
ALTER FUNCTION ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)
|
|
205
|
+
OWNER TO ${AEB_CONSUMPTION_OWNER_ROLE};
|
|
206
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ep_aeb_private
|
|
207
|
+
FROM PUBLIC, anon, authenticated, service_role;
|
|
208
|
+
GRANT USAGE ON SCHEMA ep_aeb_private TO ${AEB_CONSUMPTION_EXECUTOR_ROLE}, ${AEB_CONSUMPTION_RECOVERY_ROLE};
|
|
209
|
+
GRANT EXECUTE ON FUNCTION ep_aeb_private.reserve_operation(TEXT, TEXT, TEXT, TEXT),
|
|
210
|
+
ep_aeb_private.reserve_replay_keys(TEXT, TEXT, TEXT, TEXT[]),
|
|
211
|
+
ep_aeb_private.commit_operation(TEXT, TEXT, TEXT, TEXT),
|
|
212
|
+
ep_aeb_private.release_operation(TEXT, TEXT, TEXT, TEXT)
|
|
213
|
+
TO ${AEB_CONSUMPTION_EXECUTOR_ROLE};
|
|
214
|
+
GRANT EXECUTE ON FUNCTION ep_aeb_private.claim_operation(TEXT, TEXT, TEXT, TEXT)
|
|
215
|
+
TO ${AEB_CONSUMPTION_RECOVERY_ROLE};
|
|
216
|
+
REVOKE ${AEB_CONSUMPTION_OWNER_ROLE} FROM CURRENT_USER;`;
|
|
217
|
+
/** Exact statements issued by the store, exported for audit and deterministic fakes. */
|
|
218
|
+
export const AEB_CONSUMPTION_SQL = Object.freeze({
|
|
219
|
+
reserveOperation: `SELECT operation_key FROM ep_aeb_private.reserve_operation($1::text, $2::text, $3::text, $4::text)`,
|
|
220
|
+
reserveReplayKeys: `SELECT replay_key FROM ep_aeb_private.reserve_replay_keys($1::text, $2::text, $3::text, $4::text[])`,
|
|
221
|
+
commitOperation: `SELECT operation_key FROM ep_aeb_private.commit_operation($1::text, $2::text, $3::text, $4::text)`,
|
|
222
|
+
claimOperation: `SELECT operation_key FROM ep_aeb_private.claim_operation($1::text, $2::text, $3::text, $4::text)`,
|
|
223
|
+
releaseOperation: `SELECT operation_key FROM ep_aeb_private.release_operation($1::text, $2::text, $3::text, $4::text)`,
|
|
224
|
+
});
|
|
225
|
+
const BEGIN_WRITE = 'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE';
|
|
226
|
+
const COMMIT = 'COMMIT';
|
|
227
|
+
const ROLLBACK = 'ROLLBACK';
|
|
228
|
+
class ReservationConflict extends Error {
|
|
229
|
+
result;
|
|
230
|
+
constructor(result) {
|
|
231
|
+
super(result);
|
|
232
|
+
this.result = result;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function defaultOwnerToken() {
|
|
236
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
237
|
+
}
|
|
238
|
+
function assertText(value, label, maximumBytes) {
|
|
239
|
+
if (typeof value !== 'string'
|
|
240
|
+
|| Buffer.byteLength(value, 'utf8') < 1
|
|
241
|
+
|| Buffer.byteLength(value, 'utf8') > maximumBytes
|
|
242
|
+
|| /[\u0000-\u001f\u007f]/.test(value)) {
|
|
243
|
+
throw new TypeError(`AEB consumption ${label} is invalid`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function assertOwnerToken(value) {
|
|
247
|
+
if (typeof value !== 'string'
|
|
248
|
+
|| Buffer.byteLength(value, 'utf8') < 16
|
|
249
|
+
|| Buffer.byteLength(value, 'utf8') > 512
|
|
250
|
+
|| /[\u0000-\u001f\u007f]/.test(value)) {
|
|
251
|
+
throw new TypeError('AEB consumption ownerTokenFactory must return an opaque string of 16 to 512 bytes');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function exactRowCount(result, operation) {
|
|
255
|
+
if (!result
|
|
256
|
+
|| !Number.isSafeInteger(result.rowCount)
|
|
257
|
+
|| result.rowCount < 0) {
|
|
258
|
+
throw new Error(`${operation}: malformed PostgreSQL result`);
|
|
259
|
+
}
|
|
260
|
+
return result.rowCount;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Create the durable AEB store consumed by authorizeAebExecutionDurable().
|
|
264
|
+
* The pool must return a pinned node-postgres-style client for each transaction.
|
|
265
|
+
*/
|
|
266
|
+
export function createPostgresAebDurableConsumptionStore({ pool, recoveryPool, tenantId, relyingPartyId, ownerTokenFactory = defaultOwnerToken, authorizeRecoveryClaim, } = {}) {
|
|
267
|
+
if (!pool || typeof pool.connect !== 'function') {
|
|
268
|
+
throw new TypeError('createPostgresAebDurableConsumptionStore requires an ep_aeb_executor pg pool');
|
|
269
|
+
}
|
|
270
|
+
if (!recoveryPool || typeof recoveryPool.connect !== 'function' || recoveryPool === pool) {
|
|
271
|
+
throw new TypeError('AEB consumption requires a distinct ep_aeb_recovery pg pool');
|
|
272
|
+
}
|
|
273
|
+
assertText(tenantId, 'tenantId', 512);
|
|
274
|
+
assertText(relyingPartyId, 'relyingPartyId', 512);
|
|
275
|
+
if (typeof ownerTokenFactory !== 'function') {
|
|
276
|
+
throw new TypeError('AEB consumption ownerTokenFactory must be a function');
|
|
277
|
+
}
|
|
278
|
+
if (typeof authorizeRecoveryClaim !== 'function') {
|
|
279
|
+
throw new TypeError('AEB consumption requires an authorizeRecoveryClaim callback');
|
|
280
|
+
}
|
|
281
|
+
async function transaction(activePool, work) {
|
|
282
|
+
const client = await activePool.connect();
|
|
283
|
+
if (!client || typeof client.query !== 'function' || typeof client.release !== 'function') {
|
|
284
|
+
throw new TypeError('AEB consumption pg pool returned an invalid client');
|
|
285
|
+
}
|
|
286
|
+
let began = false;
|
|
287
|
+
try {
|
|
288
|
+
await client.query(BEGIN_WRITE);
|
|
289
|
+
began = true;
|
|
290
|
+
const result = await work(client);
|
|
291
|
+
await client.query(COMMIT);
|
|
292
|
+
began = false;
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (began) {
|
|
297
|
+
try {
|
|
298
|
+
await client.query(ROLLBACK);
|
|
299
|
+
}
|
|
300
|
+
catch (rollbackError) {
|
|
301
|
+
throw new AggregateError([error, rollbackError], 'AEB consumption transaction and rollback both failed');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
client.release();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const ownedReservations = new Map();
|
|
311
|
+
const store = {
|
|
312
|
+
durable: true,
|
|
313
|
+
ownershipFenced: true,
|
|
314
|
+
permanentConsumption: true,
|
|
315
|
+
atomicReplayFenced: true,
|
|
316
|
+
recoveryClaimSupported: true,
|
|
317
|
+
async reserve(key, replayKeys = []) {
|
|
318
|
+
assertText(key, 'operation key', 4096);
|
|
319
|
+
if (!Array.isArray(replayKeys)) {
|
|
320
|
+
throw new TypeError('AEB consumption replay keys must be an array');
|
|
321
|
+
}
|
|
322
|
+
for (const replayKey of replayKeys)
|
|
323
|
+
assertText(replayKey, 'native replay key', 4096);
|
|
324
|
+
// Stable lock acquisition order avoids crossed replay-key insert order
|
|
325
|
+
// becoming a preventable PostgreSQL deadlock under concurrency.
|
|
326
|
+
const uniqueReplayKeys = [...new Set(replayKeys)].sort();
|
|
327
|
+
const ownerToken = ownerTokenFactory();
|
|
328
|
+
assertOwnerToken(ownerToken);
|
|
329
|
+
try {
|
|
330
|
+
await transaction(pool, async (client) => {
|
|
331
|
+
const operationRows = exactRowCount(await client.query(AEB_CONSUMPTION_SQL.reserveOperation, [
|
|
332
|
+
tenantId, relyingPartyId, key, ownerToken,
|
|
333
|
+
]), 'reserve operation');
|
|
334
|
+
if (operationRows === 0)
|
|
335
|
+
throw new ReservationConflict('CONSUMPTION_CONFLICT');
|
|
336
|
+
if (operationRows !== 1)
|
|
337
|
+
throw new Error('reserve operation: unexpected PostgreSQL row count');
|
|
338
|
+
const replayRows = exactRowCount(await client.query(AEB_CONSUMPTION_SQL.reserveReplayKeys, [
|
|
339
|
+
tenantId, relyingPartyId, key, uniqueReplayKeys,
|
|
340
|
+
]), 'reserve native replay keys');
|
|
341
|
+
if (replayRows !== uniqueReplayKeys.length) {
|
|
342
|
+
throw new ReservationConflict('NATIVE_REPLAY_CONFLICT');
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof ReservationConflict)
|
|
348
|
+
return error.result;
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
ownedReservations.set(key, ownerToken);
|
|
352
|
+
return 'RESERVED';
|
|
353
|
+
},
|
|
354
|
+
async claimReservation(key, authorization) {
|
|
355
|
+
assertText(key, 'operation key', 4096);
|
|
356
|
+
// Recovery is a restart boundary, not an in-place owner rotation. The
|
|
357
|
+
// base AEB store API fences ownership by store instance (commit/release
|
|
358
|
+
// take only the operation key), so replacing a token already owned by
|
|
359
|
+
// this instance would let its stale caller inherit the new token.
|
|
360
|
+
if (ownedReservations.has(key))
|
|
361
|
+
return false;
|
|
362
|
+
const claim = Object.freeze({
|
|
363
|
+
authorization,
|
|
364
|
+
tenantId,
|
|
365
|
+
relyingPartyId,
|
|
366
|
+
operationKey: key,
|
|
367
|
+
requiredState: 'RESERVED',
|
|
368
|
+
});
|
|
369
|
+
if (await authorizeRecoveryClaim(claim) !== true)
|
|
370
|
+
return false;
|
|
371
|
+
const ownerToken = ownerTokenFactory();
|
|
372
|
+
assertOwnerToken(ownerToken);
|
|
373
|
+
const changed = await transaction(recoveryPool, async (client) => {
|
|
374
|
+
const rows = exactRowCount(await client.query(AEB_CONSUMPTION_SQL.claimOperation, [
|
|
375
|
+
tenantId, relyingPartyId, key, ownerToken,
|
|
376
|
+
]), 'claim operation');
|
|
377
|
+
if (rows > 1)
|
|
378
|
+
throw new Error('claim operation: unexpected PostgreSQL row count');
|
|
379
|
+
return rows === 1;
|
|
380
|
+
});
|
|
381
|
+
if (changed)
|
|
382
|
+
ownedReservations.set(key, ownerToken);
|
|
383
|
+
return changed;
|
|
384
|
+
},
|
|
385
|
+
async commit(key) {
|
|
386
|
+
assertText(key, 'operation key', 4096);
|
|
387
|
+
const ownerToken = ownedReservations.get(key);
|
|
388
|
+
if (ownerToken === undefined)
|
|
389
|
+
return false;
|
|
390
|
+
const changed = await transaction(pool, async (client) => {
|
|
391
|
+
const rows = exactRowCount(await client.query(AEB_CONSUMPTION_SQL.commitOperation, [
|
|
392
|
+
tenantId, relyingPartyId, key, ownerToken,
|
|
393
|
+
]), 'commit operation');
|
|
394
|
+
if (rows > 1)
|
|
395
|
+
throw new Error('commit operation: unexpected PostgreSQL row count');
|
|
396
|
+
return rows === 1;
|
|
397
|
+
});
|
|
398
|
+
ownedReservations.delete(key);
|
|
399
|
+
return changed;
|
|
400
|
+
},
|
|
401
|
+
async release(key) {
|
|
402
|
+
assertText(key, 'operation key', 4096);
|
|
403
|
+
const ownerToken = ownedReservations.get(key);
|
|
404
|
+
if (ownerToken === undefined)
|
|
405
|
+
return false;
|
|
406
|
+
const changed = await transaction(pool, async (client) => {
|
|
407
|
+
const rows = exactRowCount(await client.query(AEB_CONSUMPTION_SQL.releaseOperation, [
|
|
408
|
+
tenantId, relyingPartyId, key, ownerToken,
|
|
409
|
+
]), 'release operation');
|
|
410
|
+
if (rows > 1)
|
|
411
|
+
throw new Error('release operation: unexpected PostgreSQL row count');
|
|
412
|
+
return rows === 1;
|
|
413
|
+
});
|
|
414
|
+
ownedReservations.delete(key);
|
|
415
|
+
return changed;
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
return Object.freeze(store);
|
|
419
|
+
}
|
|
420
|
+
export default {
|
|
421
|
+
AEB_PG_CONSUMPTION_STORE_VERSION,
|
|
422
|
+
AEB_CONSUMPTION_OPERATION_TABLE,
|
|
423
|
+
AEB_CONSUMPTION_REPLAY_TABLE,
|
|
424
|
+
AEB_CONSUMPTION_EXECUTOR_ROLE,
|
|
425
|
+
AEB_CONSUMPTION_RECOVERY_ROLE,
|
|
426
|
+
AEB_CONSUMPTION_DDL,
|
|
427
|
+
AEB_CONSUMPTION_SQL,
|
|
428
|
+
createPostgresAebDurableConsumptionStore,
|
|
429
|
+
};
|
|
430
|
+
//# sourceMappingURL=aeb-consumption-store.js.map
|