@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +190 -0
  3. package/README.md +75 -16
  4. package/action-control-manifest.js +26 -0
  5. package/adapters/_kit.js +47 -5
  6. package/adapters/aws.js +15 -4
  7. package/adapters/github-demo.mjs +25 -22
  8. package/adapters/github.js +1 -1
  9. package/adapters/jira.js +1 -0
  10. package/adapters/linear.js +1 -0
  11. package/adapters/salesforce.js +1 -0
  12. package/adapters/stripe.js +1 -1
  13. package/adapters/supabase.js +26 -4
  14. package/adapters/vercel.js +33 -4
  15. package/aec-execution.js +16 -4
  16. package/breakglass.js +285 -51
  17. package/control-plane.js +341 -0
  18. package/coverage.js +722 -0
  19. package/custody-demo.mjs +13 -3
  20. package/demo.mjs +9 -3
  21. package/deploy/helm/README.md +16 -8
  22. package/deploy/helm/emilia-gate/Chart.yaml +3 -1
  23. package/deploy/helm/emilia-gate/templates/NOTES.txt +3 -2
  24. package/deploy/helm/emilia-gate/templates/deployment.yaml +55 -1
  25. package/deploy/helm/emilia-gate/values.yaml +18 -2
  26. package/deploy/helm/emilia-gate-service/Chart.yaml +11 -0
  27. package/deploy/helm/emilia-gate-service/README.md +81 -0
  28. package/deploy/helm/emilia-gate-service/templates/NOTES.txt +12 -0
  29. package/deploy/helm/emilia-gate-service/templates/_helpers.tpl +83 -0
  30. package/deploy/helm/emilia-gate-service/templates/deployment.yaml +153 -0
  31. package/deploy/helm/emilia-gate-service/templates/migration-job.yaml +73 -0
  32. package/deploy/helm/emilia-gate-service/templates/networkpolicy.yaml +113 -0
  33. package/deploy/helm/emilia-gate-service/templates/pdb.yaml +14 -0
  34. package/deploy/helm/emilia-gate-service/templates/service.yaml +20 -0
  35. package/deploy/helm/emilia-gate-service/templates/serviceaccount.yaml +8 -0
  36. package/deploy/helm/emilia-gate-service/tests/fixtures/001_gate.sql +26 -0
  37. package/deploy/helm/emilia-gate-service/tests/fixtures/002_runtime_access.sql +32 -0
  38. package/deploy/helm/emilia-gate-service/tests/fixtures/gate.config.mjs +29 -0
  39. package/deploy/helm/emilia-gate-service/tests/render-check.sh +145 -0
  40. package/deploy/helm/emilia-gate-service/values.schema.json +145 -0
  41. package/deploy/helm/emilia-gate-service/values.yaml +166 -0
  42. package/deploy/sql/001-runtime.sql +707 -0
  43. package/deploy/terraform/README.md +24 -14
  44. package/deploy/terraform/main.tf +59 -0
  45. package/deploy/terraform/service/README.md +81 -0
  46. package/deploy/terraform/service/main.tf +718 -0
  47. package/deploy/terraform/service/outputs.tf +19 -0
  48. package/deploy/terraform/service/tests/validate.sh +24 -0
  49. package/deploy/terraform/service/tests/validation.tftest.hcl +168 -0
  50. package/deploy/terraform/service/variables.tf +459 -0
  51. package/deploy/terraform/service/versions.tf +10 -0
  52. package/deploy/terraform/variables.tf +95 -2
  53. package/deployment-attestation.js +248 -0
  54. package/eg1-conformance.js +46 -12
  55. package/enterprise.js +6 -2
  56. package/ep-assure.mjs +3 -2
  57. package/evidence-postgres.js +357 -0
  58. package/evidence.js +10 -3
  59. package/execution-binding.js +141 -26
  60. package/index.js +556 -115
  61. package/key-registry.js +51 -19
  62. package/mcp.js +4 -2
  63. package/network-witness.js +500 -0
  64. package/package.json +25 -5
  65. package/reliance-kernel.js +5 -6
  66. package/reliance-packet.js +43 -10
  67. package/reports/assurance-package.js +45 -19
  68. package/reports/reperform.js +78 -18
  69. package/reports/underwriter.js +1 -1
  70. package/settlement.js +300 -0
  71. package/store-postgres.js +14 -0
  72. package/store.js +26 -5
  73. package/strict-json.js +86 -0
  74. 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 default store is in-memory; fleets
8
- * use the ownership-fenced durable contract below.
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 the action succeeds. */
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 a reserved id after the action fails; approval stays retryable. */
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 { MemoryConsumptionStore, createDurableConsumptionStore, createMemoryBackend, DURABLE_CONSUMPTION_VERSION };
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
+ };