@emilia-protocol/gate 0.9.0 → 0.9.1
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/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/enterprise.js +174 -0
- package/index.js +125 -10
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +39 -4
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
package/index.js
CHANGED
|
@@ -47,7 +47,7 @@ import { MemoryConsumptionStore } from './store.js';
|
|
|
47
47
|
import { createEvidenceLog } from './evidence.js';
|
|
48
48
|
import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
|
|
49
49
|
import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
|
|
50
|
-
import { buildReliancePacket } from './reliance-packet.js';
|
|
50
|
+
import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.js';
|
|
51
51
|
import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR, mintDeviceSignoff, mintQuorumEvidence } from './eg1-conformance.js';
|
|
52
52
|
import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
|
|
53
53
|
import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
|
|
@@ -72,7 +72,7 @@ export {
|
|
|
72
72
|
validateActionControlManifest,
|
|
73
73
|
} from './action-control-manifest.js';
|
|
74
74
|
export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
|
|
75
|
-
export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
|
|
75
|
+
export { RELIANCE_PACKET_VERSION, ADMISSIBILITY_VERDICTS, buildReliancePacket } from './reliance-packet.js';
|
|
76
76
|
export {
|
|
77
77
|
EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
|
|
78
78
|
createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
|
|
@@ -81,6 +81,55 @@ export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
|
|
|
81
81
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
82
82
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Verify a PRE-COMPUTED reliance packet's admissibility block against a profile
|
|
86
|
+
* the relying party PINNED. This is the gate's whole job re: admissibility — it
|
|
87
|
+
* does NOT re-evaluate raw evidence and does NOT define the bar. The relying
|
|
88
|
+
* party's own evaluator (lib/evidence/admissibility-profiles.js) computes the
|
|
89
|
+
* verdict OFFLINE against its pinned profile and produces the reliance packet;
|
|
90
|
+
* the gate only confirms the packet answers the SAME profile_hash and carries an
|
|
91
|
+
* 'admissible' verdict. Pure, dependency-light, fail-closed.
|
|
92
|
+
*
|
|
93
|
+
* @param {{id?:string, profile_hash:string}} pinned the profile the relying party requires
|
|
94
|
+
* @param {object|null} presented a reliance packet, or its `.admissibility` block,
|
|
95
|
+
* as produced by buildReliancePacket / the relying party's evaluator
|
|
96
|
+
* @returns {{ok:boolean, reason:string|null, pinned_hash:string, presented_hash:string|null, verdict:string|null}}
|
|
97
|
+
* ok:true ONLY when the presented profile_hash equals the pinned hash AND the
|
|
98
|
+
* verdict is exactly 'admissible'. Every other case fails closed with a distinct reason.
|
|
99
|
+
*/
|
|
100
|
+
export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
101
|
+
const pinnedHash = pinned && typeof pinned.profile_hash === 'string' ? pinned.profile_hash : null;
|
|
102
|
+
// A pin with no hash is a misconfiguration: refuse, do not silently pass.
|
|
103
|
+
if (!pinnedHash) {
|
|
104
|
+
return { ok: false, reason: 'pinned_profile_missing_hash', pinned_hash: null, presented_hash: null, verdict: null };
|
|
105
|
+
}
|
|
106
|
+
// Accept either a full reliance packet (has .admissibility) or the block itself.
|
|
107
|
+
const adm = presented && typeof presented === 'object'
|
|
108
|
+
? (presented.admissibility !== undefined ? presented.admissibility : presented)
|
|
109
|
+
: null;
|
|
110
|
+
if (!adm || typeof adm !== 'object') {
|
|
111
|
+
return { ok: false, reason: 'admissibility_profile_pinned_but_absent', pinned_hash: pinnedHash, presented_hash: null, verdict: null };
|
|
112
|
+
}
|
|
113
|
+
const presentedHash = typeof adm.profile_hash === 'string' ? adm.profile_hash : null;
|
|
114
|
+
const verdict = typeof adm.verdict === 'string' ? adm.verdict : null;
|
|
115
|
+
// Constant-work equality is unnecessary (hashes are public), but the mismatch
|
|
116
|
+
// MUST be a distinct, named refusal: a presented verdict for a DIFFERENT bar is
|
|
117
|
+
// not evidence about the pinned bar.
|
|
118
|
+
if (presentedHash === null || presentedHash !== pinnedHash) {
|
|
119
|
+
return { ok: false, reason: 'profile_hash_mismatch', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
120
|
+
}
|
|
121
|
+
// Verdict must be recognized AND exactly 'admissible'. Any other closed-set
|
|
122
|
+
// member (missing_evidence/stale/conflicted/unverifiable), an unrecognized
|
|
123
|
+
// string, or a missing verdict fails closed and names the verdict it saw.
|
|
124
|
+
if (verdict === null || !ADMISSIBILITY_VERDICTS.includes(verdict)) {
|
|
125
|
+
return { ok: false, reason: 'admissibility_verdict_unrecognized', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
126
|
+
}
|
|
127
|
+
if (verdict !== 'admissible') {
|
|
128
|
+
return { ok: false, reason: `admissibility_not_admissible:${verdict}`, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, reason: null, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
131
|
+
}
|
|
132
|
+
|
|
84
133
|
/**
|
|
85
134
|
* The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
|
|
86
135
|
*
|
|
@@ -183,7 +232,7 @@ function isSignoffEvidence(s) {
|
|
|
183
232
|
* if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
|
|
184
233
|
* revoked) at its issuance time.
|
|
185
234
|
*/
|
|
186
|
-
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null } = {}) {
|
|
235
|
+
export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, requiredAdmissibilityProfile = null } = {}) {
|
|
187
236
|
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
188
237
|
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
189
238
|
// existing callers are unchanged.
|
|
@@ -210,10 +259,19 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
210
259
|
}
|
|
211
260
|
const evidence = log || createEvidenceLog({ strict: strictEvidence });
|
|
212
261
|
|
|
213
|
-
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
|
|
262
|
+
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume', admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}) {
|
|
214
263
|
const requirement = manifest ? findActionRequirement(manifest, selector) : null;
|
|
215
264
|
const action = requirement?.action_type || selector.action_type || selector.action || null;
|
|
216
|
-
|
|
265
|
+
// Assurance tier the action requires (cryptographically checked below). For a
|
|
266
|
+
// manifest-guarded action the tier MUST be declared explicitly: never fall
|
|
267
|
+
// back to the weakest 'software' tier because assurance_class was omitted —
|
|
268
|
+
// that would let a guarded, possibly critical, action accept a bare
|
|
269
|
+
// machine-signed receipt (a fail-open). A guarded requirement with no tier
|
|
270
|
+
// is a misconfiguration and fails closed just below. Only selector-only
|
|
271
|
+
// checks (no manifest requirement) use the documented 'software' default.
|
|
272
|
+
const requiredTier = requirement
|
|
273
|
+
? requirement.assurance_class
|
|
274
|
+
: (selector.assurance_class || 'software');
|
|
217
275
|
const observed = observedAction || selector.observedAction || selector.actionDetails || null;
|
|
218
276
|
|
|
219
277
|
async function decide(allow, status, reason, extra = {}) {
|
|
@@ -268,6 +326,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
268
326
|
if (!receipt) {
|
|
269
327
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
|
|
270
328
|
}
|
|
329
|
+
// A manifest-guarded action that declares no assurance_class is a
|
|
330
|
+
// misconfiguration. Fail CLOSED rather than defaulting to the weakest tier
|
|
331
|
+
// (which would accept a bare machine-signed receipt for a guarded action).
|
|
332
|
+
// validateActionRiskManifest also rejects such a manifest at author time;
|
|
333
|
+
// this is defense in depth for a manifest loaded without re-validation.
|
|
334
|
+
if (requirement && requirement.receipt_required !== false && !requiredTier) {
|
|
335
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'manifest_missing_assurance_class');
|
|
336
|
+
}
|
|
271
337
|
// Signature / freshness / action-binding / outcome. Production key custody:
|
|
272
338
|
// resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
|
|
273
339
|
// time. A revoked or out-of-window key is excluded, so its signature does not
|
|
@@ -324,6 +390,29 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
324
390
|
if (!executionBinding.ok) {
|
|
325
391
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
|
|
326
392
|
}
|
|
393
|
+
// OPT-IN admissibility pinning. When the caller pins a required admissibility
|
|
394
|
+
// profile {id, profile_hash} (gate-level requiredAdmissibilityProfile, a
|
|
395
|
+
// per-call admissibilityProfile, or selector.admissibilityProfile), the gate
|
|
396
|
+
// REFUSES unless a presented reliance packet's admissibility block was computed
|
|
397
|
+
// against the SAME pinned profile_hash AND carries an 'admissible' verdict. The
|
|
398
|
+
// gate does NOT re-evaluate raw evidence and does NOT define the bar — the
|
|
399
|
+
// relying party's own evaluator produced the verdict OFFLINE against its pinned
|
|
400
|
+
// profile. Checked BEFORE consumption so a mismatch never burns the receipt.
|
|
401
|
+
// When no profile is pinned, this whole block is inert — behavior is
|
|
402
|
+
// byte-for-byte unchanged from the pre-admissibility gate.
|
|
403
|
+
const pinnedProfile = admissibilityProfile || selector.admissibilityProfile || requiredAdmissibilityProfile;
|
|
404
|
+
if (pinnedProfile) {
|
|
405
|
+
const presentedAdmissibility = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
|
|
406
|
+
const adm = verifyAdmissibilityAgainstPinnedProfile(pinnedProfile, presentedAdmissibility);
|
|
407
|
+
if (!adm.ok) {
|
|
408
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, adm.reason, {
|
|
409
|
+
admissibility_check: adm,
|
|
410
|
+
pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
|
|
411
|
+
have_tier: have,
|
|
412
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
327
416
|
// One-time consumption (replay defense). Require a stable, issuer-generated
|
|
328
417
|
// receipt_id — never fall back to a content hash, whose canonicalization can
|
|
329
418
|
// differ across language implementations and silently break replay detection
|
|
@@ -346,7 +435,16 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
346
435
|
if (!fresh) {
|
|
347
436
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
|
|
348
437
|
}
|
|
349
|
-
|
|
438
|
+
const allowExtra = { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode };
|
|
439
|
+
// Carry the admissibility block (from the presented packet) onto the decision
|
|
440
|
+
// so a reliance packet built from this decision embeds the verdict the relying
|
|
441
|
+
// party's evaluator computed. Only when something was actually presented.
|
|
442
|
+
const presentedAdmForAllow = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
|
|
443
|
+
if (presentedAdmForAllow) {
|
|
444
|
+
const admBlock = presentedAdmForAllow.admissibility !== undefined ? presentedAdmForAllow.admissibility : presentedAdmForAllow;
|
|
445
|
+
if (admBlock) allowExtra.admissibility = admBlock;
|
|
446
|
+
}
|
|
447
|
+
return decide(true, 200, 'allow', allowExtra);
|
|
350
448
|
}
|
|
351
449
|
|
|
352
450
|
/** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
|
|
@@ -400,9 +498,9 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
400
498
|
* If the side effect throws, the reservation is released so the approval can
|
|
401
499
|
* be retried safely.
|
|
402
500
|
*/
|
|
403
|
-
async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
|
|
501
|
+
async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
|
|
404
502
|
if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
|
|
405
|
-
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
|
|
503
|
+
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve', admissibilityProfile, reliancePacket: presentedPacket, admissibility });
|
|
406
504
|
if (!authorization.allow) {
|
|
407
505
|
return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
|
|
408
506
|
}
|
|
@@ -441,7 +539,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
441
539
|
const observedAction = typeof opts.observedAction === 'function'
|
|
442
540
|
? opts.observedAction(...args)
|
|
443
541
|
: (opts.observedAction || selector.observedAction || null);
|
|
444
|
-
const
|
|
542
|
+
const admissibilityProfile = typeof opts.admissibilityProfile === 'function'
|
|
543
|
+
? opts.admissibilityProfile(...args)
|
|
544
|
+
: (opts.admissibilityProfile ?? null);
|
|
545
|
+
const presentedPacket = typeof opts.reliancePacket === 'function'
|
|
546
|
+
? opts.reliancePacket(...args)
|
|
547
|
+
: (opts.reliancePacket ?? opts.admissibility ?? null);
|
|
548
|
+
const out = await run({ selector, receipt, observedAction, admissibilityProfile, reliancePacket: presentedPacket }, () => fn(...args), { recordExecution: opts.recordExecution });
|
|
445
549
|
if (!out.ok) {
|
|
446
550
|
const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
|
|
447
551
|
e.code = 'EMILIA_RECEIPT_REQUIRED';
|
|
@@ -452,13 +556,22 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
452
556
|
};
|
|
453
557
|
}
|
|
454
558
|
|
|
455
|
-
function reliancePacket({ authorization, execution = null, binding = null } = {}) {
|
|
559
|
+
function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
|
|
560
|
+
// The admissibility block rides on the authorization decision's evidence when
|
|
561
|
+
// a reliance packet was presented at check() time; an explicit `admissibility`
|
|
562
|
+
// arg overrides it. buildReliancePacket fails closed on a non-'admissible'
|
|
563
|
+
// block, so a do_not_rely verdict can never be laundered into rely here.
|
|
564
|
+
const adm = admissibility
|
|
565
|
+
?? authorization?.evidence?.admissibility
|
|
566
|
+
?? authorization?.admissibility
|
|
567
|
+
?? null;
|
|
456
568
|
return buildReliancePacket({
|
|
457
569
|
decision: authorization,
|
|
458
570
|
execution,
|
|
459
571
|
evidence,
|
|
460
572
|
manifest,
|
|
461
573
|
binding,
|
|
574
|
+
admissibility: adm,
|
|
462
575
|
});
|
|
463
576
|
}
|
|
464
577
|
|
|
@@ -560,6 +673,8 @@ export default {
|
|
|
560
673
|
createGate,
|
|
561
674
|
createTrustedActionFirewall,
|
|
562
675
|
receiptAssuranceTier,
|
|
676
|
+
verifyAdmissibilityAgainstPinnedProfile,
|
|
677
|
+
ADMISSIBILITY_VERDICTS,
|
|
563
678
|
MemoryConsumptionStore,
|
|
564
679
|
createEvidenceLog,
|
|
565
680
|
ASSURANCE_TIERS,
|
package/metering.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — usage metering (the value metric, EP-GATE-USAGE-v1).
|
|
4
|
+
*
|
|
5
|
+
* Pricing scales with what the gate actually protects: PROTECTED IRREVERSIBLE
|
|
6
|
+
* ACTIONS (guarded decisions — allows AND denies both consume enforcement) plus
|
|
7
|
+
* RECEIPT-YEARS under retention (one receipt kept one year = one receipt-year;
|
|
8
|
+
* horizons follow retention.js, default 6y = 2190 days). NEVER seats.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions over evidence-log entries (`evidence.all()`); no clock, no
|
|
11
|
+
* network. The billing period is an explicit [periodStart, periodEnd) window —
|
|
12
|
+
* INCLUSIVE start, EXCLUSIVE end — so adjacent periods never double-count an
|
|
13
|
+
* entry sitting exactly on a boundary.
|
|
14
|
+
*
|
|
15
|
+
* Fail-closed posture for billing: an unbounded or reversed period is refused
|
|
16
|
+
* (thrown); malformed entries are surfaced in `integrity_warnings` rather than
|
|
17
|
+
* silently dropped; an entry whose allow flag is not literally `true` is
|
|
18
|
+
* counted as a deny; a statement over foreign or malformed usage is refused.
|
|
19
|
+
* `buildUsageStatement` output is deterministic (sorted keys + content hash)
|
|
20
|
+
* and UNSIGNED — the deployer signs it; the content hash binds that signature
|
|
21
|
+
* to exactly these numbers for billing reconciliation.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export const USAGE_VERSION = 'EP-GATE-USAGE-v1';
|
|
26
|
+
|
|
27
|
+
const DAYS_PER_YEAR = 365;
|
|
28
|
+
// Mirrors retention.js's cold-horizon default (coldDays = 2190 = 6y): a receipt
|
|
29
|
+
// is presumed retained for the full audit horizon unless the entry states its own.
|
|
30
|
+
const DEFAULT_RETENTION_YEARS = 2190 / DAYS_PER_YEAR;
|
|
31
|
+
|
|
32
|
+
function sha256hex(s) {
|
|
33
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Canonical JSON (recursive sorted keys) — matches evidence.js / @emilia-protocol/verify. */
|
|
37
|
+
function canonical(v) {
|
|
38
|
+
if (v === null || v === undefined) return JSON.stringify(v);
|
|
39
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
40
|
+
if (typeof v === 'object') {
|
|
41
|
+
return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(v);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toMs(t) {
|
|
47
|
+
if (t == null) return null;
|
|
48
|
+
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
49
|
+
return Number.isFinite(ms) ? ms : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Key-sorted copy so output is byte-stable regardless of entry order. */
|
|
53
|
+
function sortedCounts(map) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const k of Object.keys(map).sort()) out[k] = map[k];
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Entry-level day resolution (1/365y ≈ 0.00274) without float noise. */
|
|
60
|
+
function round6(x) {
|
|
61
|
+
return Math.round(x * 1e6) / 1e6;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The retention this entry STATES for itself, in years.
|
|
66
|
+
* @returns {number|null|NaN} years, `null` when unstated (default applies),
|
|
67
|
+
* `NaN` when stated but invalid (default applies + integrity warning — a
|
|
68
|
+
* malformed stated value must never silently shrink the metered total).
|
|
69
|
+
*/
|
|
70
|
+
function statedRetentionYears(e) {
|
|
71
|
+
if (e.retention_years != null) {
|
|
72
|
+
const y = Number(e.retention_years);
|
|
73
|
+
return Number.isFinite(y) && y >= 0 ? y : NaN;
|
|
74
|
+
}
|
|
75
|
+
if (e.retention_days != null) {
|
|
76
|
+
const d = Number(e.retention_days);
|
|
77
|
+
return Number.isFinite(d) && d >= 0 ? d / DAYS_PER_YEAR : NaN;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Meter a billing period over evidence entries.
|
|
84
|
+
*
|
|
85
|
+
* Billable = `kind: 'decision'` entries on GUARDED actions (`not_guarded`
|
|
86
|
+
* pass-throughs are free; execution records are provenance, not enforcement).
|
|
87
|
+
* Window is [periodStart, periodEnd): inclusive start, exclusive end.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array<object>} entries evidence.all()
|
|
90
|
+
* @param {object} o
|
|
91
|
+
* @param {string|number} o.periodStart ISO or ms — required
|
|
92
|
+
* @param {string|number} o.periodEnd ISO or ms — required, >= periodStart
|
|
93
|
+
* @param {number} [o.retentionYearsDefault=6] applied when an entry states no retention
|
|
94
|
+
* @returns {{protected_actions:number, allows:number, denies:number,
|
|
95
|
+
* replays_blocked:number, by_action_type:object, by_tier:object,
|
|
96
|
+
* receipt_years:number, period:object, integrity_warnings:object[]}}
|
|
97
|
+
*/
|
|
98
|
+
export function meterUsage(entries = [], { periodStart, periodEnd, retentionYearsDefault = DEFAULT_RETENTION_YEARS } = {}) {
|
|
99
|
+
const startMs = toMs(periodStart);
|
|
100
|
+
const endMs = toMs(periodEnd);
|
|
101
|
+
// An unbounded or reversed period is not meterable — refuse rather than emit
|
|
102
|
+
// a statement whose window cannot be reconciled.
|
|
103
|
+
if (startMs == null || endMs == null) {
|
|
104
|
+
throw new Error('meterUsage: periodStart and periodEnd are required (ISO string or ms)');
|
|
105
|
+
}
|
|
106
|
+
if (endMs < startMs) throw new Error('meterUsage: periodEnd must be >= periodStart');
|
|
107
|
+
if (!Number.isFinite(retentionYearsDefault) || retentionYearsDefault < 0) {
|
|
108
|
+
throw new Error('meterUsage: retentionYearsDefault must be a finite number >= 0');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const warnings = [];
|
|
112
|
+
const byAction = Object.create(null);
|
|
113
|
+
const byTier = Object.create(null);
|
|
114
|
+
let protectedActions = 0;
|
|
115
|
+
let allows = 0;
|
|
116
|
+
let denies = 0;
|
|
117
|
+
let replaysBlocked = 0;
|
|
118
|
+
let receiptYears = 0;
|
|
119
|
+
|
|
120
|
+
entries.forEach((e, index) => {
|
|
121
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) {
|
|
122
|
+
warnings.push({ index, reason: 'not_an_object' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const t = toMs(e.at);
|
|
126
|
+
if (t == null) {
|
|
127
|
+
// Unplaceable in ANY period — surfaced, never silently dropped.
|
|
128
|
+
warnings.push({ index, reason: 'unparseable_at' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (t < startMs || t >= endMs) return; // outside [start, end)
|
|
132
|
+
if (typeof e.kind !== 'string' || e.kind.length === 0) {
|
|
133
|
+
warnings.push({ index, reason: 'missing_kind' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (e.kind !== 'decision') return; // execution/other records are not billable
|
|
137
|
+
if (e.reason === 'not_guarded') return; // pass-throughs are free by design
|
|
138
|
+
|
|
139
|
+
protectedActions += 1;
|
|
140
|
+
// Fail closed: only a literal `true` counts as an allow.
|
|
141
|
+
if (e.allow === true) allows += 1; else denies += 1;
|
|
142
|
+
if (e.reason === 'replay_refused') replaysBlocked += 1;
|
|
143
|
+
|
|
144
|
+
const action = typeof e.action === 'string' && e.action ? e.action : 'unknown';
|
|
145
|
+
byAction[action] = (byAction[action] || 0) + 1;
|
|
146
|
+
const tier = typeof e.required_tier === 'string' && e.required_tier ? e.required_tier : 'unknown';
|
|
147
|
+
byTier[tier] = (byTier[tier] || 0) + 1;
|
|
148
|
+
|
|
149
|
+
const stated = statedRetentionYears(e);
|
|
150
|
+
if (Number.isNaN(stated)) {
|
|
151
|
+
warnings.push({ index, reason: 'invalid_stated_retention' });
|
|
152
|
+
receiptYears += retentionYearsDefault;
|
|
153
|
+
} else {
|
|
154
|
+
receiptYears += stated ?? retentionYearsDefault;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
'@version': USAGE_VERSION,
|
|
160
|
+
period: {
|
|
161
|
+
start: new Date(startMs).toISOString(),
|
|
162
|
+
end: new Date(endMs).toISOString(),
|
|
163
|
+
bounds: 'inclusive_start_exclusive_end',
|
|
164
|
+
},
|
|
165
|
+
protected_actions: protectedActions,
|
|
166
|
+
allows,
|
|
167
|
+
denies,
|
|
168
|
+
replays_blocked: replaysBlocked,
|
|
169
|
+
by_action_type: sortedCounts(byAction),
|
|
170
|
+
by_tier: sortedCounts(byTier),
|
|
171
|
+
receipt_years: round6(receiptYears),
|
|
172
|
+
retention_years_default: retentionYearsDefault,
|
|
173
|
+
integrity_warnings: warnings,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the signed-ready usage statement handed to billing reconciliation.
|
|
179
|
+
* UNSIGNED — the deployer signs it; `content_hash` (sha256 over the canonical
|
|
180
|
+
* JSON of everything else) binds that signature to exactly these numbers.
|
|
181
|
+
* Deterministic: same usage + org → byte-identical statement, regardless of
|
|
182
|
+
* the entry order the usage was metered from.
|
|
183
|
+
*/
|
|
184
|
+
export function buildUsageStatement(usage, { org } = {}) {
|
|
185
|
+
// Never emit a statement over an artifact of a different or unknown format,
|
|
186
|
+
// and never one that fails to name who it bills.
|
|
187
|
+
if (!usage || usage['@version'] !== USAGE_VERSION) {
|
|
188
|
+
throw new Error(`buildUsageStatement: usage must be a ${USAGE_VERSION} object from meterUsage`);
|
|
189
|
+
}
|
|
190
|
+
if (!org || typeof org !== 'string') {
|
|
191
|
+
throw new Error('buildUsageStatement: org is required — a statement must name the billed party');
|
|
192
|
+
}
|
|
193
|
+
if (!usage.period || !usage.period.start || !usage.period.end) {
|
|
194
|
+
throw new Error('buildUsageStatement: usage.period is missing');
|
|
195
|
+
}
|
|
196
|
+
for (const k of ['protected_actions', 'allows', 'denies', 'replays_blocked', 'receipt_years']) {
|
|
197
|
+
if (!Number.isFinite(usage[k]) || usage[k] < 0) {
|
|
198
|
+
throw new Error(`buildUsageStatement: usage.${k} must be a finite number >= 0`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const warningCount = Array.isArray(usage.integrity_warnings) ? usage.integrity_warnings.length : 0;
|
|
202
|
+
const body = {
|
|
203
|
+
'@version': USAGE_VERSION,
|
|
204
|
+
kind: 'usage_statement',
|
|
205
|
+
org,
|
|
206
|
+
period: {
|
|
207
|
+
start: usage.period.start,
|
|
208
|
+
end: usage.period.end,
|
|
209
|
+
bounds: 'inclusive_start_exclusive_end',
|
|
210
|
+
},
|
|
211
|
+
protected_actions: usage.protected_actions,
|
|
212
|
+
allows: usage.allows,
|
|
213
|
+
denies: usage.denies,
|
|
214
|
+
replays_blocked: usage.replays_blocked,
|
|
215
|
+
by_action_type: sortedCounts(usage.by_action_type || {}),
|
|
216
|
+
by_tier: sortedCounts(usage.by_tier || {}),
|
|
217
|
+
receipt_years: usage.receipt_years,
|
|
218
|
+
retention_years_default: usage.retention_years_default ?? DEFAULT_RETENTION_YEARS,
|
|
219
|
+
// Data-quality signal for the reconciler: a statement metered over a log
|
|
220
|
+
// with integrity warnings is flagged incomplete, never silently clean.
|
|
221
|
+
integrity_warning_count: warningCount,
|
|
222
|
+
complete: warningCount === 0,
|
|
223
|
+
};
|
|
224
|
+
return { ...body, content_hash: sha256hex(canonical(body)) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default { meterUsage, buildUsageStatement, USAGE_VERSION };
|