@emilia-protocol/gate 0.9.2 → 0.9.4

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/store.js CHANGED
@@ -4,11 +4,14 @@
4
4
  *
5
5
  * A receipt authorizes ONE action, once. The gate consumes a receipt's
6
6
  * identifier the first time it is used; any later presentation of the same
7
- * receipt is a replay and is refused. The default store is in-memory; swap in a
8
- * Redis/DB-backed store with the same `consume(key)` contract for a fleet.
7
+ * receipt is a replay and is refused. The default store is in-memory; fleets
8
+ * use the ownership-fenced durable contract below.
9
9
  */
10
10
  export class MemoryConsumptionStore {
11
11
  constructor() {
12
+ this.durable = false;
13
+ this.ownershipFenced = false;
14
+ this.permanentConsumption = false;
12
15
  this.seen = new Set();
13
16
  this.reserved = new Set();
14
17
  }
@@ -49,41 +52,103 @@ export class MemoryConsumptionStore {
49
52
  }
50
53
  }
51
54
 
55
+ export const DURABLE_CONSUMPTION_VERSION = 'EP-GATE-DURABLE-CONSUMPTION-v2';
56
+
57
+ const COMMITTED_VALUE = 'committed:v2';
58
+ const RESERVED_PREFIX = 'reserved:v2:';
59
+
60
+ function defaultReservationToken() {
61
+ if (typeof globalThis.crypto?.randomUUID !== 'function') {
62
+ throw new Error('secure crypto.randomUUID() is required for durable reservation fencing');
63
+ }
64
+ return globalThis.crypto.randomUUID();
65
+ }
66
+
52
67
  /**
53
68
  * Production custody for replay defense: a durable consumption store backed by
54
69
  * any shared key-value backend (Redis, Postgres, DynamoDB, ...), so a receipt
55
70
  * consumed on one pod/lambda cannot be replayed on another.
56
71
  *
57
- * The backend MUST provide an ATOMIC insert-if-absent this is the single
58
- * correctness primitive that makes replay defense sound under concurrency:
72
+ * The backend MUST provide atomic insert-if-absent plus atomic conditional
73
+ * transition and delete. Together they make replay defense and reservation
74
+ * ownership sound under concurrency:
59
75
  *
60
76
  * 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
77
+ * async addIfAbsent(key, value): boolean
78
+ * async compareAndSet(key, expected, replacement): boolean
79
+ * async deleteIfValue(key, expected): boolean
65
80
  * async has(key): boolean
66
81
  * }
67
82
  *
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).
83
+ * State per receipt id is a single ownership-fenced value. A reservation is
84
+ * `reserved:v2:<random token>` and only the store instance holding that token
85
+ * can commit or release it. This prevents a delayed worker from deleting or
86
+ * committing a newer worker's reservation after timeout/failover.
87
+ *
88
+ * Reservations deliberately receive NO TTL. A crash after an external effect
89
+ * has begun is an indeterminate outcome, and automatically reopening the key
90
+ * would permit a duplicate effect. Operators must reconcile abandoned
91
+ * reservations. `ttlSeconds` applies only after a value is committed, when the
92
+ * receipt's own freshness window independently prevents reuse.
73
93
  */
74
- export function createDurableConsumptionStore(backend, { ttlSeconds } = {}) {
75
- for (const m of ['addIfAbsent', 'set', 'delete', 'has']) {
94
+ export function createDurableConsumptionStore(backend, { ttlSeconds, reservationTokenFactory = defaultReservationToken } = {}) {
95
+ for (const m of ['addIfAbsent', 'compareAndSet', 'deleteIfValue', 'has']) {
76
96
  if (typeof backend?.[m] !== 'function') {
77
97
  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.');
98
+ + 'addIfAbsent and conditional transitions MUST be atomic or replay defense is not fleet-safe.');
79
99
  }
80
100
  }
101
+ if (typeof reservationTokenFactory !== 'function') {
102
+ throw new Error('createDurableConsumptionStore: reservationTokenFactory must be a function');
103
+ }
104
+ if (ttlSeconds !== undefined && ttlSeconds !== null
105
+ && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0)) {
106
+ throw new Error('createDurableConsumptionStore: ttlSeconds must be a positive safe integer when supplied');
107
+ }
81
108
  const opt = ttlSeconds ? { ttlSeconds } : undefined;
109
+ const ownedReservations = new Map();
110
+
111
+ function ownedValue(key) {
112
+ const token = ownedReservations.get(key);
113
+ if (!token) {
114
+ throw new Error(`durable consumption transition refused: this store does not own reservation ${key}`);
115
+ }
116
+ return `${RESERVED_PREFIX}${token}`;
117
+ }
118
+
82
119
  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; },
120
+ durable: backend.durable === true,
121
+ ownershipFenced: true,
122
+ permanentConsumption: ttlSeconds === undefined || ttlSeconds === null,
123
+ retentionSeconds: ttlSeconds ?? null,
124
+ async reserve(key) {
125
+ const token = reservationTokenFactory();
126
+ if (typeof token !== 'string' || token.length < 16) {
127
+ throw new Error('reservationTokenFactory must return an unpredictable string of at least 16 characters');
128
+ }
129
+ const inserted = (await backend.addIfAbsent(key, `${RESERVED_PREFIX}${token}`)) === true;
130
+ if (inserted) ownedReservations.set(key, token);
131
+ return inserted;
132
+ },
133
+ async commit(key) {
134
+ const expected = ownedValue(key);
135
+ const changed = await backend.compareAndSet(key, expected, COMMITTED_VALUE, opt);
136
+ if (changed !== true) {
137
+ throw new Error(`durable consumption commit refused: reservation ownership was lost for ${key}`);
138
+ }
139
+ ownedReservations.delete(key);
140
+ return true;
141
+ },
142
+ async release(key) {
143
+ const expected = ownedValue(key);
144
+ const deleted = await backend.deleteIfValue(key, expected);
145
+ ownedReservations.delete(key);
146
+ if (deleted !== true) {
147
+ throw new Error(`durable consumption release refused: reservation ownership was lost for ${key}`);
148
+ }
149
+ return true;
150
+ },
151
+ async consume(key) { return (await backend.addIfAbsent(key, COMMITTED_VALUE, opt)) === true; },
87
152
  async has(key) { return (await backend.has(key)) === true; },
88
153
  };
89
154
  }
@@ -92,12 +157,21 @@ export function createDurableConsumptionStore(backend, { ttlSeconds } = {}) {
92
157
  export function createMemoryBackend() {
93
158
  const map = new Map();
94
159
  return {
160
+ durable: false,
95
161
  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); },
162
+ async compareAndSet(key, expected, replacement) {
163
+ if (map.get(key) !== expected) return false;
164
+ map.set(key, replacement);
165
+ return true;
166
+ },
167
+ async deleteIfValue(key, expected) {
168
+ if (map.get(key) !== expected) return false;
169
+ return map.delete(key);
170
+ },
98
171
  async has(key) { return map.has(key); },
172
+ async get(key) { return map.get(key); },
99
173
  get size() { return map.size; },
100
174
  };
101
175
  }
102
176
 
103
- export default { MemoryConsumptionStore, createDurableConsumptionStore, createMemoryBackend };
177
+ export default { MemoryConsumptionStore, createDurableConsumptionStore, createMemoryBackend, DURABLE_CONSUMPTION_VERSION };