@emilia-protocol/gate 0.9.3 → 0.9.5
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 +35 -7
- package/aec-execution.js +315 -0
- package/challenge-store.js +70 -0
- package/deploy/helm/README.md +1 -1
- package/deploy/helm/emilia-gate/Chart.yaml +1 -1
- package/enterprise.js +4 -1
- package/ep-assure.mjs +65 -0
- package/evidence.js +273 -4
- package/index.js +47 -15
- package/package.json +21 -3
- package/reliance-kernel.js +114 -0
- package/reports/assurance-package.js +287 -0
- package/store-postgres.js +45 -26
- package/store.js +97 -23
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EP-ASSURANCE-PACKAGE-v1 — the reliance assurance layer.
|
|
4
|
+
*
|
|
5
|
+
* The layer above the reliance kernel. The kernel answers "may this party rely on
|
|
6
|
+
* this action?" The assurance package answers the question an INDEPENDENT assurer
|
|
7
|
+
* (an audit firm, a regulator, an insurer) asks: "can I reproduce, test, and
|
|
8
|
+
* attest that an organization's automated actions were governed by admissible
|
|
9
|
+
* evidence under the organization's OWN pinned rule?"
|
|
10
|
+
*
|
|
11
|
+
* Two halves, mirroring the re-performance discipline of reperform.js:
|
|
12
|
+
* buildAssurancePackage(decisions, ...) the organization bundles its
|
|
13
|
+
* automated decisions + the evidence each relied on into ONE portable,
|
|
14
|
+
* content-addressed package (action, receipt, profile, authority proof,
|
|
15
|
+
* revocation check, consumption, denial/exception history, policy hash).
|
|
16
|
+
* reperformAssurancePackage(pkg, ...) the assurer RE-PERFORMS every
|
|
17
|
+
* reliance verdict offline from that evidence, TRUSTING NOTHING the package
|
|
18
|
+
* asserts: it recomputes each verdict with evaluateReliance, compares to the
|
|
19
|
+
* verdict the org's runtime CLAIMED (drift = a control failure the assurer
|
|
20
|
+
* caught), maps every verdict to a control objective, and emits an
|
|
21
|
+
* auditor-style workpaper. It does not conclude; the assurer concludes.
|
|
22
|
+
*
|
|
23
|
+
* PCAOB AS 1105 alignment (why this is audit evidence): the source is
|
|
24
|
+
* cryptographic, the decision trail is immutable and content-addressed, the rule
|
|
25
|
+
* is a pinned profile, and the verdict is re-performable directly. This module
|
|
26
|
+
* SUPPORTS a re-performance procedure; it never issues an opinion.
|
|
27
|
+
*/
|
|
28
|
+
import { hashCanonical } from '../execution-binding.js';
|
|
29
|
+
|
|
30
|
+
export const ASSURANCE_PACKAGE_VERSION = 'EP-ASSURANCE-PACKAGE-v1';
|
|
31
|
+
export const ASSURANCE_REPERFORMANCE_VERSION = 'EP-ASSURANCE-REPERFORMANCE-v1';
|
|
32
|
+
|
|
33
|
+
// Cross-package resolution, same pattern the Gate uses: the pure offline reliance
|
|
34
|
+
// verifier. No DB, no network.
|
|
35
|
+
const { evaluateReliance, RELIANCE_VERDICTS } = await import('@emilia-protocol/verify/reliance')
|
|
36
|
+
.catch(() => import('../../verify/reliance.js'));
|
|
37
|
+
|
|
38
|
+
export { RELIANCE_VERDICTS };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The reliance control catalog: every reliance verdict maps to the control
|
|
42
|
+
* objective it exercises. A `rely` shows the control PASSING; every do_not_rely_*
|
|
43
|
+
* shows the control OPERATING (it refused a non-admissible action). Denials are
|
|
44
|
+
* the control working, not the control failing.
|
|
45
|
+
*/
|
|
46
|
+
export const RELIANCE_CONTROL_CATALOG = Object.freeze({
|
|
47
|
+
'RC-1': { objective: 'Only a human with valid organization-bound, scoped authority for THIS exact action may authorize it', verdicts: ['do_not_rely_authority_missing', 'do_not_rely_authority_subject_mismatch', 'do_not_rely_authority_organization_mismatch', 'do_not_rely_authority_revoked', 'do_not_rely_authority_expired', 'do_not_rely_scope_mismatch', 'do_not_rely_amount_exceeded', 'do_not_rely_registry_unavailable'] },
|
|
48
|
+
'RC-2': { objective: 'Authorization uses a device-bound named-human ceremony (Class-A or quorum)', verdicts: ['do_not_rely_no_class_a', 'do_not_rely_quorum_unsatisfied'] },
|
|
49
|
+
'RC-3': { objective: 'The action conforms to a pinned, accepted policy', verdicts: ['do_not_rely_policy_mismatch'] },
|
|
50
|
+
'RC-4': { objective: 'Authorization is consumed exactly once (no replay)', verdicts: ['do_not_rely_already_consumed'] },
|
|
51
|
+
'RC-5': { objective: 'Reliance is evaluated against fresh revocation state', verdicts: ['do_not_rely_stale_revocation'] },
|
|
52
|
+
'RC-6': { objective: 'Evidence is signed by a trusted issuer and evaluated under a pinned rule', verdicts: ['do_not_rely_unsigned', 'do_not_rely_untrusted_issuer', 'do_not_rely_no_profile'] },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const VERDICT_TO_CONTROL = Object.freeze(
|
|
56
|
+
Object.entries(RELIANCE_CONTROL_CATALOG).reduce((m, [cid, c]) => {
|
|
57
|
+
for (const v of c.verdicts) m[v] = cid;
|
|
58
|
+
return m;
|
|
59
|
+
}, {}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** Which control objective a verdict exercises (rely passes ALL, so returns null). */
|
|
63
|
+
function controlForVerdict(verdict) {
|
|
64
|
+
if (verdict === 'rely') return null;
|
|
65
|
+
return VERDICT_TO_CONTROL[verdict] || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toIso(now) {
|
|
69
|
+
const ms = typeof now === 'function' ? now() : now;
|
|
70
|
+
return new Date(ms == null ? 0 : ms).toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bundle N automated reliance decisions + the evidence each relied on into one
|
|
75
|
+
* portable, content-addressed assurance package. Does NOT re-perform (that is the
|
|
76
|
+
* assurer's independent step); it packages faithfully, including the verdict the
|
|
77
|
+
* org's runtime CLAIMED, so drift is checkable later.
|
|
78
|
+
*
|
|
79
|
+
* @param {Array<object>} decisions each: { decision_id, action, receipt, quorum?,
|
|
80
|
+
* authority_proof?, revocation_state?, consumption?, stated_verdict? }
|
|
81
|
+
* @param {object} opts
|
|
82
|
+
* @param {object} opts.profile the pinned EP-RELIANCE-PROFILE-v1 the org operated under
|
|
83
|
+
* @param {object} [opts.organization] { id, name } (no PHI)
|
|
84
|
+
* @param {number|function} [opts.now]
|
|
85
|
+
* @returns {object} EP-ASSURANCE-PACKAGE-v1
|
|
86
|
+
*/
|
|
87
|
+
export function buildAssurancePackage(decisions = [], { profile, organization = null, now = 0 } = {}) {
|
|
88
|
+
if (!Array.isArray(decisions)) throw new Error('assurance-package: decisions must be an array');
|
|
89
|
+
const items = decisions.map((d, i) => {
|
|
90
|
+
const decision_id = d?.decision_id ?? `decision-${i}`;
|
|
91
|
+
return {
|
|
92
|
+
decision_id,
|
|
93
|
+
action: d?.action ?? null,
|
|
94
|
+
policy_hash: d?.action?.policy_hash ?? null,
|
|
95
|
+
stated_verdict: typeof d?.stated_verdict === 'string' ? d.stated_verdict : null,
|
|
96
|
+
evidence: {
|
|
97
|
+
receipt: d?.receipt ?? null,
|
|
98
|
+
quorum: d?.quorum ?? null,
|
|
99
|
+
authority_proof: d?.authority_proof ?? null,
|
|
100
|
+
revocation_state: d?.revocation_state ?? null,
|
|
101
|
+
consumption: d?.consumption ?? null,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
// Denial/exception history = the decisions the org itself recorded as refused.
|
|
106
|
+
const exceptions = items.filter((it) => it.stated_verdict && it.stated_verdict !== 'rely')
|
|
107
|
+
.map((it) => ({ decision_id: it.decision_id, stated_verdict: it.stated_verdict, control_id: controlForVerdict(it.stated_verdict) }));
|
|
108
|
+
|
|
109
|
+
const body = {
|
|
110
|
+
'@version': ASSURANCE_PACKAGE_VERSION,
|
|
111
|
+
organization,
|
|
112
|
+
reliance_profile: profile ?? null,
|
|
113
|
+
profile_hash: profile ? hashCanonical(profile) : null,
|
|
114
|
+
control_catalog: RELIANCE_CONTROL_CATALOG,
|
|
115
|
+
decisions: items,
|
|
116
|
+
exception_history: exceptions,
|
|
117
|
+
counts: {
|
|
118
|
+
decisions: items.length,
|
|
119
|
+
stated_admissible: items.filter((it) => it.stated_verdict === 'rely').length,
|
|
120
|
+
stated_refused: items.filter((it) => it.stated_verdict && it.stated_verdict !== 'rely').length,
|
|
121
|
+
stated_unknown: items.filter((it) => !it.stated_verdict).length,
|
|
122
|
+
},
|
|
123
|
+
assembled_at: toIso(now),
|
|
124
|
+
};
|
|
125
|
+
// Content address over everything EXCEPT the timestamp (so the same evidence
|
|
126
|
+
// yields the same digest regardless of when it was packaged).
|
|
127
|
+
const { assembled_at: _t, ...digestScope } = body;
|
|
128
|
+
return { ...body, package_digest: hashCanonical(digestScope) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* INDEPENDENT re-performance. Recompute every reliance verdict offline from the
|
|
133
|
+
* packaged evidence under the package's pinned profile and AUDITOR-supplied keys,
|
|
134
|
+
* trusting nothing the package asserts. Detect drift (recomputed ≠ stated), map
|
|
135
|
+
* to control objectives, and emit an auditor-style workpaper. Conclusion fields
|
|
136
|
+
* are ALWAYS null: the assurer concludes, not this tool.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} pkg an EP-ASSURANCE-PACKAGE-v1
|
|
139
|
+
* @param {object} opts
|
|
140
|
+
* @param {object} [opts.approverKeys] auditor-pinned approver keys (out of band)
|
|
141
|
+
* @param {string} [opts.logPublicKey] auditor-pinned transparency-log key
|
|
142
|
+
* @param {string} [opts.rpId]
|
|
143
|
+
* @param {object} [opts.revokerKeys]
|
|
144
|
+
* @param {(key:object)=>boolean} [opts.isConsumed] auditor-owned consumption lookup
|
|
145
|
+
* @param {number|string|Date} [opts.now] reliance-evaluation clock (pin for determinism)
|
|
146
|
+
* @returns {object} EP-ASSURANCE-REPERFORMANCE-v1
|
|
147
|
+
*/
|
|
148
|
+
export function reperformAssurancePackage(pkg, { approverKeys = {}, logPublicKey = null, rpId = null, revokerKeys = {}, isConsumed, now = 0 } = {}) {
|
|
149
|
+
if (!pkg || pkg['@version'] !== ASSURANCE_PACKAGE_VERSION) throw new Error('assurance-reperform: not an EP-ASSURANCE-PACKAGE-v1');
|
|
150
|
+
const profile = pkg.reliance_profile;
|
|
151
|
+
const evalOpts = { approverKeys, logPublicKey, rpId, revokerKeys, ...(typeof isConsumed === 'function' ? { isConsumed } : {}) };
|
|
152
|
+
const relianceNow = typeof now === 'function' ? now() : now;
|
|
153
|
+
|
|
154
|
+
const results = (Array.isArray(pkg.decisions) ? pkg.decisions : []).map((it) => {
|
|
155
|
+
const ev = it.evidence || {};
|
|
156
|
+
const input = {
|
|
157
|
+
action: it.action || {},
|
|
158
|
+
receipt: ev.receipt,
|
|
159
|
+
quorum: ev.quorum || undefined,
|
|
160
|
+
authority_proof: ev.authority_proof || undefined,
|
|
161
|
+
revocation_state: ev.revocation_state || undefined,
|
|
162
|
+
consumption: ev.consumption || undefined,
|
|
163
|
+
relying_party_profile: profile,
|
|
164
|
+
now: relianceNow,
|
|
165
|
+
};
|
|
166
|
+
let recomputed;
|
|
167
|
+
try {
|
|
168
|
+
recomputed = evaluateReliance(input, evalOpts);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
recomputed = { verdict: 'do_not_rely_unsigned', rely: false, reasons: [`reperform_error:${err?.message || 'threw'}`] };
|
|
171
|
+
}
|
|
172
|
+
const recomputed_verdict = recomputed.verdict;
|
|
173
|
+
const stated_verdict = it.stated_verdict ?? null;
|
|
174
|
+
// Drift: the org's runtime claimed one outcome, independent re-performance
|
|
175
|
+
// computed another. A claimed `rely` that recomputes to a refusal is the
|
|
176
|
+
// material finding — the org relied on evidence that does not support reliance.
|
|
177
|
+
const drift = stated_verdict !== null && stated_verdict !== recomputed_verdict;
|
|
178
|
+
return {
|
|
179
|
+
decision_id: it.decision_id,
|
|
180
|
+
action_type: it.action?.action_type ?? null,
|
|
181
|
+
stated_verdict,
|
|
182
|
+
recomputed_verdict,
|
|
183
|
+
admissible: recomputed_verdict === 'rely',
|
|
184
|
+
drift,
|
|
185
|
+
drift_severity: drift ? (stated_verdict === 'rely' ? 'relied_on_inadmissible_evidence' : 'refused_admissible_or_reclassified') : null,
|
|
186
|
+
control_id: controlForVerdict(recomputed_verdict),
|
|
187
|
+
reasons: recomputed.reasons || [],
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const byVerdict = Object.create(null);
|
|
192
|
+
const byControl = Object.create(null);
|
|
193
|
+
let admissible = 0;
|
|
194
|
+
let refused = 0;
|
|
195
|
+
let drift = 0;
|
|
196
|
+
for (const r of results) {
|
|
197
|
+
byVerdict[r.recomputed_verdict] = (byVerdict[r.recomputed_verdict] || 0) + 1;
|
|
198
|
+
if (r.admissible) admissible += 1; else refused += 1;
|
|
199
|
+
if (r.drift) drift += 1;
|
|
200
|
+
if (r.control_id) byControl[r.control_id] = (byControl[r.control_id] || 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Recompute the package digest from the package's OWN contents rather than
|
|
204
|
+
// trusting pkg.package_digest. "No value the package asserts is trusted" has to
|
|
205
|
+
// include the digest: copying the stated one verbatim would let a tampered
|
|
206
|
+
// package carry a lying content-address through re-performance unchecked. Mirror
|
|
207
|
+
// buildAssurancePackage's digestScope exactly (body minus assembled_at and the
|
|
208
|
+
// digest field itself), then compare.
|
|
209
|
+
const { assembled_at: _statedAt, package_digest: _statedDigest, ...digestScope } = pkg;
|
|
210
|
+
const recomputedPackageDigest = hashCanonical(digestScope);
|
|
211
|
+
const packageDigestVerified = pkg.package_digest != null && recomputedPackageDigest === pkg.package_digest;
|
|
212
|
+
|
|
213
|
+
const doc = {
|
|
214
|
+
'@version': ASSURANCE_REPERFORMANCE_VERSION,
|
|
215
|
+
product: 'EMILIA Reliance Assurance',
|
|
216
|
+
package_digest: recomputedPackageDigest,
|
|
217
|
+
stated_package_digest: pkg.package_digest ?? null,
|
|
218
|
+
package_digest_verified: packageDigestVerified,
|
|
219
|
+
profile_hash: pkg.profile_hash ?? null,
|
|
220
|
+
generated_at: toIso(now),
|
|
221
|
+
honesty: {
|
|
222
|
+
reperforms:
|
|
223
|
+
'Independent recomputation of every reliance verdict from the packaged evidence, under the package\'s pinned '
|
|
224
|
+
+ 'EP-RELIANCE-PROFILE-v1 and auditor-supplied keys, using the offline reliance kernel. No value the package '
|
|
225
|
+
+ 'asserts (including the stated verdict) is trusted; the stated verdict is compared, never relied on.',
|
|
226
|
+
does_not_establish: [
|
|
227
|
+
'Completeness of the decision population: decisions withheld before packaging are not detectable from the package alone. Bind the population to an externally anchored count to close this.',
|
|
228
|
+
'Runtime freshness or one-time consumption AT THE MOMENT OF DECISION: those were live properties; re-performance checks the evidence as packaged, not the runtime state that existed then.',
|
|
229
|
+
'Issuer, approver, and registrar key custody, enrollment, or identity proofing, which remain external trust roots the auditor supplies out of band.',
|
|
230
|
+
'The business correctness or wisdom of any authorized action.',
|
|
231
|
+
],
|
|
232
|
+
status: 'Support for an audit re-performance procedure. This document does not conclude, opine, or certify; any conclusion is the auditor\'s.',
|
|
233
|
+
},
|
|
234
|
+
population: {
|
|
235
|
+
decisions: results.length,
|
|
236
|
+
admissible,
|
|
237
|
+
refused,
|
|
238
|
+
drift,
|
|
239
|
+
relied_on_inadmissible_evidence: results.filter((r) => r.drift_severity === 'relied_on_inadmissible_evidence').length,
|
|
240
|
+
by_recomputed_verdict: byVerdict,
|
|
241
|
+
by_control: byControl,
|
|
242
|
+
},
|
|
243
|
+
control_catalog: RELIANCE_CONTROL_CATALOG,
|
|
244
|
+
results,
|
|
245
|
+
reperformance_digest: null, // filled below
|
|
246
|
+
// Conclusion fields are ALWAYS null: a machine may support re-performance, it
|
|
247
|
+
// may never fill in the auditor's sign-off. A renderer must refuse to print a
|
|
248
|
+
// non-null conclusion here.
|
|
249
|
+
conclusion: { supportable: null, opinion: null, signed_off_by: null },
|
|
250
|
+
};
|
|
251
|
+
// Deterministic re-performance digest over the recomputed results + population
|
|
252
|
+
// (excludes timestamps), so a second assurer reproduces it byte-for-byte.
|
|
253
|
+
doc.reperformance_digest = hashCanonical({ package_digest: doc.package_digest, population: doc.population, results });
|
|
254
|
+
return doc;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Render a plain-text auditor workpaper. Refuses to print a filled conclusion. */
|
|
258
|
+
export function renderAssuranceWorkpaper(doc) {
|
|
259
|
+
if (!doc || doc['@version'] !== ASSURANCE_REPERFORMANCE_VERSION) throw new Error('render: not an EP-ASSURANCE-REPERFORMANCE-v1');
|
|
260
|
+
if (doc.conclusion && (doc.conclusion.supportable !== null || doc.conclusion.opinion !== null || doc.conclusion.signed_off_by !== null)) {
|
|
261
|
+
throw new Error('render refused: conclusion fields must be null (the auditor concludes, not the tool)');
|
|
262
|
+
}
|
|
263
|
+
const p = doc.population;
|
|
264
|
+
const lines = [];
|
|
265
|
+
lines.push(`EMILIA Reliance Assurance — re-performance workpaper (${doc['@version']})`);
|
|
266
|
+
lines.push(`package_digest: ${doc.package_digest} (recomputed)`);
|
|
267
|
+
lines.push(`package_digest match: ${doc.package_digest_verified ? 'YES — recomputed digest equals the package\'s stated digest' : 'NO — stated digest does NOT match recomputed contents (tamper or drift)'}`);
|
|
268
|
+
lines.push(`reperformance_digest: ${doc.reperformance_digest}`);
|
|
269
|
+
lines.push('');
|
|
270
|
+
lines.push(`Population: ${p.decisions} decisions | admissible(rely): ${p.admissible} | refused: ${p.refused} | drift: ${p.drift}`);
|
|
271
|
+
lines.push(`Relied on INADMISSIBLE evidence (claimed rely, recomputed refusal): ${p.relied_on_inadmissible_evidence}`);
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push('By recomputed verdict:');
|
|
274
|
+
for (const [v, n] of Object.entries(p.by_recomputed_verdict)) lines.push(` ${v}: ${n}`);
|
|
275
|
+
lines.push('');
|
|
276
|
+
lines.push('Control objectives exercised (denials are the control operating):');
|
|
277
|
+
for (const [cid, n] of Object.entries(p.by_control)) lines.push(` ${cid} (${RELIANCE_CONTROL_CATALOG[cid].objective}): ${n}`);
|
|
278
|
+
lines.push('');
|
|
279
|
+
const drifts = doc.results.filter((r) => r.drift);
|
|
280
|
+
if (drifts.length) {
|
|
281
|
+
lines.push('DRIFT (independent re-performance disagrees with the runtime\'s stated verdict):');
|
|
282
|
+
for (const r of drifts) lines.push(` ${r.decision_id}: stated=${r.stated_verdict} recomputed=${r.recomputed_verdict} [${r.drift_severity}]`);
|
|
283
|
+
lines.push('');
|
|
284
|
+
}
|
|
285
|
+
lines.push('Conclusion: NULL by construction. The auditor concludes; this workpaper only supports the procedure.');
|
|
286
|
+
return lines.join('\n');
|
|
287
|
+
}
|
package/store-postgres.js
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
/**
|
|
3
3
|
* EMILIA Gate — reference DURABLE consumption backend: Postgres
|
|
4
|
-
* (EP-GATE-PG-CONSUMPTION-
|
|
4
|
+
* (EP-GATE-PG-CONSUMPTION-v2).
|
|
5
5
|
*
|
|
6
6
|
* Replay defense that survives restarts. This module implements the backend
|
|
7
7
|
* contract consumed by `createDurableConsumptionStore` in ./store.js:
|
|
8
8
|
*
|
|
9
9
|
* backend = {
|
|
10
10
|
* async addIfAbsent(key, value, { ttlSeconds }?) : boolean // true iff inserted
|
|
11
|
-
* async
|
|
12
|
-
* async
|
|
11
|
+
* async compareAndSet(key, expected, replacement, { ttlSeconds }?) : boolean
|
|
12
|
+
* async deleteIfValue(key, expected) : boolean
|
|
13
13
|
* async has(key) : boolean
|
|
14
14
|
* }
|
|
15
15
|
*
|
|
16
16
|
* plus a `cleanupExpired(now)` garbage-collection statement.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
* concurrently, Postgres serializes the inserts on the primary key and exactly
|
|
23
|
-
* one caller sees rowCount === 1. Everyone else is a replay.
|
|
18
|
+
* Each transition is one SQL statement. `addIfAbsent` decides first use;
|
|
19
|
+
* `compareAndSet` and `deleteIfValue` bind commit/release to the opaque owner of
|
|
20
|
+
* the current reservation. There is no read-then-write interval in which a
|
|
21
|
+
* delayed worker can overwrite or remove a newer worker's reservation.
|
|
24
22
|
*
|
|
25
23
|
* FAIL-CLOSED CONTRACT: if the injected `query` THROWS (connection down,
|
|
26
24
|
* timeout, constraint other than the PK, ...) the error PROPAGATES to the
|
|
@@ -37,7 +35,7 @@
|
|
|
37
35
|
* fake with real ON CONFLICT semantics — no network, no database.
|
|
38
36
|
*/
|
|
39
37
|
|
|
40
|
-
export const PG_CONSUMPTION_VERSION = 'EP-GATE-PG-CONSUMPTION-
|
|
38
|
+
export const PG_CONSUMPTION_VERSION = 'EP-GATE-PG-CONSUMPTION-v2';
|
|
41
39
|
|
|
42
40
|
/** Single consumption table. Timestamps are epoch milliseconds (BIGINT) so the
|
|
43
41
|
* injected JS clock maps 1:1 onto column values with no timezone ambiguity. */
|
|
@@ -58,16 +56,16 @@ export const CONSUMPTION_SQL = {
|
|
|
58
56
|
/** $1 key, $2 state, $3 consumed_at ms, $4 expires_at ms|null. rowCount 1 = consumed, 0 = replay. */
|
|
59
57
|
addIfAbsent: `INSERT INTO ${CONSUMPTION_TABLE} (consumption_key, state, consumed_at, expires_at) `
|
|
60
58
|
+ 'VALUES ($1, $2, $3, $4) ON CONFLICT (consumption_key) DO NOTHING',
|
|
61
|
-
/** $1 key, $2 state, $3 consumed_at ms, $
|
|
62
|
-
|
|
63
|
-
+ '
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
delete: `DELETE FROM ${CONSUMPTION_TABLE} WHERE consumption_key = $1`,
|
|
59
|
+
/** $1 key, $2 expected state, $3 replacement, $4 consumed_at ms, $5 expires_at ms|null. */
|
|
60
|
+
compareAndSet: `UPDATE ${CONSUMPTION_TABLE} SET state = $3, consumed_at = $4, expires_at = $5 `
|
|
61
|
+
+ 'WHERE consumption_key = $1 AND state = $2',
|
|
62
|
+
/** $1 key, $2 expected state. */
|
|
63
|
+
deleteIfValue: `DELETE FROM ${CONSUMPTION_TABLE} WHERE consumption_key = $1 AND state = $2`,
|
|
67
64
|
/** $1 key. Any row — even an expired one — counts as consumed until cleaned. */
|
|
68
65
|
has: `SELECT 1 FROM ${CONSUMPTION_TABLE} WHERE consumption_key = $1`,
|
|
69
66
|
/** $1 now ms. Removes ONLY rows whose TTL has elapsed; NULL expires_at never expires. */
|
|
70
|
-
cleanupExpired: `DELETE FROM ${CONSUMPTION_TABLE} WHERE
|
|
67
|
+
cleanupExpired: `DELETE FROM ${CONSUMPTION_TABLE} WHERE state LIKE 'committed:%' `
|
|
68
|
+
+ 'AND expires_at IS NOT NULL AND expires_at <= $1',
|
|
71
69
|
};
|
|
72
70
|
|
|
73
71
|
/**
|
|
@@ -84,13 +82,25 @@ export function createPostgresBackend({ query, now = Date.now } = {}) {
|
|
|
84
82
|
throw new Error('createPostgresBackend: query must be an async (text, params) => { rowCount } function '
|
|
85
83
|
+ '(e.g. pg pool.query). It must THROW on failure — a backend error must never look like a verdict.');
|
|
86
84
|
}
|
|
87
|
-
|
|
85
|
+
let lastObservedNow = Number.NEGATIVE_INFINITY;
|
|
86
|
+
const checkedNow = (candidate) => {
|
|
87
|
+
if (!Number.isSafeInteger(candidate) || candidate < 0) {
|
|
88
|
+
throw new Error('consumption clock must return a non-negative safe-integer epoch millisecond');
|
|
89
|
+
}
|
|
90
|
+
if (candidate < lastObservedNow) {
|
|
91
|
+
throw new Error(`consumption clock regression refused: ${candidate} < ${lastObservedNow}`);
|
|
92
|
+
}
|
|
93
|
+
lastObservedNow = candidate;
|
|
94
|
+
return candidate;
|
|
95
|
+
};
|
|
96
|
+
const nowMs = () => checkedNow(typeof now === 'function' ? now() : now);
|
|
88
97
|
const expiryFor = (opt) => {
|
|
89
98
|
const ttl = Number(opt?.ttlSeconds);
|
|
90
99
|
return Number.isFinite(ttl) && ttl > 0 ? nowMs() + ttl * 1000 : null;
|
|
91
100
|
};
|
|
92
101
|
|
|
93
102
|
return {
|
|
103
|
+
durable: true,
|
|
94
104
|
/** True iff THIS call inserted the row — the atomic consumed-vs-replay decision. */
|
|
95
105
|
async addIfAbsent(key, value, opt) {
|
|
96
106
|
const res = await query(CONSUMPTION_SQL.addIfAbsent, [key, value, nowMs(), expiryFor(opt)]);
|
|
@@ -102,14 +112,22 @@ export function createPostgresBackend({ query, now = Date.now } = {}) {
|
|
|
102
112
|
return res.rowCount === 1;
|
|
103
113
|
},
|
|
104
114
|
|
|
105
|
-
/**
|
|
106
|
-
async
|
|
107
|
-
await query(CONSUMPTION_SQL.
|
|
115
|
+
/** Ownership-fenced reserved -> committed transition. */
|
|
116
|
+
async compareAndSet(key, expected, replacement, opt) {
|
|
117
|
+
const res = await query(CONSUMPTION_SQL.compareAndSet, [key, expected, replacement, nowMs(), expiryFor(opt)]);
|
|
118
|
+
if (!res || typeof res.rowCount !== 'number') {
|
|
119
|
+
throw new Error('compareAndSet: query result has no numeric rowCount — cannot prove reservation ownership');
|
|
120
|
+
}
|
|
121
|
+
return res.rowCount === 1;
|
|
108
122
|
},
|
|
109
123
|
|
|
110
|
-
/** Remove
|
|
111
|
-
async
|
|
112
|
-
await query(CONSUMPTION_SQL.
|
|
124
|
+
/** Remove only the reservation owned by the caller. */
|
|
125
|
+
async deleteIfValue(key, expected) {
|
|
126
|
+
const res = await query(CONSUMPTION_SQL.deleteIfValue, [key, expected]);
|
|
127
|
+
if (!res || typeof res.rowCount !== 'number') {
|
|
128
|
+
throw new Error('deleteIfValue: query result has no numeric rowCount — cannot prove reservation ownership');
|
|
129
|
+
}
|
|
130
|
+
return res.rowCount === 1;
|
|
113
131
|
},
|
|
114
132
|
|
|
115
133
|
/** Present = consumed. Expired-but-uncleaned rows still count (conservative). */
|
|
@@ -119,8 +137,9 @@ export function createPostgresBackend({ query, now = Date.now } = {}) {
|
|
|
119
137
|
},
|
|
120
138
|
|
|
121
139
|
/** Garbage-collect rows whose TTL elapsed. Returns the number removed. */
|
|
122
|
-
async cleanupExpired(at
|
|
123
|
-
const
|
|
140
|
+
async cleanupExpired(at) {
|
|
141
|
+
const cleanupAt = at === undefined ? nowMs() : checkedNow(at);
|
|
142
|
+
const res = await query(CONSUMPTION_SQL.cleanupExpired, [cleanupAt]);
|
|
124
143
|
return res?.rowCount ?? 0;
|
|
125
144
|
},
|
|
126
145
|
};
|
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;
|
|
8
|
-
*
|
|
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
|
|
58
|
-
*
|
|
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
|
|
62
|
-
*
|
|
63
|
-
* async
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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', '
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
97
|
-
|
|
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 };
|