@emilia-protocol/gate 0.1.0 → 0.6.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.
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — issuer key registry (production key custody for the VERIFIER).
4
+ *
5
+ * A flat `trustedKeys: string[]` cannot express the two things a production
6
+ * deployment needs:
7
+ * 1. ROTATION — an issuer key is valid only for a window; a new key overlaps
8
+ * the old one, then the old one retires, without rejecting receipts that
9
+ * were legitimately issued while it was current.
10
+ * 2. REVOCATION — a COMPROMISED issuer key must be rejected immediately, for
11
+ * every receipt, regardless of the issuance time the (now-untrusted) key
12
+ * claims. Fail closed: once revoked, the key signs nothing the gate accepts.
13
+ *
14
+ * The registry resolves, for a given receipt issuance time, the set of public
15
+ * keys the gate should verify against — excluding revoked keys entirely and
16
+ * windowed keys whose [not_before, not_after] does not contain that time. The
17
+ * gate passes that resolved set to the receipt verifier, so an excluded key's
18
+ * signature simply does not verify and the action is refused.
19
+ *
20
+ * A key with no window and no revocation behaves exactly like a flat trustedKeys
21
+ * entry (back-compatible).
22
+ */
23
+
24
+ function toMs(t) {
25
+ if (t == null) return null;
26
+ const ms = typeof t === 'number' ? t : Date.parse(t);
27
+ return Number.isFinite(ms) ? ms : null;
28
+ }
29
+
30
+ /**
31
+ * @param {Array<object>} entries each: {
32
+ * kid?: string, key: string (base64url SPKI-DER public key),
33
+ * not_before?: string|number, not_after?: string|number, revoked_at?: string|number
34
+ * }
35
+ */
36
+ export function createKeyRegistry(entries = []) {
37
+ const list = [];
38
+ function normalize(e) {
39
+ if (!e || !e.key || typeof e.key !== 'string') throw new Error('key registry entry requires a base64url SPKI key');
40
+ return {
41
+ kid: e.kid || e.key.slice(0, 16),
42
+ key: e.key,
43
+ not_before: toMs(e.not_before),
44
+ not_after: toMs(e.not_after),
45
+ revoked_at: toMs(e.revoked_at),
46
+ };
47
+ }
48
+ for (const e of entries) list.push(normalize(e));
49
+
50
+ /** Is this entry usable to verify a receipt issued at `atMs`? */
51
+ function entryActiveAt(entry, atMs) {
52
+ if (entry.revoked_at != null) return false; // HARD revocation: never trust a revoked key
53
+ if (entry.not_before != null && atMs != null && atMs < entry.not_before) return false;
54
+ if (entry.not_after != null && atMs != null && atMs > entry.not_after) return false;
55
+ // A windowed key with an unknown issuance time cannot be safely placed in
56
+ // its window — fail closed and exclude it. An unwindowed key still applies.
57
+ if ((entry.not_before != null || entry.not_after != null) && atMs == null) return false;
58
+ return true;
59
+ }
60
+
61
+ return {
62
+ /** The base64url public keys to verify a receipt issued at `at` (ISO or ms). */
63
+ keysValidAt(at) {
64
+ const atMs = toMs(at);
65
+ return list.filter((e) => entryActiveAt(e, atMs)).map((e) => e.key);
66
+ },
67
+ /** Mark a kid revoked as of `at` (default: now-as-supplied). Fail-closed thereafter. */
68
+ revoke(kid, at) {
69
+ let n = 0;
70
+ for (const e of list) {
71
+ if (e.kid === kid && e.revoked_at == null) { e.revoked_at = toMs(at) ?? 0; n += 1; }
72
+ }
73
+ if (n === 0) throw new Error(`key registry: no active key with kid "${kid}" to revoke`);
74
+ return n;
75
+ },
76
+ /** Add/rotate in a new key. */
77
+ add(entry) { list.push(normalize(entry)); return this; },
78
+ /** Operational snapshot (no private material; keys are public). */
79
+ status(at) {
80
+ const atMs = toMs(at);
81
+ return list.map((e) => ({
82
+ kid: e.kid,
83
+ revoked: e.revoked_at != null,
84
+ active: entryActiveAt(e, atMs),
85
+ not_before: e.not_before,
86
+ not_after: e.not_after,
87
+ revoked_at: e.revoked_at,
88
+ }));
89
+ },
90
+ get size() { return list.length; },
91
+ };
92
+ }
93
+
94
+ /** Coerce a flat trustedKeys[] OR a registry into a registry (back-compat). */
95
+ export function asKeyRegistry(trustedKeysOrRegistry) {
96
+ if (trustedKeysOrRegistry && typeof trustedKeysOrRegistry.keysValidAt === 'function') {
97
+ return trustedKeysOrRegistry;
98
+ }
99
+ const keys = Array.isArray(trustedKeysOrRegistry) ? trustedKeysOrRegistry : [];
100
+ return createKeyRegistry(keys.map((key) => ({ key })));
101
+ }
102
+
103
+ export default { createKeyRegistry, asKeyRegistry };
package/mcp.js ADDED
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — MCP drop-in. The lowest-friction place to put the firewall:
4
+ * agents already live at the MCP tool-call boundary, and a single wrapper turns
5
+ * a dangerous MCP tool into a receipt-required one.
6
+ *
7
+ * import { createTrustedActionFirewall } from '@emilia-protocol/gate';
8
+ * import { gateMcpTool } from '@emilia-protocol/gate/mcp';
9
+ * const gate = createTrustedActionFirewall({ trustedKeys: [ISSUER] });
10
+ *
11
+ * server.tool('release_payment',
12
+ * gateMcpTool(gate, { tool: 'release_payment' }, async (args) => actuallyPay(args)));
13
+ *
14
+ * A guarded call with no valid, sufficiently-assured, non-replayed receipt
15
+ * returns a structured MCP error (isError) carrying the Receipt-Required
16
+ * challenge, so the agent knows to go get a human/quorum to authorize THIS exact
17
+ * action. On success the tool runs and an execution proof + reliance packet are
18
+ * attached under `_emilia`.
19
+ *
20
+ * Receipt resolution order (override with opts.receipt): args._emilia_receipt,
21
+ * args.emilia_receipt, then a base64 string in args._emilia_receipt_b64.
22
+ */
23
+
24
+ function resolveReceipt(args, opts) {
25
+ if (typeof opts.receipt === 'function') return opts.receipt(args);
26
+ if (opts.receipt) return opts.receipt;
27
+ if (args && typeof args === 'object') {
28
+ if (args._emilia_receipt) return args._emilia_receipt;
29
+ if (args.emilia_receipt) return args.emilia_receipt;
30
+ if (typeof args._emilia_receipt_b64 === 'string') {
31
+ try { return JSON.parse(Buffer.from(args._emilia_receipt_b64, 'base64').toString('utf8')); } catch { return null; }
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Wrap a single MCP tool handler so it runs only behind a passing gate check.
39
+ * @param {object} gate an EMILIA Gate (createGate/createTrustedActionFirewall)
40
+ * @param {object} o
41
+ * @param {string} o.tool the MCP tool name (matched against the manifest)
42
+ * @param {string} [o.protocol='mcp']
43
+ * @param {string} [o.action] explicit action_type (else resolved by the manifest from {protocol,tool})
44
+ * @param {object|function} [o.observedAction] the system-of-record facts to bind (default: the tool args)
45
+ * @param {object|function} [o.receipt] override receipt resolution
46
+ * @param {function} handler the real tool implementation (args, extra) => result
47
+ * @returns {function} a gated MCP tool handler
48
+ */
49
+ export function gateMcpTool(gate, o = {}, handler) {
50
+ if (!gate || typeof gate.run !== 'function') throw new Error('gateMcpTool requires an EMILIA Gate (with .run)');
51
+ if (typeof handler !== 'function') throw new Error('gateMcpTool requires a tool handler function');
52
+ const { tool, protocol = 'mcp', action } = o;
53
+ if (!tool) throw new Error('gateMcpTool requires { tool }');
54
+
55
+ return async function gatedTool(args = {}, extra) {
56
+ const selector = { protocol, tool, ...(action ? { action_type: action } : {}) };
57
+ const receipt = resolveReceipt(args, o);
58
+ const observedAction = typeof o.observedAction === 'function'
59
+ ? o.observedAction(args, extra)
60
+ : (o.observedAction ?? args);
61
+
62
+ const out = await gate.run({ selector, receipt, observedAction }, () => handler(args, extra));
63
+ if (!out.ok) {
64
+ return {
65
+ isError: true,
66
+ content: [{
67
+ type: 'text',
68
+ text: `EMILIA Gate refused "${tool}": ${out.authorization.reason}. `
69
+ + 'This is a high-risk action; present a valid, sufficiently-assured, unused human/quorum receipt.',
70
+ }],
71
+ _emilia: {
72
+ gate: 'refused',
73
+ status: out.status,
74
+ reason: out.authorization.reason,
75
+ challenge: out.body,
76
+ },
77
+ };
78
+ }
79
+ const result = out.result;
80
+ // Attach the proof without clobbering a structured tool result.
81
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
82
+ return { ...result, _emilia: { gate: 'allowed', execution: out.execution, reliance: out.packet } };
83
+ }
84
+ return { result, _emilia: { gate: 'allowed', execution: out.execution, reliance: out.packet } };
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Convenience: wrap a map of { toolName: handler } in one call. Tools not named
90
+ * in the manifest still pass through the gate (which lets non-guarded tools run).
91
+ */
92
+ export function gateMcpTools(gate, handlers = {}, opts = {}) {
93
+ const wrapped = {};
94
+ for (const [tool, handler] of Object.entries(handlers)) {
95
+ wrapped[tool] = gateMcpTool(gate, { ...opts, tool }, handler);
96
+ }
97
+ return wrapped;
98
+ }
99
+
100
+ export default { gateMcpTool, gateMcpTools };
package/package.json CHANGED
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.1.0",
4
- "description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), and a tamper-evident evidence log. Framework-agnostic, fails closed. Reference implementation, experimental.",
3
+ "version": "0.6.0",
4
+ "description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
- "files": ["index.js", "store.js", "evidence.js", "README.md"],
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./mcp": "./mcp.js",
11
+ "./adapters/github": "./adapters/github.js",
12
+ "./adapters/stripe": "./adapters/stripe.js",
13
+ "./adapters/supabase": "./adapters/supabase.js",
14
+ "./adapters/aws": "./adapters/aws.js",
15
+ "./adapters/k8s": "./adapters/k8s.js",
16
+ "./adapters/terraform": "./adapters/terraform.js",
17
+ "./adapters/gcp": "./adapters/gcp.js",
18
+ "./key-registry": "./key-registry.js",
19
+ "./retention": "./retention.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": ["index.js", "store.js", "evidence.js", "action-packs.js", "execution-binding.js", "reliance-packet.js", "key-registry.js", "retention.js", "eg1-conformance.js", "eg1.mjs", "mcp.js", "adapters/_kit.js", "adapters/github.js", "adapters/github-demo.mjs", "adapters/stripe.js", "adapters/supabase.js", "adapters/aws.js", "adapters/k8s.js", "adapters/terraform.js", "adapters/gcp.js", "demo.mjs", "custody-demo.mjs", "README.md"],
9
23
  "scripts": {
10
24
  "test": "node --test",
11
- "demo": "node demo.mjs"
25
+ "demo": "node demo.mjs",
26
+ "eg1": "node eg1.mjs"
12
27
  },
13
28
  "dependencies": {
14
- "@emilia-protocol/require-receipt": "^0.1.0"
29
+ "@emilia-protocol/require-receipt": "^0.4.1"
15
30
  },
16
31
  "keywords": ["emilia", "authorization", "receipt", "firewall", "trusted-action-firewall", "agent", "mcp", "gate", "policy-enforcement-point", "ai-safety"]
17
32
  }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Auditor / insurer-facing reliance packet for an EMILIA Gate decision.
3
+
4
+ export const RELIANCE_PACKET_VERSION = 'EP-GATE-RELIANCE-PACKET-v1';
5
+
6
+ function evidenceStatus(evidence) {
7
+ if (!evidence) return { ok: null, length: null, head: null };
8
+ if (typeof evidence.verify === 'function') return evidence.verify();
9
+ return evidence;
10
+ }
11
+
12
+ function check(id, ok, detail = null) {
13
+ return { id, ok, ...(detail ? { detail } : {}) };
14
+ }
15
+
16
+ export function buildReliancePacket({
17
+ decision,
18
+ execution = null,
19
+ evidence = null,
20
+ manifest = null,
21
+ binding = null,
22
+ verifier = '@emilia-protocol/gate',
23
+ } = {}) {
24
+ const evidenceCheck = evidenceStatus(evidence);
25
+ const decisionHash = decision?.evidence?.hash || decision?.hash || null;
26
+ const executionBound = !execution || (execution.kind === 'execution' && execution.authorizes_decision === decisionHash);
27
+ const bindingCheck = binding || decision?.evidence?.execution_binding || decision?.execution_binding || null;
28
+ const allowed = decision?.allow === true;
29
+ const evidenceOk = evidenceCheck.ok !== false;
30
+ const bindingOk = bindingCheck ? bindingCheck.ok === true : true;
31
+ const verdict = allowed && executionBound && evidenceOk && bindingOk ? 'rely' : 'do_not_rely';
32
+
33
+ return {
34
+ '@version': RELIANCE_PACKET_VERSION,
35
+ product: 'EMILIA Gate',
36
+ verifier,
37
+ verdict,
38
+ summary: {
39
+ action: decision?.action || null,
40
+ receipt_id: decision?.evidence?.receipt_id || decision?.receipt_id || null,
41
+ subject: decision?.evidence?.subject || null,
42
+ required_tier: decision?.evidence?.required_tier || decision?.required_tier || null,
43
+ observed_tier: decision?.evidence?.have_tier || decision?.have_tier || null,
44
+ decision_hash: decisionHash,
45
+ execution_hash: execution?.hash || null,
46
+ evidence_head: evidenceCheck.head || null,
47
+ },
48
+ checks: [
49
+ check('receipt_present_and_valid', allowed && !String(decision?.reason || '').startsWith('receipt_rejected'), decision?.reason || null),
50
+ check('assurance_sufficient', allowed || decision?.reason !== 'assurance_too_low', decision?.reason === 'assurance_too_low' ? 'receipt tier below action requirement' : null),
51
+ check('receipt_one_time_consumed', allowed || decision?.reason === 'replay_refused' ? decision?.reason !== 'replay_refused' : null),
52
+ check('execution_fields_bound', bindingCheck ? bindingCheck.ok === true : null, bindingCheck ? { missing_observed_fields: bindingCheck.missing_observed_fields || [], mismatched_fields: bindingCheck.mismatched_fields || [] } : 'no material execution-field binding required by this action'),
53
+ check('execution_attests_decision', execution ? executionBound : null, execution ? null : 'no execution record supplied'),
54
+ check('evidence_log_intact', evidenceCheck.ok === undefined ? null : evidenceCheck.ok, evidenceCheck.reason || null),
55
+ ],
56
+ manifest_version: manifest?.['@version'] || null,
57
+ limitations: [
58
+ 'The packet proves the gate verified a receipt and enforced its configured policy; it does not prove the human made a wise decision.',
59
+ 'Identity, authority enrollment, and key custody remain external trust roots that must be operated correctly.',
60
+ 'For execution-field binding, observedAction must come from the system of record, not from attacker-controlled request input.',
61
+ ],
62
+ };
63
+ }
64
+
65
+ export default { RELIANCE_PACKET_VERSION, buildReliancePacket };
package/retention.js ADDED
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — evidence retention policy (production audit custody).
4
+ *
5
+ * The evidence log is the compliance/insurance artifact. Production custody adds
6
+ * a retention POLICY over it: classify each decision/execution record as HOT
7
+ * (recent, fast access), COLD (older, archival), or EXPIRED (past the retention
8
+ * horizon, eligible for deletion) — and honor a LEGAL HOLD that pins records so
9
+ * they are never expired. `EP_AUDIT_HOT_DAYS` / `EP_AUDIT_COLD_DAYS` set the
10
+ * horizons; legal hold is a set of evidence hashes.
11
+ *
12
+ * Pure functions over the evidence entries (each has `.at` ISO and `.hash`); the
13
+ * gate never deletes anything itself — it tells the operator what is eligible.
14
+ */
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ function ageDays(atISO, nowMs) {
19
+ const t = Date.parse(atISO);
20
+ if (!Number.isFinite(t)) return null;
21
+ return (nowMs - t) / DAY_MS;
22
+ }
23
+
24
+ /**
25
+ * Classify evidence entries into retention buckets.
26
+ * @param {Array<{at:string, hash?:string}>} entries evidence.all()
27
+ * @param {object} o
28
+ * @param {number} [o.hotDays=365]
29
+ * @param {number} [o.coldDays=2190] (6y)
30
+ * @param {number} [o.now=Date.now()]
31
+ * @param {Set<string>|string[]} [o.legalHold] hashes pinned indefinitely
32
+ * @returns {{hot:object[], cold:object[], expired:object[], legal_hold:object[], unknown:object[], summary:object}}
33
+ */
34
+ export function classifyRetention(entries = [], {
35
+ hotDays = 365, coldDays = 2190, now = Date.now(), legalHold,
36
+ } = {}) {
37
+ const held = legalHold instanceof Set ? legalHold : new Set(legalHold || []);
38
+ const buckets = { hot: [], cold: [], expired: [], legal_hold: [], unknown: [] };
39
+ for (const e of entries) {
40
+ const tagged = { hash: e.hash ?? null, at: e.at ?? null, kind: e.kind ?? null };
41
+ if (e.hash && held.has(e.hash)) { buckets.legal_hold.push(tagged); continue; }
42
+ const age = ageDays(e.at, now);
43
+ if (age == null) { buckets.unknown.push(tagged); continue; }
44
+ if (age <= hotDays) buckets.hot.push(tagged);
45
+ else if (age <= coldDays) buckets.cold.push(tagged);
46
+ else buckets.expired.push(tagged);
47
+ }
48
+ return {
49
+ ...buckets,
50
+ summary: {
51
+ total: entries.length,
52
+ hot: buckets.hot.length,
53
+ cold: buckets.cold.length,
54
+ expired: buckets.expired.length,
55
+ legal_hold: buckets.legal_hold.length,
56
+ unknown: buckets.unknown.length,
57
+ hot_days: hotDays,
58
+ cold_days: coldDays,
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build an export manifest (the artifact handed to an auditor / SIEM). Includes
65
+ * the evidence head so the export is verifiably tied to a chain state.
66
+ */
67
+ export function buildRetentionExport(entries = [], opts = {}) {
68
+ const cls = classifyRetention(entries, opts);
69
+ const last = entries[entries.length - 1] || null;
70
+ return {
71
+ '@version': 'EP-GATE-RETENTION-EXPORT-v1',
72
+ generated_at: new Date(opts.now || Date.now()).toISOString(),
73
+ hot_days: cls.summary.hot_days,
74
+ cold_days: cls.summary.cold_days,
75
+ evidence_head: last?.hash ?? null,
76
+ counts: {
77
+ total: cls.summary.total, hot: cls.summary.hot, cold: cls.summary.cold,
78
+ expired: cls.summary.expired, legal_hold: cls.summary.legal_hold, unknown: cls.summary.unknown,
79
+ },
80
+ entries: entries.map((e) => ({ hash: e.hash ?? null, at: e.at ?? null, kind: e.kind ?? null })),
81
+ };
82
+ }
83
+
84
+ export const RETENTION_EXPORT_VERSION = 'EP-GATE-RETENTION-EXPORT-v1';
85
+ export default { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION };
package/store.js CHANGED
@@ -10,17 +10,38 @@
10
10
  export class MemoryConsumptionStore {
11
11
  constructor() {
12
12
  this.seen = new Set();
13
+ this.reserved = new Set();
13
14
  }
14
15
 
15
16
  /** Returns true the FIRST time a key is seen, false on every replay. */
16
17
  async consume(key) {
17
- if (this.seen.has(key)) return false;
18
+ if (this.seen.has(key) || this.reserved.has(key)) return false;
18
19
  this.seen.add(key);
19
20
  return true;
20
21
  }
21
22
 
23
+ /** Reserve an id while an action is in flight; blocks concurrent replay. */
24
+ async reserve(key) {
25
+ if (this.seen.has(key) || this.reserved.has(key)) return false;
26
+ this.reserved.add(key);
27
+ return true;
28
+ }
29
+
30
+ /** Commit a reserved id after the action succeeds. */
31
+ async commit(key) {
32
+ this.reserved.delete(key);
33
+ this.seen.add(key);
34
+ return true;
35
+ }
36
+
37
+ /** Release a reserved id after the action fails; approval stays retryable. */
38
+ async release(key) {
39
+ this.reserved.delete(key);
40
+ return true;
41
+ }
42
+
22
43
  async has(key) {
23
- return this.seen.has(key);
44
+ return this.seen.has(key) || this.reserved.has(key);
24
45
  }
25
46
 
26
47
  get size() {
@@ -28,4 +49,55 @@ export class MemoryConsumptionStore {
28
49
  }
29
50
  }
30
51
 
31
- export default { MemoryConsumptionStore };
52
+ /**
53
+ * Production custody for replay defense: a durable consumption store backed by
54
+ * any shared key-value backend (Redis, Postgres, DynamoDB, ...), so a receipt
55
+ * consumed on one pod/lambda cannot be replayed on another.
56
+ *
57
+ * The backend MUST provide an ATOMIC insert-if-absent — this is the single
58
+ * correctness primitive that makes replay defense sound under concurrency:
59
+ *
60
+ * backend = {
61
+ * async addIfAbsent(key, value): boolean // true iff it inserted (Redis SET NX,
62
+ * // Postgres INSERT .. ON CONFLICT DO NOTHING)
63
+ * async set(key, value): void // overwrite
64
+ * async delete(key): void
65
+ * async has(key): boolean
66
+ * }
67
+ *
68
+ * State per receipt id is a single key: 'reserved' while in flight, 'committed'
69
+ * once the action succeeded. reserve() is the atomic gate; a second reserve()
70
+ * (concurrent replay) loses the race and is refused. Optional `ttlSeconds` is
71
+ * passed through to the backend so consumed ids can expire after the receipt's
72
+ * own max age (the gate already rejects stale receipts on freshness).
73
+ */
74
+ export function createDurableConsumptionStore(backend, { ttlSeconds } = {}) {
75
+ for (const m of ['addIfAbsent', 'set', 'delete', 'has']) {
76
+ if (typeof backend?.[m] !== 'function') {
77
+ throw new Error(`createDurableConsumptionStore: backend must implement async ${m}(). `
78
+ + 'addIfAbsent MUST be atomic (e.g. Redis SET NX) or replay defense is not fleet-safe.');
79
+ }
80
+ }
81
+ const opt = ttlSeconds ? { ttlSeconds } : undefined;
82
+ return {
83
+ async reserve(key) { return (await backend.addIfAbsent(key, 'reserved', opt)) === true; },
84
+ async commit(key) { await backend.set(key, 'committed', opt); return true; },
85
+ async release(key) { await backend.delete(key); return true; },
86
+ async consume(key) { return (await backend.addIfAbsent(key, 'committed', opt)) === true; },
87
+ async has(key) { return (await backend.has(key)) === true; },
88
+ };
89
+ }
90
+
91
+ /** In-memory reference backend (for tests/single-process). addIfAbsent is atomic in a single thread. */
92
+ export function createMemoryBackend() {
93
+ const map = new Map();
94
+ return {
95
+ async addIfAbsent(key, value) { if (map.has(key)) return false; map.set(key, value); return true; },
96
+ async set(key, value) { map.set(key, value); },
97
+ async delete(key) { map.delete(key); },
98
+ async has(key) { return map.has(key); },
99
+ get size() { return map.size; },
100
+ };
101
+ }
102
+
103
+ export default { MemoryConsumptionStore, createDurableConsumptionStore, createMemoryBackend };