@emilia-protocol/gate 0.9.0 → 0.9.2
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 +132 -10
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +41 -4
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/external-verification.js +223 -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,14 @@ 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
|
+
export {
|
|
77
|
+
EXTERNAL_VERIFICATION_STATEMENT_VERSION,
|
|
78
|
+
EXTERNAL_VERIFICATION_DOMAIN,
|
|
79
|
+
externalVerificationDigest,
|
|
80
|
+
signExternalVerificationStatement,
|
|
81
|
+
verifyExternalVerificationStatement,
|
|
82
|
+
} from './reports/external-verification.js';
|
|
76
83
|
export {
|
|
77
84
|
EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
|
|
78
85
|
createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
|
|
@@ -81,6 +88,55 @@ export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
|
|
|
81
88
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
82
89
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
83
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Verify a PRE-COMPUTED reliance packet's admissibility block against a profile
|
|
93
|
+
* the relying party PINNED. This is the gate's whole job re: admissibility — it
|
|
94
|
+
* does NOT re-evaluate raw evidence and does NOT define the bar. The relying
|
|
95
|
+
* party's own evaluator (lib/evidence/admissibility-profiles.js) computes the
|
|
96
|
+
* verdict OFFLINE against its pinned profile and produces the reliance packet;
|
|
97
|
+
* the gate only confirms the packet answers the SAME profile_hash and carries an
|
|
98
|
+
* 'admissible' verdict. Pure, dependency-light, fail-closed.
|
|
99
|
+
*
|
|
100
|
+
* @param {{id?:string, profile_hash:string}} pinned the profile the relying party requires
|
|
101
|
+
* @param {object|null} presented a reliance packet, or its `.admissibility` block,
|
|
102
|
+
* as produced by buildReliancePacket / the relying party's evaluator
|
|
103
|
+
* @returns {{ok:boolean, reason:string|null, pinned_hash:string, presented_hash:string|null, verdict:string|null}}
|
|
104
|
+
* ok:true ONLY when the presented profile_hash equals the pinned hash AND the
|
|
105
|
+
* verdict is exactly 'admissible'. Every other case fails closed with a distinct reason.
|
|
106
|
+
*/
|
|
107
|
+
export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
|
|
108
|
+
const pinnedHash = pinned && typeof pinned.profile_hash === 'string' ? pinned.profile_hash : null;
|
|
109
|
+
// A pin with no hash is a misconfiguration: refuse, do not silently pass.
|
|
110
|
+
if (!pinnedHash) {
|
|
111
|
+
return { ok: false, reason: 'pinned_profile_missing_hash', pinned_hash: null, presented_hash: null, verdict: null };
|
|
112
|
+
}
|
|
113
|
+
// Accept either a full reliance packet (has .admissibility) or the block itself.
|
|
114
|
+
const adm = presented && typeof presented === 'object'
|
|
115
|
+
? (presented.admissibility !== undefined ? presented.admissibility : presented)
|
|
116
|
+
: null;
|
|
117
|
+
if (!adm || typeof adm !== 'object') {
|
|
118
|
+
return { ok: false, reason: 'admissibility_profile_pinned_but_absent', pinned_hash: pinnedHash, presented_hash: null, verdict: null };
|
|
119
|
+
}
|
|
120
|
+
const presentedHash = typeof adm.profile_hash === 'string' ? adm.profile_hash : null;
|
|
121
|
+
const verdict = typeof adm.verdict === 'string' ? adm.verdict : null;
|
|
122
|
+
// Constant-work equality is unnecessary (hashes are public), but the mismatch
|
|
123
|
+
// MUST be a distinct, named refusal: a presented verdict for a DIFFERENT bar is
|
|
124
|
+
// not evidence about the pinned bar.
|
|
125
|
+
if (presentedHash === null || presentedHash !== pinnedHash) {
|
|
126
|
+
return { ok: false, reason: 'profile_hash_mismatch', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
127
|
+
}
|
|
128
|
+
// Verdict must be recognized AND exactly 'admissible'. Any other closed-set
|
|
129
|
+
// member (missing_evidence/stale/conflicted/unverifiable), an unrecognized
|
|
130
|
+
// string, or a missing verdict fails closed and names the verdict it saw.
|
|
131
|
+
if (verdict === null || !ADMISSIBILITY_VERDICTS.includes(verdict)) {
|
|
132
|
+
return { ok: false, reason: 'admissibility_verdict_unrecognized', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
133
|
+
}
|
|
134
|
+
if (verdict !== 'admissible') {
|
|
135
|
+
return { ok: false, reason: `admissibility_not_admissible:${verdict}`, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, reason: null, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
/**
|
|
85
141
|
* The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
|
|
86
142
|
*
|
|
@@ -183,7 +239,7 @@ function isSignoffEvidence(s) {
|
|
|
183
239
|
* if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
|
|
184
240
|
* revoked) at its issuance time.
|
|
185
241
|
*/
|
|
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 } = {}) {
|
|
242
|
+
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
243
|
// Production key custody: a registry (rotation + revocation) supersedes a flat
|
|
188
244
|
// trustedKeys list. A flat list is coerced to an always-valid registry, so
|
|
189
245
|
// existing callers are unchanged.
|
|
@@ -210,10 +266,19 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
210
266
|
}
|
|
211
267
|
const evidence = log || createEvidenceLog({ strict: strictEvidence });
|
|
212
268
|
|
|
213
|
-
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
|
|
269
|
+
async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume', admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}) {
|
|
214
270
|
const requirement = manifest ? findActionRequirement(manifest, selector) : null;
|
|
215
271
|
const action = requirement?.action_type || selector.action_type || selector.action || null;
|
|
216
|
-
|
|
272
|
+
// Assurance tier the action requires (cryptographically checked below). For a
|
|
273
|
+
// manifest-guarded action the tier MUST be declared explicitly: never fall
|
|
274
|
+
// back to the weakest 'software' tier because assurance_class was omitted —
|
|
275
|
+
// that would let a guarded, possibly critical, action accept a bare
|
|
276
|
+
// machine-signed receipt (a fail-open). A guarded requirement with no tier
|
|
277
|
+
// is a misconfiguration and fails closed just below. Only selector-only
|
|
278
|
+
// checks (no manifest requirement) use the documented 'software' default.
|
|
279
|
+
const requiredTier = requirement
|
|
280
|
+
? requirement.assurance_class
|
|
281
|
+
: (selector.assurance_class || 'software');
|
|
217
282
|
const observed = observedAction || selector.observedAction || selector.actionDetails || null;
|
|
218
283
|
|
|
219
284
|
async function decide(allow, status, reason, extra = {}) {
|
|
@@ -268,6 +333,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
268
333
|
if (!receipt) {
|
|
269
334
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
|
|
270
335
|
}
|
|
336
|
+
// A manifest-guarded action that declares no assurance_class is a
|
|
337
|
+
// misconfiguration. Fail CLOSED rather than defaulting to the weakest tier
|
|
338
|
+
// (which would accept a bare machine-signed receipt for a guarded action).
|
|
339
|
+
// validateActionRiskManifest also rejects such a manifest at author time;
|
|
340
|
+
// this is defense in depth for a manifest loaded without re-validation.
|
|
341
|
+
if (requirement && requirement.receipt_required !== false && !requiredTier) {
|
|
342
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, 'manifest_missing_assurance_class');
|
|
343
|
+
}
|
|
271
344
|
// Signature / freshness / action-binding / outcome. Production key custody:
|
|
272
345
|
// resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
|
|
273
346
|
// time. A revoked or out-of-window key is excluded, so its signature does not
|
|
@@ -324,6 +397,29 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
324
397
|
if (!executionBinding.ok) {
|
|
325
398
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
|
|
326
399
|
}
|
|
400
|
+
// OPT-IN admissibility pinning. When the caller pins a required admissibility
|
|
401
|
+
// profile {id, profile_hash} (gate-level requiredAdmissibilityProfile, a
|
|
402
|
+
// per-call admissibilityProfile, or selector.admissibilityProfile), the gate
|
|
403
|
+
// REFUSES unless a presented reliance packet's admissibility block was computed
|
|
404
|
+
// against the SAME pinned profile_hash AND carries an 'admissible' verdict. The
|
|
405
|
+
// gate does NOT re-evaluate raw evidence and does NOT define the bar — the
|
|
406
|
+
// relying party's own evaluator produced the verdict OFFLINE against its pinned
|
|
407
|
+
// profile. Checked BEFORE consumption so a mismatch never burns the receipt.
|
|
408
|
+
// When no profile is pinned, this whole block is inert — behavior is
|
|
409
|
+
// byte-for-byte unchanged from the pre-admissibility gate.
|
|
410
|
+
const pinnedProfile = admissibilityProfile || selector.admissibilityProfile || requiredAdmissibilityProfile;
|
|
411
|
+
if (pinnedProfile) {
|
|
412
|
+
const presentedAdmissibility = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
|
|
413
|
+
const adm = verifyAdmissibilityAgainstPinnedProfile(pinnedProfile, presentedAdmissibility);
|
|
414
|
+
if (!adm.ok) {
|
|
415
|
+
return decide(false, RECEIPT_REQUIRED_STATUS, adm.reason, {
|
|
416
|
+
admissibility_check: adm,
|
|
417
|
+
pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
|
|
418
|
+
have_tier: have,
|
|
419
|
+
assurance_tier_source: 'cryptographic_verification',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
327
423
|
// One-time consumption (replay defense). Require a stable, issuer-generated
|
|
328
424
|
// receipt_id — never fall back to a content hash, whose canonicalization can
|
|
329
425
|
// differ across language implementations and silently break replay detection
|
|
@@ -346,7 +442,16 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
346
442
|
if (!fresh) {
|
|
347
443
|
return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
|
|
348
444
|
}
|
|
349
|
-
|
|
445
|
+
const allowExtra = { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode };
|
|
446
|
+
// Carry the admissibility block (from the presented packet) onto the decision
|
|
447
|
+
// so a reliance packet built from this decision embeds the verdict the relying
|
|
448
|
+
// party's evaluator computed. Only when something was actually presented.
|
|
449
|
+
const presentedAdmForAllow = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
|
|
450
|
+
if (presentedAdmForAllow) {
|
|
451
|
+
const admBlock = presentedAdmForAllow.admissibility !== undefined ? presentedAdmForAllow.admissibility : presentedAdmForAllow;
|
|
452
|
+
if (admBlock) allowExtra.admissibility = admBlock;
|
|
453
|
+
}
|
|
454
|
+
return decide(true, 200, 'allow', allowExtra);
|
|
350
455
|
}
|
|
351
456
|
|
|
352
457
|
/** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
|
|
@@ -400,9 +505,9 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
400
505
|
* If the side effect throws, the reservation is released so the approval can
|
|
401
506
|
* be retried safely.
|
|
402
507
|
*/
|
|
403
|
-
async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
|
|
508
|
+
async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
|
|
404
509
|
if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
|
|
405
|
-
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
|
|
510
|
+
const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve', admissibilityProfile, reliancePacket: presentedPacket, admissibility });
|
|
406
511
|
if (!authorization.allow) {
|
|
407
512
|
return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
|
|
408
513
|
}
|
|
@@ -441,7 +546,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
441
546
|
const observedAction = typeof opts.observedAction === 'function'
|
|
442
547
|
? opts.observedAction(...args)
|
|
443
548
|
: (opts.observedAction || selector.observedAction || null);
|
|
444
|
-
const
|
|
549
|
+
const admissibilityProfile = typeof opts.admissibilityProfile === 'function'
|
|
550
|
+
? opts.admissibilityProfile(...args)
|
|
551
|
+
: (opts.admissibilityProfile ?? null);
|
|
552
|
+
const presentedPacket = typeof opts.reliancePacket === 'function'
|
|
553
|
+
? opts.reliancePacket(...args)
|
|
554
|
+
: (opts.reliancePacket ?? opts.admissibility ?? null);
|
|
555
|
+
const out = await run({ selector, receipt, observedAction, admissibilityProfile, reliancePacket: presentedPacket }, () => fn(...args), { recordExecution: opts.recordExecution });
|
|
445
556
|
if (!out.ok) {
|
|
446
557
|
const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
|
|
447
558
|
e.code = 'EMILIA_RECEIPT_REQUIRED';
|
|
@@ -452,13 +563,22 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
|
|
|
452
563
|
};
|
|
453
564
|
}
|
|
454
565
|
|
|
455
|
-
function reliancePacket({ authorization, execution = null, binding = null } = {}) {
|
|
566
|
+
function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
|
|
567
|
+
// The admissibility block rides on the authorization decision's evidence when
|
|
568
|
+
// a reliance packet was presented at check() time; an explicit `admissibility`
|
|
569
|
+
// arg overrides it. buildReliancePacket fails closed on a non-'admissible'
|
|
570
|
+
// block, so a do_not_rely verdict can never be laundered into rely here.
|
|
571
|
+
const adm = admissibility
|
|
572
|
+
?? authorization?.evidence?.admissibility
|
|
573
|
+
?? authorization?.admissibility
|
|
574
|
+
?? null;
|
|
456
575
|
return buildReliancePacket({
|
|
457
576
|
decision: authorization,
|
|
458
577
|
execution,
|
|
459
578
|
evidence,
|
|
460
579
|
manifest,
|
|
461
580
|
binding,
|
|
581
|
+
admissibility: adm,
|
|
462
582
|
});
|
|
463
583
|
}
|
|
464
584
|
|
|
@@ -560,6 +680,8 @@ export default {
|
|
|
560
680
|
createGate,
|
|
561
681
|
createTrustedActionFirewall,
|
|
562
682
|
receiptAssuranceTier,
|
|
683
|
+
verifyAdmissibilityAgainstPinnedProfile,
|
|
684
|
+
ADMISSIBILITY_VERDICTS,
|
|
563
685
|
MemoryConsumptionStore,
|
|
564
686
|
createEvidenceLog,
|
|
565
687
|
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 };
|