@emilia-protocol/gate 0.9.5 → 0.10.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 +70 -0
- package/LICENSE +190 -0
- package/README.md +75 -16
- package/action-control-manifest.js +26 -0
- package/adapters/_kit.js +47 -5
- package/adapters/aws.js +15 -4
- package/adapters/github-demo.mjs +25 -22
- package/adapters/github.js +1 -1
- package/adapters/jira.js +1 -0
- package/adapters/linear.js +1 -0
- package/adapters/salesforce.js +1 -0
- package/adapters/stripe.js +1 -1
- package/adapters/supabase.js +26 -4
- package/adapters/vercel.js +33 -4
- package/aec-execution.js +1 -3
- package/breakglass.js +285 -51
- package/control-plane.js +341 -0
- package/coverage.js +722 -0
- package/custody-demo.mjs +13 -3
- package/demo.mjs +9 -3
- package/deploy/helm/README.md +16 -8
- package/deploy/helm/emilia-gate/Chart.yaml +3 -1
- package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
- package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
- package/deploy/helm/emilia-gate/values.yaml +18 -2
- package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
- package/deploy/helm/emilia-gate-service/README.md +81 -0
- package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
- package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
- package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
- package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
- package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
- package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
- package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
- package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
- package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
- package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
- package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
- package/deploy/helm/emilia-gate-service/values.yaml +166 -0
- package/deploy/sql/001-runtime.sql +707 -0
- package/deploy/terraform/README.md +24 -14
- package/deploy/terraform/main.tf +59 -0
- package/deploy/terraform/service/README.md +81 -0
- package/deploy/terraform/service/main.tf +718 -0
- package/deploy/terraform/service/outputs.tf +19 -0
- package/deploy/terraform/service/tests/validate.sh +24 -0
- package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
- package/deploy/terraform/service/variables.tf +459 -0
- package/deploy/terraform/service/versions.tf +10 -0
- package/deploy/terraform/variables.tf +95 -2
- package/deployment-attestation.js +248 -0
- package/eg1-conformance.js +46 -12
- package/enterprise.js +6 -2
- package/ep-assure.mjs +3 -2
- package/evidence-postgres.js +357 -0
- package/evidence.js +10 -3
- package/execution-binding.js +141 -26
- package/index.js +556 -115
- package/key-registry.js +51 -19
- package/mcp.js +4 -2
- package/network-witness.js +500 -0
- package/package.json +25 -5
- package/reliance-kernel.js +5 -6
- package/reliance-packet.js +43 -10
- package/reports/assurance-package.js +45 -19
- package/reports/reperform.js +78 -18
- package/reports/underwriter.js +1 -1
- package/settlement.js +300 -0
- package/store-postgres.js +14 -0
- package/store.js +26 -5
- package/strict-json.js +86 -0
- package/witness-postgres.js +97 -0
package/settlement.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic evidence-completeness decision for settlement workflows.
|
|
4
|
+
*
|
|
5
|
+
* This is not a payment rail and does not decide legal liability. It answers a
|
|
6
|
+
* narrower question: does a relying-party-pinned profile have every verified,
|
|
7
|
+
* digest-joined artifact it required before its own settlement system acts?
|
|
8
|
+
*/
|
|
9
|
+
import { canonicalize, hashCanonical } from './execution-binding.js';
|
|
10
|
+
import {
|
|
11
|
+
NETWORK_WITNESS_EVENTS,
|
|
12
|
+
acceptNetworkWitnessStatement,
|
|
13
|
+
networkWitnessDigest,
|
|
14
|
+
validateTrustedNetworkWitnessAcceptance,
|
|
15
|
+
} from './network-witness.js';
|
|
16
|
+
|
|
17
|
+
export const SETTLEMENT_PROFILE_VERSION = 'EP-GATE-SETTLEMENT-PROFILE-v1';
|
|
18
|
+
export const SETTLEMENT_RESULT_VERSION = 'EP-GATE-SETTLEMENT-RESULT-v1';
|
|
19
|
+
export const SETTLEMENT_VERDICTS = Object.freeze([
|
|
20
|
+
'eligible',
|
|
21
|
+
'refuse_profile_invalid',
|
|
22
|
+
'refuse_authorization',
|
|
23
|
+
'refuse_execution',
|
|
24
|
+
'refuse_witness',
|
|
25
|
+
'refuse_outcome',
|
|
26
|
+
'refuse_coverage',
|
|
27
|
+
'refuse_binding',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
34
|
+
const prototype = Object.getPrototypeOf(value);
|
|
35
|
+
return prototype === Object.prototype || prototype === null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function exactKeys(value, allowed) {
|
|
39
|
+
return isPlainObject(value) && Object.keys(value).every((key) => allowed.has(key));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function string(value, max = 512) {
|
|
43
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function digest(value) {
|
|
47
|
+
return typeof value === 'string' && DIGEST_RE.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function canonicalSnapshot(value) {
|
|
51
|
+
return JSON.parse(canonicalize(value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateProfile(profile) {
|
|
55
|
+
if (!exactKeys(profile, new Set([
|
|
56
|
+
'@version', 'profile_id', 'require_witness', 'require_outcome', 'require_coverage',
|
|
57
|
+
'required_witness_event', 'required_witness_id', 'required_capture_point_id',
|
|
58
|
+
'required_coverage_state', 'required_surface_id',
|
|
59
|
+
]))) return 'profile_shape_invalid';
|
|
60
|
+
if (profile['@version'] !== SETTLEMENT_PROFILE_VERSION || !string(profile.profile_id)) return 'profile_identity_invalid';
|
|
61
|
+
for (const field of ['require_witness', 'require_outcome', 'require_coverage']) {
|
|
62
|
+
if (profile[field] !== true && profile[field] !== false) return `profile_${field}_invalid`;
|
|
63
|
+
}
|
|
64
|
+
if (profile.require_witness && (!string(profile.required_witness_event)
|
|
65
|
+
|| !NETWORK_WITNESS_EVENTS.includes(profile.required_witness_event)
|
|
66
|
+
|| !string(profile.required_witness_id) || !string(profile.required_capture_point_id))) {
|
|
67
|
+
return 'profile_witness_binding_invalid';
|
|
68
|
+
}
|
|
69
|
+
if (!profile.require_witness && (profile.required_witness_event !== undefined
|
|
70
|
+
|| profile.required_witness_id !== undefined || profile.required_capture_point_id !== undefined)) {
|
|
71
|
+
return 'profile_witness_fields_forbidden';
|
|
72
|
+
}
|
|
73
|
+
if (profile.require_coverage && (profile.required_coverage_state !== 'gated'
|
|
74
|
+
|| !string(profile.required_surface_id))) return 'profile_coverage_binding_invalid';
|
|
75
|
+
if (!profile.require_coverage && (profile.required_coverage_state !== undefined
|
|
76
|
+
|| profile.required_surface_id !== undefined)) return 'profile_coverage_fields_forbidden';
|
|
77
|
+
try { canonicalize(profile); } catch { return 'profile_canonicalization_invalid'; }
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function settlementProfileDigest(profile) {
|
|
82
|
+
const invalid = validateProfile(profile);
|
|
83
|
+
if (invalid) throw new TypeError(invalid);
|
|
84
|
+
return `sha256:${hashCanonical(profile)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function refused(verdict, reason, profileHash, checks, actionDigest = null) {
|
|
88
|
+
const body = {
|
|
89
|
+
'@version': SETTLEMENT_RESULT_VERSION,
|
|
90
|
+
verdict,
|
|
91
|
+
eligible: false,
|
|
92
|
+
reason,
|
|
93
|
+
profile_hash: profileHash,
|
|
94
|
+
action_digest: actionDigest,
|
|
95
|
+
checks,
|
|
96
|
+
limitations: [
|
|
97
|
+
'This result is evidence-completeness input to a relying party; it is not a legal settlement instruction or warranty.',
|
|
98
|
+
'A valid evidence bundle does not establish physical truth beyond the separately verified outcome source.',
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
return Object.freeze({ ...body, result_hash: `sha256:${hashCanonical(body)}` });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function invokeVerifier(verifier, artifact, context) {
|
|
105
|
+
if (typeof verifier !== 'function') return { accepted: false, reason: 'pinned_verifier_missing' };
|
|
106
|
+
try {
|
|
107
|
+
const result = await verifier(artifact, Object.freeze({ ...context }));
|
|
108
|
+
if (!isPlainObject(result)) return { accepted: false, reason: 'verifier_result_invalid' };
|
|
109
|
+
return canonicalSnapshot(result);
|
|
110
|
+
} catch {
|
|
111
|
+
return { accepted: false, reason: 'pinned_verifier_error' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Evaluate a raw evidence bundle. Authorization, execution, outcome, and
|
|
117
|
+
* coverage are interpreted only by verifier functions pinned in code by the
|
|
118
|
+
* relying party; no artifact may select its own verifier.
|
|
119
|
+
*/
|
|
120
|
+
export async function evaluateSettlementEligibility(bundle = {}, options = {}) {
|
|
121
|
+
let profileInput;
|
|
122
|
+
let profileInputInvalid = false;
|
|
123
|
+
try { profileInput = canonicalSnapshot(options.profile); } catch { profileInputInvalid = true; }
|
|
124
|
+
let verifyAuthorization;
|
|
125
|
+
let verifyExecution;
|
|
126
|
+
let verifyOutcome;
|
|
127
|
+
let verifyCoverage;
|
|
128
|
+
let pinnedWitnesses;
|
|
129
|
+
let trustedWitnessAcceptance = null;
|
|
130
|
+
let hasTrustedWitnessAcceptance = false;
|
|
131
|
+
let witnessSequenceStore;
|
|
132
|
+
let allowEphemeralWitnessStore = false;
|
|
133
|
+
let witnessNow;
|
|
134
|
+
let witnessMaxAgeSec;
|
|
135
|
+
let maxFutureSkewSec;
|
|
136
|
+
try {
|
|
137
|
+
verifyAuthorization = options.verifyAuthorization;
|
|
138
|
+
verifyExecution = options.verifyExecution;
|
|
139
|
+
verifyOutcome = options.verifyOutcome;
|
|
140
|
+
verifyCoverage = options.verifyCoverage;
|
|
141
|
+
pinnedWitnesses = options.pinnedWitnesses === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: canonicalSnapshot(options.pinnedWitnesses);
|
|
144
|
+
hasTrustedWitnessAcceptance = Object.hasOwn(options, 'trustedWitnessAcceptance');
|
|
145
|
+
if (hasTrustedWitnessAcceptance) {
|
|
146
|
+
trustedWitnessAcceptance = canonicalSnapshot(options.trustedWitnessAcceptance);
|
|
147
|
+
}
|
|
148
|
+
witnessSequenceStore = options.witnessSequenceStore;
|
|
149
|
+
allowEphemeralWitnessStore = options.allowEphemeralWitnessStore === true;
|
|
150
|
+
witnessNow = options.now;
|
|
151
|
+
witnessMaxAgeSec = options.witnessMaxAgeSec;
|
|
152
|
+
maxFutureSkewSec = options.maxFutureSkewSec;
|
|
153
|
+
} catch {
|
|
154
|
+
pinnedWitnesses = [];
|
|
155
|
+
hasTrustedWitnessAcceptance = true;
|
|
156
|
+
trustedWitnessAcceptance = null;
|
|
157
|
+
}
|
|
158
|
+
let profile;
|
|
159
|
+
let invalid;
|
|
160
|
+
if (profileInputInvalid) {
|
|
161
|
+
invalid = 'profile_hostile_input';
|
|
162
|
+
profile = null;
|
|
163
|
+
} else {
|
|
164
|
+
invalid = validateProfile(profileInput);
|
|
165
|
+
profile = profileInput;
|
|
166
|
+
}
|
|
167
|
+
const baseChecks = {
|
|
168
|
+
profile: !invalid,
|
|
169
|
+
authorization: false,
|
|
170
|
+
execution: false,
|
|
171
|
+
witness: profile?.require_witness === false,
|
|
172
|
+
outcome: profile?.require_outcome === false,
|
|
173
|
+
coverage: profile?.require_coverage === false,
|
|
174
|
+
digest_join: false,
|
|
175
|
+
};
|
|
176
|
+
if (invalid) return refused('refuse_profile_invalid', invalid, null, baseChecks);
|
|
177
|
+
const profileHash = settlementProfileDigest(profile);
|
|
178
|
+
let evidence;
|
|
179
|
+
try {
|
|
180
|
+
evidence = JSON.parse(canonicalize(bundle));
|
|
181
|
+
} catch {
|
|
182
|
+
return refused('refuse_binding', 'evidence_bundle_not_canonical_json', profileHash, baseChecks);
|
|
183
|
+
}
|
|
184
|
+
const actionDigest = evidence.action_digest;
|
|
185
|
+
if (!digest(actionDigest)) {
|
|
186
|
+
return refused('refuse_binding', 'action_digest_invalid', profileHash, baseChecks);
|
|
187
|
+
}
|
|
188
|
+
const context = { action_digest: actionDigest, profile_hash: profileHash };
|
|
189
|
+
|
|
190
|
+
const authorization = await invokeVerifier(verifyAuthorization, evidence.authorization, context);
|
|
191
|
+
if (authorization.accepted !== true || authorization.action_digest !== actionDigest
|
|
192
|
+
|| !digest(authorization.decision_digest)) {
|
|
193
|
+
return refused('refuse_authorization', authorization.reason ?? 'authorization_not_verified', profileHash, baseChecks, actionDigest);
|
|
194
|
+
}
|
|
195
|
+
baseChecks.authorization = true;
|
|
196
|
+
|
|
197
|
+
const execution = await invokeVerifier(verifyExecution, evidence.execution, {
|
|
198
|
+
...context,
|
|
199
|
+
authorization_digest: authorization.decision_digest,
|
|
200
|
+
});
|
|
201
|
+
if (execution.accepted !== true || execution.outcome !== 'executed'
|
|
202
|
+
|| execution.action_digest !== actionDigest || !digest(execution.execution_digest)) {
|
|
203
|
+
return refused('refuse_execution', execution.reason ?? 'execution_not_verified', profileHash, baseChecks, actionDigest);
|
|
204
|
+
}
|
|
205
|
+
baseChecks.execution = true;
|
|
206
|
+
if (execution.authorization_digest !== authorization.decision_digest) {
|
|
207
|
+
return refused('refuse_binding', 'execution_authorization_digest_mismatch', profileHash, baseChecks, actionDigest);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let witness = null;
|
|
211
|
+
if (profile.require_witness) {
|
|
212
|
+
const witnessOptions = {
|
|
213
|
+
expectedActionDigest: actionDigest,
|
|
214
|
+
expectedEvent: profile.required_witness_event,
|
|
215
|
+
maxAgeSec: witnessMaxAgeSec,
|
|
216
|
+
maxFutureSkewSec,
|
|
217
|
+
now: witnessNow,
|
|
218
|
+
allowEphemeralStore: allowEphemeralWitnessStore,
|
|
219
|
+
};
|
|
220
|
+
if (hasTrustedWitnessAcceptance) {
|
|
221
|
+
let expectedStatementDigest;
|
|
222
|
+
try { expectedStatementDigest = networkWitnessDigest(evidence.witness); } catch {
|
|
223
|
+
return refused('refuse_witness', 'witness_statement_digest_invalid', profileHash, baseChecks, actionDigest);
|
|
224
|
+
}
|
|
225
|
+
witness = validateTrustedNetworkWitnessAcceptance(trustedWitnessAcceptance, {
|
|
226
|
+
...witnessOptions,
|
|
227
|
+
expectedStatementDigest,
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
witness = await acceptNetworkWitnessStatement(evidence.witness, {
|
|
231
|
+
...witnessOptions,
|
|
232
|
+
pinnedWitnesses,
|
|
233
|
+
sequenceStore: witnessSequenceStore,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (!witness.accepted) {
|
|
237
|
+
return refused('refuse_witness', witness.reason ?? 'witness_not_verified', profileHash, baseChecks, actionDigest);
|
|
238
|
+
}
|
|
239
|
+
if (witness.witness_id !== profile.required_witness_id
|
|
240
|
+
|| witness.capture_point_id !== profile.required_capture_point_id) {
|
|
241
|
+
return refused('refuse_binding', 'witness_capture_point_mismatch', profileHash, baseChecks, actionDigest);
|
|
242
|
+
}
|
|
243
|
+
baseChecks.witness = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let outcome = null;
|
|
247
|
+
if (profile.require_outcome) {
|
|
248
|
+
outcome = await invokeVerifier(verifyOutcome, evidence.outcome, {
|
|
249
|
+
...context,
|
|
250
|
+
execution_digest: execution.execution_digest,
|
|
251
|
+
});
|
|
252
|
+
if (outcome.accepted !== true || outcome.within_tolerance !== true
|
|
253
|
+
|| outcome.action_digest !== actionDigest || !digest(outcome.outcome_digest)
|
|
254
|
+
|| outcome.execution_digest !== execution.execution_digest) {
|
|
255
|
+
return refused('refuse_outcome', outcome.reason ?? 'outcome_not_verified', profileHash, baseChecks, actionDigest);
|
|
256
|
+
}
|
|
257
|
+
baseChecks.outcome = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let coverage = null;
|
|
261
|
+
if (profile.require_coverage) {
|
|
262
|
+
coverage = await invokeVerifier(verifyCoverage, evidence.coverage, context);
|
|
263
|
+
if (coverage.accepted !== true || coverage.state !== profile.required_coverage_state
|
|
264
|
+
|| coverage.surface_id !== profile.required_surface_id || !digest(coverage.report_hash)) {
|
|
265
|
+
return refused('refuse_coverage', coverage.reason ?? 'coverage_not_verified', profileHash, baseChecks, actionDigest);
|
|
266
|
+
}
|
|
267
|
+
baseChecks.coverage = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
baseChecks.digest_join = true;
|
|
271
|
+
const body = {
|
|
272
|
+
'@version': SETTLEMENT_RESULT_VERSION,
|
|
273
|
+
verdict: 'eligible',
|
|
274
|
+
eligible: true,
|
|
275
|
+
reason: null,
|
|
276
|
+
profile_hash: profileHash,
|
|
277
|
+
action_digest: actionDigest,
|
|
278
|
+
evidence: {
|
|
279
|
+
authorization_digest: authorization.decision_digest,
|
|
280
|
+
execution_digest: execution.execution_digest,
|
|
281
|
+
...(witness ? { witness_digest: witness.statement_digest } : {}),
|
|
282
|
+
...(outcome ? { outcome_digest: outcome.outcome_digest } : {}),
|
|
283
|
+
...(coverage ? { coverage_report_hash: coverage.report_hash, surface_id: coverage.surface_id } : {}),
|
|
284
|
+
},
|
|
285
|
+
checks: baseChecks,
|
|
286
|
+
limitations: [
|
|
287
|
+
'Eligible means the relying party\'s pinned evidence profile was satisfied; the relying party still owns pricing, legal effect, and payment execution.',
|
|
288
|
+
'The witness proves observation, not authorization or physical outcome; those are separate verified rows.',
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
return Object.freeze({ ...body, result_hash: `sha256:${hashCanonical(body)}` });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default {
|
|
295
|
+
SETTLEMENT_PROFILE_VERSION,
|
|
296
|
+
SETTLEMENT_RESULT_VERSION,
|
|
297
|
+
SETTLEMENT_VERDICTS,
|
|
298
|
+
settlementProfileDigest,
|
|
299
|
+
evaluateSettlementEligibility,
|
|
300
|
+
};
|
package/store-postgres.js
CHANGED
|
@@ -53,6 +53,10 @@ CREATE INDEX IF NOT EXISTS ${CONSUMPTION_TABLE}_expires_idx
|
|
|
53
53
|
/** The exact statements the backend issues — exported for transparency and so
|
|
54
54
|
* a fake client can implement them without parsing SQL. */
|
|
55
55
|
export const CONSUMPTION_SQL = {
|
|
56
|
+
health: `SELECT
|
|
57
|
+
to_regclass('public.${CONSUMPTION_TABLE}') IS NOT NULL AS table_ready,
|
|
58
|
+
CASE WHEN to_regclass('public.${CONSUMPTION_TABLE}') IS NULL THEN FALSE
|
|
59
|
+
ELSE has_table_privilege(current_user, to_regclass('public.${CONSUMPTION_TABLE}'), 'SELECT,INSERT,UPDATE,DELETE') END AS can_use`,
|
|
56
60
|
/** $1 key, $2 state, $3 consumed_at ms, $4 expires_at ms|null. rowCount 1 = consumed, 0 = replay. */
|
|
57
61
|
addIfAbsent: `INSERT INTO ${CONSUMPTION_TABLE} (consumption_key, state, consumed_at, expires_at) `
|
|
58
62
|
+ 'VALUES ($1, $2, $3, $4) ON CONFLICT (consumption_key) DO NOTHING',
|
|
@@ -101,6 +105,16 @@ export function createPostgresBackend({ query, now = Date.now } = {}) {
|
|
|
101
105
|
|
|
102
106
|
return {
|
|
103
107
|
durable: true,
|
|
108
|
+
async health() {
|
|
109
|
+
const res = await query(CONSUMPTION_SQL.health, []);
|
|
110
|
+
if (!res || res.rowCount !== 1 || !Array.isArray(res.rows) || res.rows.length !== 1) {
|
|
111
|
+
throw new Error('consumption health: malformed Postgres result');
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
ok: res.rows[0].table_ready === true && res.rows[0].can_use === true,
|
|
115
|
+
version: PG_CONSUMPTION_VERSION,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
104
118
|
/** True iff THIS call inserted the row — the atomic consumed-vs-replay decision. */
|
|
105
119
|
async addIfAbsent(key, value, opt) {
|
|
106
120
|
const res = await query(CONSUMPTION_SQL.addIfAbsent, [key, value, nowMs(), expiryFor(opt)]);
|
package/store.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
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
|
|
8
|
-
* use the
|
|
7
|
+
* receipt is a replay and is refused. The in-memory store is an explicit
|
|
8
|
+
* test/demo opt-in; security-bearing gates use the durable contract below.
|
|
9
9
|
*/
|
|
10
10
|
export class MemoryConsumptionStore {
|
|
11
11
|
constructor() {
|
|
@@ -30,14 +30,14 @@ export class MemoryConsumptionStore {
|
|
|
30
30
|
return true;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Commit a reserved id after
|
|
33
|
+
/** Commit a reserved id after an external-effect attempt begins. */
|
|
34
34
|
async commit(key) {
|
|
35
35
|
this.reserved.delete(key);
|
|
36
36
|
this.seen.add(key);
|
|
37
37
|
return true;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Release
|
|
40
|
+
/** Release only when the caller can prove the external effect never began. */
|
|
41
41
|
async release(key) {
|
|
42
42
|
this.reserved.delete(key);
|
|
43
43
|
return true;
|
|
@@ -54,6 +54,17 @@ export class MemoryConsumptionStore {
|
|
|
54
54
|
|
|
55
55
|
export const DURABLE_CONSUMPTION_VERSION = 'EP-GATE-DURABLE-CONSUMPTION-v2';
|
|
56
56
|
|
|
57
|
+
/** Capability contract required by security-bearing Gate execution paths. */
|
|
58
|
+
export function isSecureConsumptionStore(store) {
|
|
59
|
+
if (!store || typeof store !== 'object') return false;
|
|
60
|
+
return store.durable === true
|
|
61
|
+
&& store.ownershipFenced === true
|
|
62
|
+
&& store.permanentConsumption === true
|
|
63
|
+
&& typeof store.consume === 'function'
|
|
64
|
+
&& typeof store.reserve === 'function'
|
|
65
|
+
&& typeof store.commit === 'function';
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
const COMMITTED_VALUE = 'committed:v2';
|
|
58
69
|
const RESERVED_PREFIX = 'reserved:v2:';
|
|
59
70
|
|
|
@@ -121,6 +132,10 @@ export function createDurableConsumptionStore(backend, { ttlSeconds, reservation
|
|
|
121
132
|
ownershipFenced: true,
|
|
122
133
|
permanentConsumption: ttlSeconds === undefined || ttlSeconds === null,
|
|
123
134
|
retentionSeconds: ttlSeconds ?? null,
|
|
135
|
+
async health() {
|
|
136
|
+
if (typeof backend.health !== 'function') return { ok: false, reason: 'backend_health_unavailable' };
|
|
137
|
+
return backend.health();
|
|
138
|
+
},
|
|
124
139
|
async reserve(key) {
|
|
125
140
|
const token = reservationTokenFactory();
|
|
126
141
|
if (typeof token !== 'string' || token.length < 16) {
|
|
@@ -174,4 +189,10 @@ export function createMemoryBackend() {
|
|
|
174
189
|
};
|
|
175
190
|
}
|
|
176
191
|
|
|
177
|
-
export default {
|
|
192
|
+
export default {
|
|
193
|
+
MemoryConsumptionStore,
|
|
194
|
+
createDurableConsumptionStore,
|
|
195
|
+
createMemoryBackend,
|
|
196
|
+
isSecureConsumptionStore,
|
|
197
|
+
DURABLE_CONSUMPTION_VERSION,
|
|
198
|
+
};
|
package/strict-json.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Duplicate-name and Unicode-scalar gate for signed nested JSON such as
|
|
3
|
+
// WebAuthn clientDataJSON. JSON.parse remains the syntax gate.
|
|
4
|
+
|
|
5
|
+
export const MAX_JSON_DEPTH = 64;
|
|
6
|
+
|
|
7
|
+
export function strictJsonGate(raw) {
|
|
8
|
+
if (typeof raw !== 'string') return { ok: false, reason: 'JSON input must be text' };
|
|
9
|
+
try { JSON.parse(raw); } catch { return { ok: false, reason: 'invalid JSON syntax' }; }
|
|
10
|
+
let index = 0;
|
|
11
|
+
const stack = [];
|
|
12
|
+
let reason = null;
|
|
13
|
+
const escapes = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
|
14
|
+
|
|
15
|
+
function readString() {
|
|
16
|
+
index += 1;
|
|
17
|
+
let output = '';
|
|
18
|
+
while (index < raw.length) {
|
|
19
|
+
const character = raw[index];
|
|
20
|
+
if (character === '"') { index += 1; return output; }
|
|
21
|
+
if (character !== '\\') { output += character; index += 1; continue; }
|
|
22
|
+
const escape = raw[index + 1];
|
|
23
|
+
if (escape !== 'u') {
|
|
24
|
+
output += escapes[escape] ?? '';
|
|
25
|
+
index += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const first = Number.parseInt(raw.slice(index + 2, index + 6), 16);
|
|
29
|
+
index += 6;
|
|
30
|
+
if (first >= 0xd800 && first <= 0xdbff) {
|
|
31
|
+
if (raw[index] === '\\' && raw[index + 1] === 'u') {
|
|
32
|
+
const second = Number.parseInt(raw.slice(index + 2, index + 6), 16);
|
|
33
|
+
if (second >= 0xdc00 && second <= 0xdfff) {
|
|
34
|
+
output += String.fromCharCode(first, second);
|
|
35
|
+
index += 6;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
reason = 'unpaired high surrogate escape';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (first >= 0xdc00 && first <= 0xdfff) {
|
|
43
|
+
reason = 'unpaired low surrogate escape';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
output += String.fromCharCode(first);
|
|
47
|
+
}
|
|
48
|
+
reason = 'unterminated string';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
while (index < raw.length) {
|
|
53
|
+
const character = raw[index];
|
|
54
|
+
if (character === '{') {
|
|
55
|
+
stack.push({ object: true, keys: new Set(), expectsKey: true });
|
|
56
|
+
if (stack.length > MAX_JSON_DEPTH) return { ok: false, reason: `nesting depth exceeds ${MAX_JSON_DEPTH}` };
|
|
57
|
+
index += 1;
|
|
58
|
+
} else if (character === '[') {
|
|
59
|
+
stack.push({ object: false });
|
|
60
|
+
if (stack.length > MAX_JSON_DEPTH) return { ok: false, reason: `nesting depth exceeds ${MAX_JSON_DEPTH}` };
|
|
61
|
+
index += 1;
|
|
62
|
+
} else if (character === '}' || character === ']') {
|
|
63
|
+
stack.pop();
|
|
64
|
+
index += 1;
|
|
65
|
+
} else if (character === ',') {
|
|
66
|
+
const top = stack.at(-1);
|
|
67
|
+
if (top?.object) top.expectsKey = true;
|
|
68
|
+
index += 1;
|
|
69
|
+
} else if (character === '"') {
|
|
70
|
+
const top = stack.at(-1);
|
|
71
|
+
const isKey = Boolean(top?.object && top.expectsKey);
|
|
72
|
+
const value = readString();
|
|
73
|
+
if (reason) return { ok: false, reason };
|
|
74
|
+
if (isKey) {
|
|
75
|
+
if (top.keys.has(value)) return { ok: false, reason: 'duplicate object member name' };
|
|
76
|
+
top.keys.add(value);
|
|
77
|
+
top.expectsKey = false;
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
index += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default { strictJsonGate, MAX_JSON_DEPTH };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Tenant-scoped Postgres sequence store for EP-GATE-NETWORK-WITNESS-v1.
|
|
4
|
+
*
|
|
5
|
+
* The migration owns synchronization and authorization. Runtime callers get
|
|
6
|
+
* EXECUTE on one SECURITY DEFINER function, not INSERT/UPDATE privileges on the
|
|
7
|
+
* checkpoint table. Database ambiguity is allowed to throw so the witness
|
|
8
|
+
* ingestion kernel can fail closed as `sequence_store_unavailable`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const PG_WITNESS_SEQUENCE_VERSION = 'EP-GATE-PG-WITNESS-SEQUENCE-v1';
|
|
12
|
+
export const WITNESS_CHECKPOINT_FUNCTION = 'emilia_gate_evidence.advance_network_witness_checkpoint';
|
|
13
|
+
|
|
14
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
15
|
+
const CLOSED_REASONS = new Set(['statement_replay', 'sequence_rollback', 'sequence_equivocation']);
|
|
16
|
+
|
|
17
|
+
export const WITNESS_SEQUENCE_SQL = Object.freeze({
|
|
18
|
+
advance: `SELECT accepted, reason
|
|
19
|
+
FROM ${WITNESS_CHECKPOINT_FUNCTION}($1, $2, $3::bytea, $4::bigint, $5)`,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function scopedId(value, label) {
|
|
23
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256
|
|
24
|
+
|| /[\u0000-\u001f\u007f]/.test(value)) {
|
|
25
|
+
throw new Error(`${label} must be a non-empty control-free string of at most 256 characters`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function streamBytes(value) {
|
|
31
|
+
if (typeof value !== 'string' || value.length > 513) {
|
|
32
|
+
throw new Error('witness streamId is invalid');
|
|
33
|
+
}
|
|
34
|
+
const parts = value.split('\0');
|
|
35
|
+
if (parts.length !== 2 || parts.some((part) => part.length === 0 || part.length > 256
|
|
36
|
+
|| /[\u0000-\u001f\u007f]/.test(part))) {
|
|
37
|
+
throw new Error('witness streamId must contain one witness-id/capture-point separator');
|
|
38
|
+
}
|
|
39
|
+
return Buffer.from(value, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function definitiveRow(result) {
|
|
43
|
+
if (!result || result.rowCount !== 1 || !Array.isArray(result.rows)
|
|
44
|
+
|| result.rows.length !== 1 || typeof result.rows[0]?.accepted !== 'boolean') {
|
|
45
|
+
throw new Error('witness checkpoint outcome is unproven');
|
|
46
|
+
}
|
|
47
|
+
const { accepted, reason } = result.rows[0];
|
|
48
|
+
if (accepted) {
|
|
49
|
+
if (reason !== null && reason !== undefined && reason !== '') {
|
|
50
|
+
throw new Error('accepted witness checkpoint carried a refusal reason');
|
|
51
|
+
}
|
|
52
|
+
return { accepted: true, reason: null };
|
|
53
|
+
}
|
|
54
|
+
if (!CLOSED_REASONS.has(reason)) {
|
|
55
|
+
throw new Error('witness checkpoint returned an unknown refusal reason');
|
|
56
|
+
}
|
|
57
|
+
return { accepted: false, reason };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create the durable store expected by acceptNetworkWitnessStatement().
|
|
62
|
+
* `query` is a node-postgres style function such as pool.query.bind(pool).
|
|
63
|
+
*/
|
|
64
|
+
export function createPostgresWitnessSequenceStore({ query, tenantId, gateId } = {}) {
|
|
65
|
+
if (typeof query !== 'function') {
|
|
66
|
+
throw new Error('createPostgresWitnessSequenceStore: query must be an async pg-style function');
|
|
67
|
+
}
|
|
68
|
+
const tenant = scopedId(tenantId, 'tenantId');
|
|
69
|
+
const gate = scopedId(gateId, 'gateId');
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
durable: true,
|
|
72
|
+
scope: Object.freeze({ tenantId: tenant, gateId: gate }),
|
|
73
|
+
async advance(streamId, sequence, statementDigest) {
|
|
74
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
75
|
+
throw new Error('witness sequence must be a non-negative safe integer');
|
|
76
|
+
}
|
|
77
|
+
if (typeof statementDigest !== 'string' || !DIGEST_RE.test(statementDigest)) {
|
|
78
|
+
throw new Error('witness statement digest is invalid');
|
|
79
|
+
}
|
|
80
|
+
const result = await query(WITNESS_SEQUENCE_SQL.advance, [
|
|
81
|
+
tenant,
|
|
82
|
+
gate,
|
|
83
|
+
streamBytes(streamId),
|
|
84
|
+
sequence,
|
|
85
|
+
statementDigest,
|
|
86
|
+
]);
|
|
87
|
+
return definitiveRow(result);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
PG_WITNESS_SEQUENCE_VERSION,
|
|
94
|
+
WITNESS_CHECKPOINT_FUNCTION,
|
|
95
|
+
WITNESS_SEQUENCE_SQL,
|
|
96
|
+
createPostgresWitnessSequenceStore,
|
|
97
|
+
};
|