@emilia-protocol/gate 0.9.4 → 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 +16 -4
- 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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate production evidence backend for Postgres.
|
|
4
|
+
*
|
|
5
|
+
* The deployment SQL exposes one SECURITY DEFINER append function. That
|
|
6
|
+
* function locks the tenant/gate/stream head, checks the caller's expected
|
|
7
|
+
* head, inserts one immutable record, and advances the head in the same SQL
|
|
8
|
+
* statement transaction. Runtime roles receive SELECT and EXECUTE only; they
|
|
9
|
+
* cannot insert, update, delete, or truncate evidence tables directly.
|
|
10
|
+
*
|
|
11
|
+
* Database errors and malformed driver responses always propagate on the
|
|
12
|
+
* write path. A storage outage must never be mistaken for a successful append
|
|
13
|
+
* or an ordinary contention retry.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
canonicalEvidenceJson,
|
|
18
|
+
verifyEvidenceRecord,
|
|
19
|
+
} from './evidence.js';
|
|
20
|
+
|
|
21
|
+
export const PG_EVIDENCE_VERSION = 'EP-GATE-PG-EVIDENCE-v1';
|
|
22
|
+
export const EVIDENCE_SCHEMA = 'emilia_gate_evidence';
|
|
23
|
+
export const EVIDENCE_RECORDS_TABLE = `${EVIDENCE_SCHEMA}.records`;
|
|
24
|
+
export const EVIDENCE_HEADS_TABLE = `${EVIDENCE_SCHEMA}.heads`;
|
|
25
|
+
export const EVIDENCE_APPEND_FUNCTION = `${EVIDENCE_SCHEMA}.append_record`;
|
|
26
|
+
|
|
27
|
+
const HEX_256 = /^[0-9a-f]{64}$/;
|
|
28
|
+
// evidence.js permits 1 MiB of raw string/key bytes. JSON control-character
|
|
29
|
+
// escaping can expand those bytes by up to 6x, plus bounded container syntax.
|
|
30
|
+
const MAX_CANONICAL_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Exact statements issued by the adapter, exported for audit and test fakes. */
|
|
33
|
+
export const EVIDENCE_SQL = Object.freeze({
|
|
34
|
+
health: `SELECT
|
|
35
|
+
to_regclass('${EVIDENCE_RECORDS_TABLE}') IS NOT NULL AS records_ready,
|
|
36
|
+
to_regclass('${EVIDENCE_HEADS_TABLE}') IS NOT NULL AS heads_ready,
|
|
37
|
+
to_regprocedure('${EVIDENCE_APPEND_FUNCTION}(text,text,text,text,jsonb,text)') IS NOT NULL AS append_ready,
|
|
38
|
+
CASE WHEN to_regclass('${EVIDENCE_RECORDS_TABLE}') IS NULL THEN FALSE
|
|
39
|
+
ELSE has_table_privilege(current_user, to_regclass('${EVIDENCE_RECORDS_TABLE}'), 'SELECT') END AS can_read_records,
|
|
40
|
+
CASE WHEN to_regclass('${EVIDENCE_HEADS_TABLE}') IS NULL THEN FALSE
|
|
41
|
+
ELSE has_table_privilege(current_user, to_regclass('${EVIDENCE_HEADS_TABLE}'), 'SELECT') END AS can_read_heads,
|
|
42
|
+
CASE WHEN to_regclass('${EVIDENCE_RECORDS_TABLE}') IS NULL THEN FALSE ELSE (
|
|
43
|
+
has_table_privilege(current_user, to_regclass('${EVIDENCE_RECORDS_TABLE}'), 'INSERT')
|
|
44
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_RECORDS_TABLE}'), 'UPDATE')
|
|
45
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_RECORDS_TABLE}'), 'DELETE')
|
|
46
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_RECORDS_TABLE}'), 'TRUNCATE')
|
|
47
|
+
) END AS can_write_records_directly,
|
|
48
|
+
CASE WHEN to_regclass('${EVIDENCE_HEADS_TABLE}') IS NULL THEN FALSE ELSE (
|
|
49
|
+
has_table_privilege(current_user, to_regclass('${EVIDENCE_HEADS_TABLE}'), 'INSERT')
|
|
50
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_HEADS_TABLE}'), 'UPDATE')
|
|
51
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_HEADS_TABLE}'), 'DELETE')
|
|
52
|
+
OR has_table_privilege(current_user, to_regclass('${EVIDENCE_HEADS_TABLE}'), 'TRUNCATE')
|
|
53
|
+
) END AS can_write_heads_directly,
|
|
54
|
+
CASE WHEN to_regprocedure('${EVIDENCE_APPEND_FUNCTION}(text,text,text,text,jsonb,text)') IS NULL THEN FALSE
|
|
55
|
+
ELSE has_function_privilege(current_user,
|
|
56
|
+
to_regprocedure('${EVIDENCE_APPEND_FUNCTION}(text,text,text,text,jsonb,text)'), 'EXECUTE') END AS can_append`,
|
|
57
|
+
|
|
58
|
+
readHead: `SELECT head_seq AS seq, head_hash AS hash
|
|
59
|
+
FROM ${EVIDENCE_HEADS_TABLE}
|
|
60
|
+
WHERE tenant_id = $1 AND gate_id = $2 AND stream_id = $3`,
|
|
61
|
+
|
|
62
|
+
getById: `SELECT seq, record_id, prev_hash, hash, record
|
|
63
|
+
FROM ${EVIDENCE_RECORDS_TABLE}
|
|
64
|
+
WHERE tenant_id = $1 AND gate_id = $2 AND stream_id = $3 AND record_id = $4`,
|
|
65
|
+
|
|
66
|
+
readAll: `SELECT seq, record_id, prev_hash, hash, record
|
|
67
|
+
FROM ${EVIDENCE_RECORDS_TABLE}
|
|
68
|
+
WHERE tenant_id = $1 AND gate_id = $2 AND stream_id = $3
|
|
69
|
+
ORDER BY seq ASC`,
|
|
70
|
+
|
|
71
|
+
// One SELECT gives verify() a single MVCC snapshot of rows and head.
|
|
72
|
+
snapshot: `SELECT
|
|
73
|
+
COALESCE((
|
|
74
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
75
|
+
'seq', r.seq,
|
|
76
|
+
'record_id', r.record_id,
|
|
77
|
+
'prev_hash', r.prev_hash,
|
|
78
|
+
'hash', r.hash,
|
|
79
|
+
'record', r.record
|
|
80
|
+
) ORDER BY r.seq ASC)
|
|
81
|
+
FROM ${EVIDENCE_RECORDS_TABLE} r
|
|
82
|
+
WHERE r.tenant_id = $1 AND r.gate_id = $2 AND r.stream_id = $3
|
|
83
|
+
), '[]'::jsonb) AS record_rows,
|
|
84
|
+
(
|
|
85
|
+
SELECT CASE WHEN h.head_seq = -1 THEN NULL ELSE jsonb_build_object(
|
|
86
|
+
'seq', h.head_seq, 'hash', h.head_hash
|
|
87
|
+
) END
|
|
88
|
+
FROM ${EVIDENCE_HEADS_TABLE} h
|
|
89
|
+
WHERE h.tenant_id = $1 AND h.gate_id = $2 AND h.stream_id = $3
|
|
90
|
+
) AS head`,
|
|
91
|
+
|
|
92
|
+
appendIfHead: `SELECT ${EVIDENCE_APPEND_FUNCTION}($1, $2, $3, $4, $5::jsonb, $6) AS appended`,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function assertScopedId(value, label, { minLength = 1, maxLength = 256 } = {}) {
|
|
96
|
+
if (typeof value !== 'string' || value.length < minLength
|
|
97
|
+
|| value.length > maxLength || value.includes('\0')) {
|
|
98
|
+
throw new Error(`${label} must be a string of ${minLength} to ${maxLength} characters`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function queryRows(result, operation) {
|
|
104
|
+
if (!result || !Number.isInteger(result.rowCount) || result.rowCount < 0
|
|
105
|
+
|| !Array.isArray(result.rows) || result.rows.length !== result.rowCount) {
|
|
106
|
+
throw new Error(`${operation}: malformed Postgres result; storage outcome is unproven`);
|
|
107
|
+
}
|
|
108
|
+
return result.rows;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseSequence(value, operation, { sentinel = false } = {}) {
|
|
112
|
+
let parsed = value;
|
|
113
|
+
if (typeof value === 'string' && /^-?(0|[1-9][0-9]*)$/.test(value)) parsed = Number(value);
|
|
114
|
+
const minimum = sentinel ? -1 : 0;
|
|
115
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum) {
|
|
116
|
+
throw new Error(`${operation}: Postgres returned an invalid evidence sequence`);
|
|
117
|
+
}
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseJson(value, operation) {
|
|
122
|
+
if (typeof value !== 'string') return value;
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(value);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(`${operation}: Postgres returned malformed JSON`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeHead(value, operation) {
|
|
131
|
+
const head = parseJson(value, operation);
|
|
132
|
+
if (head === null || head === undefined) return null;
|
|
133
|
+
if (!head || typeof head !== 'object' || Array.isArray(head)) {
|
|
134
|
+
throw new Error(`${operation}: Postgres returned a malformed evidence head`);
|
|
135
|
+
}
|
|
136
|
+
const seq = parseSequence(head.seq, operation, { sentinel: true });
|
|
137
|
+
if (seq === -1) {
|
|
138
|
+
if (head.hash !== null && head.hash !== undefined) {
|
|
139
|
+
throw new Error(`${operation}: empty evidence head has a hash`);
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (typeof head.hash !== 'string' || !HEX_256.test(head.hash)) {
|
|
144
|
+
throw new Error(`${operation}: Postgres returned a malformed evidence head hash`);
|
|
145
|
+
}
|
|
146
|
+
return { seq, hash: head.hash };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeRecordRow(value, operation) {
|
|
150
|
+
const row = parseJson(value, operation);
|
|
151
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
152
|
+
throw new Error(`${operation}: Postgres returned a malformed evidence row`);
|
|
153
|
+
}
|
|
154
|
+
const seq = parseSequence(row.seq, operation);
|
|
155
|
+
const record = parseJson(row.record, operation);
|
|
156
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
157
|
+
|| record.seq !== seq
|
|
158
|
+
|| record.record_id !== row.record_id
|
|
159
|
+
|| record.prev_hash !== row.prev_hash
|
|
160
|
+
|| record.hash !== row.hash) {
|
|
161
|
+
throw new Error(`${operation}: evidence row metadata does not match its record`);
|
|
162
|
+
}
|
|
163
|
+
return structuredClone(record);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function canonicalAppendPayload(record, expectedHeadHash) {
|
|
167
|
+
if (expectedHeadHash !== null && (typeof expectedHeadHash !== 'string' || !HEX_256.test(expectedHeadHash))) {
|
|
168
|
+
throw new Error('appendIfHead: expected head must be null or a lowercase SHA-256 hash');
|
|
169
|
+
}
|
|
170
|
+
if (!verifyEvidenceRecord(record, { atomicRequired: true })) {
|
|
171
|
+
throw new Error('appendIfHead: record is not a valid canonical atomic evidence record');
|
|
172
|
+
}
|
|
173
|
+
if (expectedHeadHash === null) {
|
|
174
|
+
if (record.seq !== 0 || record.prev_hash !== 'genesis') {
|
|
175
|
+
throw new Error('appendIfHead: genesis append has an invalid sequence or predecessor');
|
|
176
|
+
}
|
|
177
|
+
} else if (record.seq < 1 || record.prev_hash !== expectedHeadHash) {
|
|
178
|
+
throw new Error('appendIfHead: record does not extend the expected head');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let persisted;
|
|
182
|
+
try {
|
|
183
|
+
persisted = JSON.parse(JSON.stringify(record));
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error('appendIfHead: record is not losslessly JSON-serializable');
|
|
186
|
+
}
|
|
187
|
+
if (!persisted || typeof persisted !== 'object' || Array.isArray(persisted)
|
|
188
|
+
|| canonicalEvidenceJson(persisted) !== canonicalEvidenceJson(record)
|
|
189
|
+
|| !verifyEvidenceRecord(persisted, { atomicRequired: true })) {
|
|
190
|
+
throw new Error('appendIfHead: record is not losslessly JSON-serializable');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const { hash: _hash, ...body } = persisted;
|
|
194
|
+
const canonicalBody = canonicalEvidenceJson(body);
|
|
195
|
+
if (typeof canonicalBody !== 'string' || Buffer.byteLength(canonicalBody, 'utf8') > MAX_CANONICAL_BYTES) {
|
|
196
|
+
throw new Error('appendIfHead: canonical evidence body exceeds the Postgres limit');
|
|
197
|
+
}
|
|
198
|
+
return { canonicalBody, persisted };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function verifyHistory(records, head) {
|
|
202
|
+
const recordIds = new Set();
|
|
203
|
+
const sequences = new Set();
|
|
204
|
+
let previous = 'genesis';
|
|
205
|
+
|
|
206
|
+
for (let index = 0; index < records.length; index++) {
|
|
207
|
+
const record = records[index];
|
|
208
|
+
if (sequences.has(record.seq)) return { ok: false, at: record.seq, reason: 'fork_detected' };
|
|
209
|
+
sequences.add(record.seq);
|
|
210
|
+
if (record.seq !== index) return { ok: false, at: index, reason: 'sequence_gap_or_rollback' };
|
|
211
|
+
if (record.prev_hash !== previous) {
|
|
212
|
+
return { ok: false, at: index, reason: 'fork_or_predecessor_mismatch' };
|
|
213
|
+
}
|
|
214
|
+
if (recordIds.has(record.record_id)) {
|
|
215
|
+
return { ok: false, at: index, reason: 'duplicate_record_id' };
|
|
216
|
+
}
|
|
217
|
+
if (!verifyEvidenceRecord(record, { atomicRequired: true })) {
|
|
218
|
+
return { ok: false, at: index, reason: 'hash_mismatch_or_malformed_record' };
|
|
219
|
+
}
|
|
220
|
+
recordIds.add(record.record_id);
|
|
221
|
+
previous = record.hash;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const expectedHead = records.length === 0
|
|
225
|
+
? null
|
|
226
|
+
: { seq: records.length - 1, hash: previous };
|
|
227
|
+
if ((expectedHead === null && head !== null)
|
|
228
|
+
|| (expectedHead !== null && (head === null
|
|
229
|
+
|| head.seq !== expectedHead.seq || head.hash !== expectedHead.hash))) {
|
|
230
|
+
return { ok: false, reason: 'head_rollback_or_mismatch' };
|
|
231
|
+
}
|
|
232
|
+
return { ok: true, length: records.length, head: expectedHead?.hash ?? null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a tenant-and-gate-bound backend for createAtomicEvidenceLog().
|
|
237
|
+
*
|
|
238
|
+
* `query` is a node-postgres style function such as `pool.query.bind(pool)`.
|
|
239
|
+
* The migration must be installed and the connection role must inherit or SET
|
|
240
|
+
* ROLE to `emilia_gate_evidence_runtime`.
|
|
241
|
+
*/
|
|
242
|
+
export function createPostgresEvidenceBackend({ query, tenantId, gateId } = {}) {
|
|
243
|
+
if (typeof query !== 'function') {
|
|
244
|
+
throw new Error('createPostgresEvidenceBackend: query must be an async pg-style function that throws on failure');
|
|
245
|
+
}
|
|
246
|
+
const tenant = assertScopedId(tenantId, 'tenantId');
|
|
247
|
+
const gate = assertScopedId(gateId, 'gateId');
|
|
248
|
+
const scope = Object.freeze({ tenantId: tenant, gateId: gate });
|
|
249
|
+
const scopeParams = (streamId) => [tenant, gate, assertScopedId(streamId, 'streamId')];
|
|
250
|
+
|
|
251
|
+
async function readHead(streamId) {
|
|
252
|
+
const rows = queryRows(await query(EVIDENCE_SQL.readHead, scopeParams(streamId)), 'readHead');
|
|
253
|
+
if (rows.length > 1) throw new Error('readHead: Postgres returned multiple scoped heads');
|
|
254
|
+
return rows.length === 0 ? null : normalizeHead(rows[0], 'readHead');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function getById(streamId, recordId) {
|
|
258
|
+
assertScopedId(recordId, 'recordId', { minLength: 16 });
|
|
259
|
+
const rows = queryRows(
|
|
260
|
+
await query(EVIDENCE_SQL.getById, [...scopeParams(streamId), recordId]),
|
|
261
|
+
'getById',
|
|
262
|
+
);
|
|
263
|
+
if (rows.length > 1) throw new Error('getById: Postgres returned duplicate scoped record IDs');
|
|
264
|
+
return rows.length === 0 ? null : normalizeRecordRow(rows[0], 'getById');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function appendIfHead(streamId, expectedHeadHash, record) {
|
|
268
|
+
const params = scopeParams(streamId);
|
|
269
|
+
const { canonicalBody, persisted } = canonicalAppendPayload(record, expectedHeadHash);
|
|
270
|
+
const rows = queryRows(
|
|
271
|
+
await query(EVIDENCE_SQL.appendIfHead, [
|
|
272
|
+
...params,
|
|
273
|
+
expectedHeadHash,
|
|
274
|
+
persisted,
|
|
275
|
+
canonicalBody,
|
|
276
|
+
]),
|
|
277
|
+
'appendIfHead',
|
|
278
|
+
);
|
|
279
|
+
if (rows.length !== 1 || typeof rows[0]?.appended !== 'boolean') {
|
|
280
|
+
throw new Error('appendIfHead: Postgres did not return a definitive append verdict');
|
|
281
|
+
}
|
|
282
|
+
return rows[0].appended;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function readAll(streamId) {
|
|
286
|
+
const rows = queryRows(await query(EVIDENCE_SQL.readAll, scopeParams(streamId)), 'readAll');
|
|
287
|
+
return rows.map((row) => normalizeRecordRow(row, 'readAll'));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function readSnapshot(streamId) {
|
|
291
|
+
const rows = queryRows(await query(EVIDENCE_SQL.snapshot, scopeParams(streamId)), 'verify');
|
|
292
|
+
if (rows.length !== 1) throw new Error('verify: Postgres did not return one scoped snapshot');
|
|
293
|
+
const recordRows = parseJson(rows[0].record_rows, 'verify');
|
|
294
|
+
if (!Array.isArray(recordRows)) throw new Error('verify: Postgres returned malformed evidence history');
|
|
295
|
+
return {
|
|
296
|
+
records: recordRows.map((row) => normalizeRecordRow(row, 'verify')),
|
|
297
|
+
head: normalizeHead(rows[0].head, 'verify'),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
durable: true,
|
|
303
|
+
persisted: true,
|
|
304
|
+
strict: true,
|
|
305
|
+
forkAware: true,
|
|
306
|
+
atomicAppend: true,
|
|
307
|
+
appendOnly: true,
|
|
308
|
+
version: PG_EVIDENCE_VERSION,
|
|
309
|
+
scope,
|
|
310
|
+
readHead,
|
|
311
|
+
head: readHead,
|
|
312
|
+
getById,
|
|
313
|
+
appendIfHead,
|
|
314
|
+
readAll,
|
|
315
|
+
all: readAll,
|
|
316
|
+
async verify(streamId) {
|
|
317
|
+
try {
|
|
318
|
+
const snapshot = await readSnapshot(streamId);
|
|
319
|
+
return verifyHistory(snapshot.records, snapshot.head);
|
|
320
|
+
} catch {
|
|
321
|
+
return { ok: false, reason: 'backend_read_failed_or_malformed' };
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
async health() {
|
|
325
|
+
const rows = queryRows(await query(EVIDENCE_SQL.health, []), 'health');
|
|
326
|
+
if (rows.length !== 1) throw new Error('health: Postgres did not return one readiness row');
|
|
327
|
+
const checks = {
|
|
328
|
+
recordsReady: rows[0].records_ready === true,
|
|
329
|
+
headsReady: rows[0].heads_ready === true,
|
|
330
|
+
appendReady: rows[0].append_ready === true,
|
|
331
|
+
canReadRecords: rows[0].can_read_records === true,
|
|
332
|
+
canReadHeads: rows[0].can_read_heads === true,
|
|
333
|
+
noDirectRecordWrites: rows[0].can_write_records_directly === false,
|
|
334
|
+
noDirectHeadWrites: rows[0].can_write_heads_directly === false,
|
|
335
|
+
canAppend: rows[0].can_append === true,
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
ok: Object.values(checks).every(Boolean),
|
|
339
|
+
version: PG_EVIDENCE_VERSION,
|
|
340
|
+
scope,
|
|
341
|
+
checks,
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const postgresEvidence = {
|
|
348
|
+
createPostgresEvidenceBackend,
|
|
349
|
+
EVIDENCE_SQL,
|
|
350
|
+
EVIDENCE_SCHEMA,
|
|
351
|
+
EVIDENCE_RECORDS_TABLE,
|
|
352
|
+
EVIDENCE_HEADS_TABLE,
|
|
353
|
+
EVIDENCE_APPEND_FUNCTION,
|
|
354
|
+
PG_EVIDENCE_VERSION,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export default postgresEvidence;
|
package/evidence.js
CHANGED
|
@@ -28,15 +28,17 @@ function sha256hex(s) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Canonical JSON (recursive sorted keys) — matches @emilia-protocol/verify. */
|
|
31
|
-
function
|
|
31
|
+
export function canonicalEvidenceJson(v) {
|
|
32
32
|
if (v === null || v === undefined) return JSON.stringify(v);
|
|
33
|
-
if (Array.isArray(v)) return `[${v.map(
|
|
33
|
+
if (Array.isArray(v)) return `[${v.map(canonicalEvidenceJson).join(',')}]`;
|
|
34
34
|
if (typeof v === 'object') {
|
|
35
|
-
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' +
|
|
35
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalEvidenceJson(v[k])).join(',')}}`;
|
|
36
36
|
}
|
|
37
37
|
return JSON.stringify(v);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const canonical = canonicalEvidenceJson;
|
|
41
|
+
|
|
40
42
|
export function createEvidenceLog({ sink, strict = false } = {}) {
|
|
41
43
|
const records = [];
|
|
42
44
|
let prev = 'genesis';
|
|
@@ -229,6 +231,11 @@ export function createAtomicEvidenceLog(backend, {
|
|
|
229
231
|
atomicAppend: true,
|
|
230
232
|
streamId,
|
|
231
233
|
|
|
234
|
+
async health() {
|
|
235
|
+
if (typeof backend.health !== 'function') return { ok: false, reason: 'backend_health_unavailable' };
|
|
236
|
+
return backend.health();
|
|
237
|
+
},
|
|
238
|
+
|
|
232
239
|
async record(entry) {
|
|
233
240
|
const snapshot = structuredClone(entry);
|
|
234
241
|
assertLogEntry(snapshot);
|
package/execution-binding.js
CHANGED
|
@@ -6,33 +6,125 @@ import crypto from 'node:crypto';
|
|
|
6
6
|
|
|
7
7
|
export const EXECUTION_BINDING_VERSION = 'EP-GATE-EXECUTION-BINDING-v1';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const MAX_JSON_DEPTH = 64;
|
|
10
|
+
const MAX_JSON_NODES = 50000;
|
|
11
|
+
const MAX_JSON_STRING_BYTES = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
function validUnicodeString(value) {
|
|
14
|
+
for (let i = 0; i < value.length; i++) {
|
|
15
|
+
const unit = value.charCodeAt(i);
|
|
16
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
17
|
+
const next = value.charCodeAt(++i);
|
|
18
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
19
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
26
|
+
const prototype = Object.getPrototypeOf(value);
|
|
27
|
+
return prototype === Object.prototype || prototype === null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Match the hardened EP canonical JSON safety profile: valid Unicode strings,
|
|
32
|
+
* booleans, null, safe integers, dense arrays, and plain data objects. Object
|
|
33
|
+
* identity is unique across the graph, so cycles and aliases both refuse.
|
|
34
|
+
*/
|
|
35
|
+
function assertCanonicalJson(value) {
|
|
36
|
+
const stack = [{ value, depth: 0 }];
|
|
37
|
+
const seen = new WeakSet();
|
|
38
|
+
let nodes = 0;
|
|
39
|
+
let stringBytes = 0;
|
|
40
|
+
|
|
41
|
+
while (stack.length) {
|
|
42
|
+
const current = stack.pop();
|
|
43
|
+
if (++nodes > MAX_JSON_NODES || current.depth > MAX_JSON_DEPTH) {
|
|
44
|
+
throw new TypeError('value exceeds the EP canonical JSON resource profile');
|
|
45
|
+
}
|
|
46
|
+
const v = current.value;
|
|
47
|
+
if (v === null || typeof v === 'boolean') continue;
|
|
48
|
+
if (typeof v === 'string') {
|
|
49
|
+
if (!validUnicodeString(v)) throw new TypeError('value contains invalid Unicode');
|
|
50
|
+
stringBytes += Buffer.byteLength(v, 'utf8');
|
|
51
|
+
if (stringBytes > MAX_JSON_STRING_BYTES) throw new TypeError('value exceeds the EP canonical JSON string limit');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (typeof v === 'number') {
|
|
55
|
+
if (!Number.isSafeInteger(v)) throw new TypeError('value contains a non-safe integer');
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(v) && !isPlainObject(v)) {
|
|
59
|
+
throw new TypeError('value contains a non-plain JSON object');
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(v)) throw new TypeError('value contains a cycle or alias');
|
|
62
|
+
seen.add(v);
|
|
63
|
+
|
|
64
|
+
if (Array.isArray(v)) {
|
|
65
|
+
const ownKeys = Reflect.ownKeys(v);
|
|
66
|
+
if (ownKeys.length !== v.length + 1 || !ownKeys.includes('length')) {
|
|
67
|
+
throw new TypeError('value contains a sparse or extended array');
|
|
68
|
+
}
|
|
69
|
+
for (let i = 0; i < v.length; i++) {
|
|
70
|
+
const descriptor = Object.getOwnPropertyDescriptor(v, String(i));
|
|
71
|
+
if (!descriptor || descriptor.enumerable !== true || !Object.hasOwn(descriptor, 'value')) {
|
|
72
|
+
throw new TypeError('value contains a sparse or accessor array');
|
|
73
|
+
}
|
|
74
|
+
stack.push({ value: descriptor.value, depth: current.depth + 1 });
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
80
|
+
if (typeof key !== 'string' || !validUnicodeString(key)) {
|
|
81
|
+
throw new TypeError('value contains a non-JSON object key');
|
|
82
|
+
}
|
|
83
|
+
const descriptor = Object.getOwnPropertyDescriptor(v, key);
|
|
84
|
+
if (!descriptor || descriptor.enumerable !== true || !Object.hasOwn(descriptor, 'value')) {
|
|
85
|
+
throw new TypeError('value contains a non-data JSON property');
|
|
86
|
+
}
|
|
87
|
+
stringBytes += Buffer.byteLength(key, 'utf8');
|
|
88
|
+
if (stringBytes > MAX_JSON_STRING_BYTES) throw new TypeError('value exceeds the EP canonical JSON string limit');
|
|
89
|
+
stack.push({ value: descriptor.value, depth: current.depth + 1 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function canonicalizeValidated(v) {
|
|
95
|
+
if (v === null) return 'null';
|
|
96
|
+
if (Array.isArray(v)) return `[${v.map(canonicalizeValidated).join(',')}]`;
|
|
12
97
|
if (typeof v === 'object') {
|
|
13
|
-
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' +
|
|
98
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalizeValidated(v[k])).join(',')}}`;
|
|
14
99
|
}
|
|
15
100
|
return JSON.stringify(v);
|
|
16
101
|
}
|
|
17
102
|
|
|
18
|
-
export function
|
|
19
|
-
|
|
103
|
+
export function canonicalize(v) {
|
|
104
|
+
assertCanonicalJson(v);
|
|
105
|
+
return canonicalizeValidated(v);
|
|
20
106
|
}
|
|
21
107
|
|
|
22
|
-
function
|
|
23
|
-
return v
|
|
108
|
+
export function hashCanonical(v) {
|
|
109
|
+
return crypto.createHash('sha256').update(canonicalize(v)).digest('hex');
|
|
24
110
|
}
|
|
25
111
|
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
112
|
+
function fieldValue(container, field) {
|
|
113
|
+
if (!isPlainObject(container)) return { state: 'invalid' };
|
|
114
|
+
const descriptor = Object.getOwnPropertyDescriptor(container, field);
|
|
115
|
+
if (!descriptor) return { state: 'missing' };
|
|
116
|
+
if (descriptor.enumerable !== true || !Object.hasOwn(descriptor, 'value')) return { state: 'invalid' };
|
|
117
|
+
if (descriptor.value === null) return { state: 'missing' };
|
|
118
|
+
try {
|
|
119
|
+
assertCanonicalJson(descriptor.value);
|
|
120
|
+
return { state: 'valid', value: descriptor.value };
|
|
121
|
+
} catch {
|
|
122
|
+
return { state: 'invalid' };
|
|
30
123
|
}
|
|
31
|
-
return v;
|
|
32
124
|
}
|
|
33
125
|
|
|
34
126
|
function equalValue(a, b) {
|
|
35
|
-
return
|
|
127
|
+
return canonicalize(a) === canonicalize(b);
|
|
36
128
|
}
|
|
37
129
|
|
|
38
130
|
export function materialFieldsFor(requirement) {
|
|
@@ -55,36 +147,59 @@ export function verifyExecutionBinding({ requirement, receipt, observedAction }
|
|
|
55
147
|
const observed = observedAction || {};
|
|
56
148
|
const missingSigned = [];
|
|
57
149
|
const missingObserved = [];
|
|
150
|
+
const invalidSigned = [];
|
|
151
|
+
const invalidObserved = [];
|
|
58
152
|
const mismatched = [];
|
|
59
153
|
const signedValues = {};
|
|
60
154
|
const observedValues = {};
|
|
61
155
|
|
|
62
156
|
for (const field of requiredFields) {
|
|
63
|
-
const expected = signed
|
|
64
|
-
const actual = observed
|
|
65
|
-
if (
|
|
157
|
+
const expected = fieldValue(signed, field);
|
|
158
|
+
const actual = fieldValue(observed, field);
|
|
159
|
+
if (expected.state === 'missing') {
|
|
66
160
|
missingSigned.push(field);
|
|
67
|
-
|
|
161
|
+
} else if (expected.state === 'invalid') {
|
|
162
|
+
invalidSigned.push(field);
|
|
163
|
+
} else {
|
|
164
|
+
signedValues[field] = expected.value;
|
|
68
165
|
}
|
|
69
|
-
|
|
70
|
-
if (!hasValue(actual)) {
|
|
166
|
+
if (actual.state === 'missing') {
|
|
71
167
|
missingObserved.push(field);
|
|
72
|
-
|
|
168
|
+
} else if (actual.state === 'invalid') {
|
|
169
|
+
invalidObserved.push(field);
|
|
170
|
+
} else {
|
|
171
|
+
observedValues[field] = actual.value;
|
|
172
|
+
}
|
|
173
|
+
if (expected.state === 'valid' && actual.state === 'valid'
|
|
174
|
+
&& !equalValue(expected.value, actual.value)) mismatched.push(field);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Per-field checks do not see one object reused by two required fields.
|
|
178
|
+
// Validate the aggregate graphs before computing either digest.
|
|
179
|
+
try { assertCanonicalJson(signedValues); } catch {
|
|
180
|
+
for (const field of Object.keys(signedValues)) {
|
|
181
|
+
if (!invalidSigned.includes(field)) invalidSigned.push(field);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try { assertCanonicalJson(observedValues); } catch {
|
|
185
|
+
for (const field of Object.keys(observedValues)) {
|
|
186
|
+
if (!invalidObserved.includes(field)) invalidObserved.push(field);
|
|
73
187
|
}
|
|
74
|
-
observedValues[field] = normalize(actual);
|
|
75
|
-
if (!equalValue(expected, actual)) mismatched.push(field);
|
|
76
188
|
}
|
|
77
189
|
|
|
78
190
|
return {
|
|
79
191
|
'@version': EXECUTION_BINDING_VERSION,
|
|
80
|
-
ok: missingSigned.length === 0 && missingObserved.length === 0
|
|
192
|
+
ok: missingSigned.length === 0 && missingObserved.length === 0
|
|
193
|
+
&& invalidSigned.length === 0 && invalidObserved.length === 0 && mismatched.length === 0,
|
|
81
194
|
required: true,
|
|
82
195
|
required_fields: requiredFields,
|
|
83
196
|
missing_signed_fields: missingSigned,
|
|
84
197
|
missing_observed_fields: missingObserved,
|
|
198
|
+
invalid_signed_fields: invalidSigned,
|
|
199
|
+
invalid_observed_fields: invalidObserved,
|
|
85
200
|
mismatched_fields: mismatched,
|
|
86
|
-
signed_hash: hashCanonical(signedValues),
|
|
87
|
-
observed_hash: hashCanonical(observedValues),
|
|
201
|
+
signed_hash: invalidSigned.length === 0 ? hashCanonical(signedValues) : null,
|
|
202
|
+
observed_hash: invalidObserved.length === 0 ? hashCanonical(observedValues) : null,
|
|
88
203
|
note: 'Executor MUST provide observedAction from the system of record; request-body fields are not a trust source.',
|
|
89
204
|
};
|
|
90
205
|
}
|