@emilia-protocol/gate 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/demo.mjs +10 -1
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/eg1-conformance.js +111 -13
- package/enterprise.js +174 -0
- package/index.js +257 -28
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +41 -5
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
package/siem.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — SIEM export of the evidence log (EP-GATE-SIEM-EXPORT-v1).
|
|
4
|
+
*
|
|
5
|
+
* Gate decisions must land where the SOC already looks: Splunk, Sentinel,
|
|
6
|
+
* Datadog. Two static, offline mappings over evidence-log entries:
|
|
7
|
+
* - OCSF (JSON object) for OCSF-native pipelines (Amazon Security Lake,
|
|
8
|
+
* Splunk CIM-OCSF, Sentinel ASIM ingestion);
|
|
9
|
+
* - CEF (single line) as the lowest-common-denominator syslog fallback.
|
|
10
|
+
* Pure functions: entry in, event out — no network, no wall clock. Every
|
|
11
|
+
* timestamp in the output comes from the entry itself, so a fixed entry maps
|
|
12
|
+
* to a byte-identical event on every call and every host.
|
|
13
|
+
*
|
|
14
|
+
* OCSF class choice — class_uid 6003 (API Activity, category 6 Application
|
|
15
|
+
* Activity). The gate is a policy-enforcement point in front of a tool/API
|
|
16
|
+
* call: each evidence entry is one attempted API operation with an
|
|
17
|
+
* allow/deny (or executed/failed) disposition, which is exactly what 6003
|
|
18
|
+
* models via status_id. The IAM alternatives fit worse: 3003 Authorize
|
|
19
|
+
* Session models session-privilege grants (no deny activity), and 6004 Web
|
|
20
|
+
* Resource Access Activity is deprecated in current OCSF.
|
|
21
|
+
*
|
|
22
|
+
* Mapping table (evidence entry → OCSF 6003):
|
|
23
|
+
* entry.at (ISO) → time (epoch ms; 0 sentinel if unparseable)
|
|
24
|
+
* entry.kind → activity_name ('decision'|'execution'), activity_id 99 (Other)
|
|
25
|
+
* entry.action → api.operation
|
|
26
|
+
* entry.allow / outcome → status_id 1 Success / 2 Failure (+ status)
|
|
27
|
+
* entry.reason / outcome → status_detail
|
|
28
|
+
* entry.subject → actor.user.uid
|
|
29
|
+
* entry.receipt_id → metadata.correlation_uid
|
|
30
|
+
* entry.hash → metadata.uid (ties the event to the evidence chain)
|
|
31
|
+
* allow → severity_id 1 Informational; refuse/fail → 3 Medium
|
|
32
|
+
* required_tier, selector, seq, prev_hash → unmapped.* (no OCSF slot)
|
|
33
|
+
*
|
|
34
|
+
* A malformed entry NEVER throws out of the mappers: it becomes a structured
|
|
35
|
+
* error event (status Failure, status_detail 'malformed_evidence_entry') so
|
|
36
|
+
* the corruption itself is visible in the SIEM instead of silently dropped.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export const SIEM_EXPORT_VERSION = 'EP-GATE-SIEM-EXPORT-v1';
|
|
40
|
+
export const SIEM_OCSF_CLASS_UID = 6003; // API Activity
|
|
41
|
+
const OCSF_SCHEMA_VERSION = '1.1.0';
|
|
42
|
+
const CATEGORY_UID = 6; // Application Activity
|
|
43
|
+
const PRODUCT = { name: 'EMILIA Gate', vendor_name: 'Emilia Protocol' };
|
|
44
|
+
const PREVIEW_MAX = 256;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shape-check an evidence entry and derive the disposition. An entry is
|
|
48
|
+
* mappable only if it carries a parseable timestamp AND a verdict (a decision's
|
|
49
|
+
* `allow` boolean or an execution's `outcome` string) — anything else is
|
|
50
|
+
* treated as malformed and surfaced as an error event, never a throw.
|
|
51
|
+
*/
|
|
52
|
+
function classifyEntry(entry) {
|
|
53
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { valid: false };
|
|
54
|
+
const timeMs = typeof entry.at === 'string' ? Date.parse(entry.at) : NaN;
|
|
55
|
+
const hasVerdict = typeof entry.allow === 'boolean' || typeof entry.outcome === 'string';
|
|
56
|
+
if (!Number.isFinite(timeMs) || !hasVerdict) return { valid: false };
|
|
57
|
+
const kind = entry.kind === 'execution' ? 'execution' : 'decision';
|
|
58
|
+
const success = typeof entry.allow === 'boolean' ? entry.allow === true : entry.outcome === 'executed';
|
|
59
|
+
return { valid: true, kind, success, timeMs };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bounded, throw-proof rendering of an unmappable entry for the error event. */
|
|
63
|
+
function safePreview(entry) {
|
|
64
|
+
let s;
|
|
65
|
+
try {
|
|
66
|
+
s = typeof entry === 'string' ? entry : JSON.stringify(entry);
|
|
67
|
+
} catch {
|
|
68
|
+
s = '[unserializable]';
|
|
69
|
+
}
|
|
70
|
+
if (s == null) s = String(entry);
|
|
71
|
+
return s.replace(/[\r\n]+/g, ' ').slice(0, PREVIEW_MAX);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map an evidence-log entry to an OCSF API Activity (6003) event object.
|
|
76
|
+
* Static and deterministic: same entry, same object, always. Malformed input
|
|
77
|
+
* yields a structured error event rather than throwing (see module doc).
|
|
78
|
+
* @param {object} entry one record from evidence.all()
|
|
79
|
+
* @returns {object} OCSF-shaped event
|
|
80
|
+
*/
|
|
81
|
+
export function toOCSF(entry) {
|
|
82
|
+
const c = classifyEntry(entry);
|
|
83
|
+
if (!c.valid) {
|
|
84
|
+
return {
|
|
85
|
+
activity_id: 0,
|
|
86
|
+
activity_name: 'Unknown',
|
|
87
|
+
category_uid: CATEGORY_UID,
|
|
88
|
+
category_name: 'Application Activity',
|
|
89
|
+
class_uid: SIEM_OCSF_CLASS_UID,
|
|
90
|
+
class_name: 'API Activity',
|
|
91
|
+
type_uid: SIEM_OCSF_CLASS_UID * 100 + 0,
|
|
92
|
+
// No trustworthy timestamp inside the entry; 0 sentinel, never the wall
|
|
93
|
+
// clock — the mapping stays pure and the bad entry stays evident.
|
|
94
|
+
time: 0,
|
|
95
|
+
severity_id: 3,
|
|
96
|
+
severity: 'Medium',
|
|
97
|
+
status_id: 2,
|
|
98
|
+
status: 'Failure',
|
|
99
|
+
status_detail: 'malformed_evidence_entry',
|
|
100
|
+
metadata: { version: OCSF_SCHEMA_VERSION, log_name: SIEM_EXPORT_VERSION, product: PRODUCT, uid: null, correlation_uid: null },
|
|
101
|
+
actor: { user: { uid: null } },
|
|
102
|
+
api: { operation: null },
|
|
103
|
+
unmapped: { error: 'malformed_evidence_entry', entry_preview: safePreview(entry) },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
activity_id: 99,
|
|
108
|
+
activity_name: c.kind,
|
|
109
|
+
category_uid: CATEGORY_UID,
|
|
110
|
+
category_name: 'Application Activity',
|
|
111
|
+
class_uid: SIEM_OCSF_CLASS_UID,
|
|
112
|
+
class_name: 'API Activity',
|
|
113
|
+
type_uid: SIEM_OCSF_CLASS_UID * 100 + 99,
|
|
114
|
+
time: c.timeMs,
|
|
115
|
+
severity_id: c.success ? 1 : 3,
|
|
116
|
+
severity: c.success ? 'Informational' : 'Medium',
|
|
117
|
+
status_id: c.success ? 1 : 2,
|
|
118
|
+
status: c.success ? 'Success' : 'Failure',
|
|
119
|
+
status_detail: entry.reason ?? entry.outcome ?? null,
|
|
120
|
+
metadata: {
|
|
121
|
+
version: OCSF_SCHEMA_VERSION,
|
|
122
|
+
log_name: SIEM_EXPORT_VERSION,
|
|
123
|
+
product: PRODUCT,
|
|
124
|
+
uid: entry.hash ?? null,
|
|
125
|
+
correlation_uid: entry.receipt_id ?? null,
|
|
126
|
+
},
|
|
127
|
+
actor: { user: { uid: entry.subject ?? null } },
|
|
128
|
+
api: { operation: entry.action ?? null },
|
|
129
|
+
unmapped: {
|
|
130
|
+
kind: c.kind,
|
|
131
|
+
required_tier: entry.required_tier ?? null,
|
|
132
|
+
selector: entry.selector ?? null,
|
|
133
|
+
evidence_seq: Number.isFinite(entry.seq) ? entry.seq : null,
|
|
134
|
+
prev_hash: entry.prev_hash ?? null,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// CEF escaping per the ArcSight CEF spec: prefix fields escape backslash and
|
|
140
|
+
// pipe; extension values escape backslash and equals. Newlines are collapsed
|
|
141
|
+
// in both — a CEF record is one line, always.
|
|
142
|
+
function escPrefix(v) {
|
|
143
|
+
return String(v).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
|
|
144
|
+
}
|
|
145
|
+
function escExt(v) {
|
|
146
|
+
return String(v).replace(/\\/g, '\\\\').replace(/=/g, '\\=').replace(/[\r\n]+/g, ' ');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Map an evidence-log entry to a one-line CEF string (syslog fallback for
|
|
151
|
+
* SIEMs without OCSF ingestion). Same determinism and malformed-input
|
|
152
|
+
* contract as toOCSF.
|
|
153
|
+
* @param {object} entry one record from evidence.all()
|
|
154
|
+
* @returns {string} `CEF:0|...` single line
|
|
155
|
+
*/
|
|
156
|
+
export function toCEF(entry) {
|
|
157
|
+
const c = classifyEntry(entry);
|
|
158
|
+
if (!c.valid) {
|
|
159
|
+
const ext = [
|
|
160
|
+
'act=error',
|
|
161
|
+
'outcome=malformed_evidence_entry',
|
|
162
|
+
`msg=${escExt(safePreview(entry))}`,
|
|
163
|
+
`cs4=${escExt(SIEM_EXPORT_VERSION)}`, 'cs4Label=export_version',
|
|
164
|
+
].join(' ');
|
|
165
|
+
return `CEF:0|EmiliaProtocol|Gate|1|gate.malformed|malformed evidence entry|5|${ext}`;
|
|
166
|
+
}
|
|
167
|
+
const verdictWord = c.kind === 'execution'
|
|
168
|
+
? (c.success ? 'executed' : 'failed')
|
|
169
|
+
: (c.success ? 'allowed' : 'refused');
|
|
170
|
+
const signatureId = `gate.${c.kind}.${c.success ? 'allow' : 'deny'}`;
|
|
171
|
+
const name = `${entry.action ?? 'unknown_action'} ${verdictWord}`;
|
|
172
|
+
const severity = c.success ? 3 : 7;
|
|
173
|
+
const ext = [`end=${c.timeMs}`, `act=${c.success ? 'allow' : 'deny'}`];
|
|
174
|
+
const reason = entry.reason ?? entry.outcome;
|
|
175
|
+
if (reason != null) ext.push(`outcome=${escExt(reason)}`);
|
|
176
|
+
if (entry.subject != null) ext.push(`suser=${escExt(entry.subject)}`);
|
|
177
|
+
if (entry.receipt_id != null) ext.push(`cs1=${escExt(entry.receipt_id)}`, 'cs1Label=receipt_id');
|
|
178
|
+
if (entry.hash != null) ext.push(`cs2=${escExt(entry.hash)}`, 'cs2Label=evidence_hash');
|
|
179
|
+
if (entry.required_tier != null) ext.push(`cs3=${escExt(entry.required_tier)}`, 'cs3Label=required_tier');
|
|
180
|
+
ext.push(`cs4=${escExt(SIEM_EXPORT_VERSION)}`, 'cs4Label=export_version');
|
|
181
|
+
if (Number.isFinite(entry.seq)) ext.push(`cn1=${entry.seq}`, 'cn1Label=evidence_seq');
|
|
182
|
+
return `CEF:0|EmiliaProtocol|Gate|1|${escPrefix(signatureId)}|${escPrefix(name)}|${severity}|${ext.join(' ')}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create a forwarder that ships evidence entries to a SIEM sink.
|
|
187
|
+
*
|
|
188
|
+
* INVARIANT: SIEM export must NEVER block or crash enforcement. The gate path
|
|
189
|
+
* calls forward() fire-and-forget; a sink that throws, rejects, or is down is
|
|
190
|
+
* recorded on the internal `dropped` counter (exposed via stats()) and NOTHING
|
|
191
|
+
* propagates back to the caller — forward() always resolves, never rejects.
|
|
192
|
+
* This is the inverse of the evidence log's strict mode: the evidence log is
|
|
193
|
+
* the authoritative record and fails closed; the SIEM copy is telemetry and
|
|
194
|
+
* fails open, silently, with an auditable drop count.
|
|
195
|
+
*
|
|
196
|
+
* Configuration errors (unknown format, missing sink) DO throw — at
|
|
197
|
+
* construction time, before anything is on the gate path.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} o
|
|
200
|
+
* @param {'ocsf'|'cef'} [o.format='ocsf']
|
|
201
|
+
* @param {function} o.sink receives the mapped event (object for ocsf, string for cef); may be async
|
|
202
|
+
* @returns {{ forward(entry): Promise<{delivered:boolean, event:object|string|null}>, stats(): object }}
|
|
203
|
+
*/
|
|
204
|
+
export function createSiemForwarder({ format = 'ocsf', sink } = {}) {
|
|
205
|
+
if (format !== 'ocsf' && format !== 'cef') {
|
|
206
|
+
throw new Error(`EMILIA Gate SIEM: unknown format "${format}" (expected 'ocsf' or 'cef')`);
|
|
207
|
+
}
|
|
208
|
+
if (typeof sink !== 'function') {
|
|
209
|
+
throw new Error('EMILIA Gate SIEM: a sink function is required');
|
|
210
|
+
}
|
|
211
|
+
const counts = { forwarded: 0, dropped: 0, malformed: 0 };
|
|
212
|
+
|
|
213
|
+
async function forward(entry) {
|
|
214
|
+
let event = null;
|
|
215
|
+
try {
|
|
216
|
+
if (!classifyEntry(entry).valid) counts.malformed += 1;
|
|
217
|
+
event = format === 'cef' ? toCEF(entry) : toOCSF(entry);
|
|
218
|
+
await sink(event);
|
|
219
|
+
counts.forwarded += 1;
|
|
220
|
+
return { delivered: true, event };
|
|
221
|
+
} catch {
|
|
222
|
+
// Sink failure (or any unexpected mapper failure): counted, never
|
|
223
|
+
// propagated — enforcement must not depend on SIEM availability.
|
|
224
|
+
counts.dropped += 1;
|
|
225
|
+
return { delivered: false, event };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stats() {
|
|
230
|
+
return { format, forwarded: counts.forwarded, dropped: counts.dropped, malformed: counts.malformed };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { forward, stats };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default { SIEM_EXPORT_VERSION, SIEM_OCSF_CLASS_UID, toOCSF, toCEF, createSiemForwarder };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — reference DURABLE consumption backend: Postgres
|
|
4
|
+
* (EP-GATE-PG-CONSUMPTION-v1).
|
|
5
|
+
*
|
|
6
|
+
* Replay defense that survives restarts. This module implements the backend
|
|
7
|
+
* contract consumed by `createDurableConsumptionStore` in ./store.js:
|
|
8
|
+
*
|
|
9
|
+
* backend = {
|
|
10
|
+
* async addIfAbsent(key, value, { ttlSeconds }?) : boolean // true iff inserted
|
|
11
|
+
* async set(key, value, { ttlSeconds }?) : void // overwrite
|
|
12
|
+
* async delete(key) : void
|
|
13
|
+
* async has(key) : boolean
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* plus a `cleanupExpired(now)` garbage-collection statement.
|
|
17
|
+
*
|
|
18
|
+
* THE ONE CORRECTNESS PRIMITIVE — atomic insert-if-absent. `addIfAbsent` is a
|
|
19
|
+
* SINGLE statement: `INSERT ... ON CONFLICT (consumption_key) DO NOTHING`, and
|
|
20
|
+
* the returned row count decides consumed-vs-replay. There is no read-then-
|
|
21
|
+
* write, so there is no race: when two pods consume the same receipt id
|
|
22
|
+
* concurrently, Postgres serializes the inserts on the primary key and exactly
|
|
23
|
+
* one caller sees rowCount === 1. Everyone else is a replay.
|
|
24
|
+
*
|
|
25
|
+
* FAIL-CLOSED CONTRACT: if the injected `query` THROWS (connection down,
|
|
26
|
+
* timeout, constraint other than the PK, ...) the error PROPAGATES to the
|
|
27
|
+
* caller — nothing here catches it. The gate must refuse an action when
|
|
28
|
+
* one-time-use cannot be proven; a backend error is NEVER treated as
|
|
29
|
+
* not-consumed (which would admit replays during an outage) and never treated
|
|
30
|
+
* as consumed-silently (which would mask the outage as a replay verdict).
|
|
31
|
+
* Likewise, an EXPIRED row that cleanup has not yet removed still refuses:
|
|
32
|
+
* TTL expiry is garbage collection, never a grant — the gate already rejects
|
|
33
|
+
* stale receipts on freshness, so nothing is lost by refusing conservatively.
|
|
34
|
+
*
|
|
35
|
+
* Deterministic by construction: the pg-style `query(text, params)` function
|
|
36
|
+
* and the clock (`now`) are both injected, so tests run against an in-memory
|
|
37
|
+
* fake with real ON CONFLICT semantics — no network, no database.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export const PG_CONSUMPTION_VERSION = 'EP-GATE-PG-CONSUMPTION-v1';
|
|
41
|
+
|
|
42
|
+
/** Single consumption table. Timestamps are epoch milliseconds (BIGINT) so the
|
|
43
|
+
* injected JS clock maps 1:1 onto column values with no timezone ambiguity. */
|
|
44
|
+
export const CONSUMPTION_TABLE = 'ep_gate_consumption';
|
|
45
|
+
|
|
46
|
+
export const CONSUMPTION_TABLE_DDL = `CREATE TABLE IF NOT EXISTS ${CONSUMPTION_TABLE} (
|
|
47
|
+
consumption_key TEXT PRIMARY KEY,
|
|
48
|
+
state TEXT NOT NULL,
|
|
49
|
+
consumed_at BIGINT NOT NULL,
|
|
50
|
+
expires_at BIGINT
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS ${CONSUMPTION_TABLE}_expires_idx
|
|
53
|
+
ON ${CONSUMPTION_TABLE} (expires_at) WHERE expires_at IS NOT NULL;`;
|
|
54
|
+
|
|
55
|
+
/** The exact statements the backend issues — exported for transparency and so
|
|
56
|
+
* a fake client can implement them without parsing SQL. */
|
|
57
|
+
export const CONSUMPTION_SQL = {
|
|
58
|
+
/** $1 key, $2 state, $3 consumed_at ms, $4 expires_at ms|null. rowCount 1 = consumed, 0 = replay. */
|
|
59
|
+
addIfAbsent: `INSERT INTO ${CONSUMPTION_TABLE} (consumption_key, state, consumed_at, expires_at) `
|
|
60
|
+
+ 'VALUES ($1, $2, $3, $4) ON CONFLICT (consumption_key) DO NOTHING',
|
|
61
|
+
/** $1 key, $2 state, $3 consumed_at ms, $4 expires_at ms|null. Upsert (overwrite). */
|
|
62
|
+
set: `INSERT INTO ${CONSUMPTION_TABLE} (consumption_key, state, consumed_at, expires_at) `
|
|
63
|
+
+ 'VALUES ($1, $2, $3, $4) ON CONFLICT (consumption_key) DO UPDATE '
|
|
64
|
+
+ 'SET state = EXCLUDED.state, consumed_at = EXCLUDED.consumed_at, expires_at = EXCLUDED.expires_at',
|
|
65
|
+
/** $1 key. */
|
|
66
|
+
delete: `DELETE FROM ${CONSUMPTION_TABLE} WHERE consumption_key = $1`,
|
|
67
|
+
/** $1 key. Any row — even an expired one — counts as consumed until cleaned. */
|
|
68
|
+
has: `SELECT 1 FROM ${CONSUMPTION_TABLE} WHERE consumption_key = $1`,
|
|
69
|
+
/** $1 now ms. Removes ONLY rows whose TTL has elapsed; NULL expires_at never expires. */
|
|
70
|
+
cleanupExpired: `DELETE FROM ${CONSUMPTION_TABLE} WHERE expires_at IS NOT NULL AND expires_at <= $1`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create the Postgres consumption backend.
|
|
75
|
+
* @param {object} o
|
|
76
|
+
* @param {(text: string, params: any[]) => Promise<{ rowCount: number, rows?: any[] }>} o.query
|
|
77
|
+
* pg-style query function (e.g. `pool.query.bind(pool)`). Injected so tests
|
|
78
|
+
* need no database. MUST throw on failure — errors propagate (fail closed).
|
|
79
|
+
* @param {number|function} [o.now=Date.now] injected clock (ms or () => ms).
|
|
80
|
+
* @returns backend conforming to createDurableConsumptionStore, plus cleanupExpired(now).
|
|
81
|
+
*/
|
|
82
|
+
export function createPostgresBackend({ query, now = Date.now } = {}) {
|
|
83
|
+
if (typeof query !== 'function') {
|
|
84
|
+
throw new Error('createPostgresBackend: query must be an async (text, params) => { rowCount } function '
|
|
85
|
+
+ '(e.g. pg pool.query). It must THROW on failure — a backend error must never look like a verdict.');
|
|
86
|
+
}
|
|
87
|
+
const nowMs = () => (typeof now === 'function' ? now() : now);
|
|
88
|
+
const expiryFor = (opt) => {
|
|
89
|
+
const ttl = Number(opt?.ttlSeconds);
|
|
90
|
+
return Number.isFinite(ttl) && ttl > 0 ? nowMs() + ttl * 1000 : null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
/** True iff THIS call inserted the row — the atomic consumed-vs-replay decision. */
|
|
95
|
+
async addIfAbsent(key, value, opt) {
|
|
96
|
+
const res = await query(CONSUMPTION_SQL.addIfAbsent, [key, value, nowMs(), expiryFor(opt)]);
|
|
97
|
+
// Fail closed on a malformed driver result: without a numeric rowCount we
|
|
98
|
+
// cannot PROVE first-use, so refuse loudly rather than guess.
|
|
99
|
+
if (!res || typeof res.rowCount !== 'number') {
|
|
100
|
+
throw new Error('addIfAbsent: query result has no numeric rowCount — cannot prove one-time use');
|
|
101
|
+
}
|
|
102
|
+
return res.rowCount === 1;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Overwrite (used by commit(): 'reserved' -> 'committed'). */
|
|
106
|
+
async set(key, value, opt) {
|
|
107
|
+
await query(CONSUMPTION_SQL.set, [key, value, nowMs(), expiryFor(opt)]);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** Remove (used by release(): a failed action leaves the approval retryable). */
|
|
111
|
+
async delete(key) {
|
|
112
|
+
await query(CONSUMPTION_SQL.delete, [key]);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/** Present = consumed. Expired-but-uncleaned rows still count (conservative). */
|
|
116
|
+
async has(key) {
|
|
117
|
+
const res = await query(CONSUMPTION_SQL.has, [key]);
|
|
118
|
+
return (res?.rows?.length ?? res?.rowCount ?? 0) > 0;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/** Garbage-collect rows whose TTL elapsed. Returns the number removed. */
|
|
122
|
+
async cleanupExpired(at = nowMs()) {
|
|
123
|
+
const res = await query(CONSUMPTION_SQL.cleanupExpired, [at]);
|
|
124
|
+
return res?.rowCount ?? 0;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default { createPostgresBackend, CONSUMPTION_TABLE_DDL, CONSUMPTION_TABLE, CONSUMPTION_SQL, PG_CONSUMPTION_VERSION };
|