Audit

Tamper-evident, append-only record of every privileged action — the forensic surface every compliance posture (HIPAA / PCI-DSS / SOC 2 / GDPR / SOX / DORA) bottoms out on. The audit_log table is baked into db.js's schema runner so apps cannot opt out; the chain is verified at boot and a break refuses-to-boot.

Hash chain: every row carries prevHash + rowHash computed over the SEALED form of the row plus a nonce. Verification recomputes directly from disk without unsealing — auditors can confirm integrity without holding the vault key. Periodic SLH-DSA-SHAKE-256f checkpoints (post-quantum signatures over the chain tip) anchor the chain to off-line evidence; tampering that recomputes hashes still fails checkpoint verification.

Namespaces: framework owns auth.* / system.* / audit.* / consent.* / subject.*; apps call registerNamespace("orders") at boot before emitting orders.created. Unregistered namespaces are rejected so typos don't become silent unobservable events.

Action shape — the 5W form: WHO (actor.userId / sessionId / ip / userAgent), WHAT (action = "namespace.verb[.qualifier]"), WHEN (recordedAt ms epoch + monotonic counter), WHERE (resource.kind / id), HOW (outcome ∈ {success, failure, denied} + reason + metadata).

Two emit paths: - record(event) — async, throws on bad input, awaits the chain append. Use when the caller needs durability before continuing. - emit(event) / safeEmit(event) — synchronous fire-and-forget; events buffer in an AsyncHandler and drain serially through record(). safeEmit is drop-silent on malformed input by design: it runs in request hot paths where throwing would crash the request that triggered the audit attempt.

Reserved metadata keys: traceId (cross-request correlation, beginTrace() mints), parentEventId, before / after (state diff for change events), evidenceRef (pointer to signed PDF / ticket).

b.audit.registerNamespace(name) #

0.1.0

Register an action namespace at app bootstrap so record() / emit() accept events under it. Names must match [a-z][a-z0-9_]*. Calling twice is a no-op. Framework namespaces (auth / system / audit / consent / subject + every per-primitive namespace) are pre-registered.

b.audit.registerNamespace("orders");
b.audit.safeEmit({
  action:  "orders.shipped",
  actor:   { userId: "u-42" },
  outcome: "success",
});

b.audit.record(event) #

0.1.0hipaapci-dssgdprsoc2sox-404
{
  actor:     { userId, ip, userAgent, sessionId },
  action:    "namespace.verb[.qualifier]",
  resource:  { kind, id },
  outcome:   "success" | "failure" | "denied",
  reason:    string,
  metadata:  object,             // serialized to JSON
  requestId: string,
}

Append one event to the audit chain and await durability. Throws on a bad action shape, an unregistered namespace, or an outcome outside {success, failure, denied}. The chain-writer serializes the actual INSERT under a mutex so concurrent record() calls produce a strictly monotonic counter and a valid prevHash → rowHash chain.

Use record() when the caller must know the row landed before continuing (consent grants, break-glass unseals, change-control approvals). For request hot paths where best-effort is acceptable, prefer safeEmit().

await b.audit.record({
  actor:    { userId: "u-42", ip: "10.0.0.1" },
  action:   "consent.granted",
  resource: { kind: "purpose", id: "marketing" },
  outcome:  "success",
  metadata: { traceId: b.audit.beginTrace() },
});

b.audit.useStore({ record }) #

stable0.11.4hipaapci-dssgdprsoc2sox-404
{
  record:        async function (row),  // operator's persistence callback
  replaceChain:  boolean,               // default: false (shadow). true → redirect (skip the b.db chain)
}

Register an operator-supplied shadow store for every audit chain append. The framework's tamper-evident chain remains authoritative (HIPAA §164.312(b) / PCI-DSS Req 10 / SOX-404 / ISO 27001 A.12.4.1 posture preserved); the operator's record(row) async function is called AFTER each successful framework chain.append with the FULL appended row — { _id, recordedAt, monotonicCounter, prevHash, rowHash, action, outcome, actorUserId, ..., metadata } — so external consumers see identical hashes for cross-store reconciliation.

Typical use: replicate audit records to an immutable external destination (AWS QLDB / Azure Confidential Ledger / Google Cloud Audit Logs / an in-house WORM appliance / a SIEM forwarder). Operators in regulated industries often need their audit trail in a destination outside the application's own database for separation-of-duties (PCI-DSS Req 10.5.3) or independent retention (HIPAA §164.312(b) / SEC 17a-4 WORM).

Failure posture: if the operator's record throws / rejects / times out (30s hard cap — a stalled network call MUST NOT block the audit critical path), the shadow failure is surfaced via b.observability as either audit.shadow_failed (throw/reject) or audit.shadow_timeout (cap exceeded) with { action, monotonicCounter, error, timeoutMs } metadata, and the framework chain append still succeeds (the row is durable in the framework's own table; the shadow is a best-effort archival). Hot-path observability sinks emit drop-silent — an unreachable / hanging shadow MUST NOT crash or stall the request path that triggered the audit attempt.

Call this once at boot, BEFORE the first b.audit.record / b.audit.emit / b.audit.safeEmit. Switching stores on a running app strands every prior audit row in the previous shadow store — the framework chain has them, but the new shadow doesn't unless the operator backfills.

Pass null (or { record: null }) to unregister and revert to chain-only mode.

Redirect mode — useStore({ record, replaceChain: true }): a consumer that owns its own database + audit layer and does NOT use b.db has no chain to shadow. In shadow mode every record() / emit() / safeEmit() still tries the b.db chain append first, which throws db/not-initialized on every emit (or silently drops from the emit handler). With replaceChain: true the shaped audit event is handed STRAIGHT to record(event) and the b.db chain append is skipped, so framework audit events (an SMTP insecure-TLS escape-hatch, mTLS negotiation at boot) land in the consumer's own tamper-evident log instead of erroring. In redirect mode the consumer store is authoritative: record()'s 30s timeout bounds a stalled callback, but a genuine store failure PROPAGATES to the caller (record() is the await-durability surface), while the emit() / safeEmit() handler-flush path drop-silent-catches it. The event passed to record is the shaped logical event ({ action, outcome, actorUserId, actorIp, resourceKind, resourceId, reason, metadata, requestId, ... }) — no framework _id / monotonicCounter / prevHash / rowHash, since there is no framework chain to hash against.

var b = require("@blamejs/core");
await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
await b.db.init({ dataDir: "/var/lib/blamejs" });
b.audit.useStore({
  record: async function (row) {
    // Replicate to AWS QLDB / Azure Confidential Ledger / etc.
    await externalLedger.append({
      id:               row._id,
      recordedAt:       row.recordedAt,
      monotonicCounter: row.monotonicCounter,
      prevHash:         row.prevHash,
      rowHash:          row.rowHash,
      action:           row.action,
      outcome:          row.outcome,
      metadata:         row.metadata,
    });
  },
});
// Every b.audit.* append now also lands in externalLedger.

b.audit.query(criteria) #

0.1.0pci-dsssoc2
{
  from:         number | Date | string,   // recordedAt >=
  to:           number | Date | string,   // recordedAt <=
  actorUserId:  string,
  resourceId:   string,
  action:       string,
  resourceKind: string,
  outcome:      "success" | "failure" | "denied",
  limit:        number,
  offset:       number,
}

Read audit rows matching the criteria, returning unsealed rows for the auditor's view. Every call self-logs an audit.read event before returning (PCI DSS 10.2.3) so exfiltration attempts are forensically visible; the self-log is suppressed per-invocation only for a query whose own criteria targets action: "audit.read", so concurrent reads each record their own audit.read. Plain-field criteria translate into derived-hash equality where the column is sealed.

var rows = await b.audit.query({
  action: "consent.granted",
  from:   Date.now() - 86400000,
  limit:  100,
});
rows.length;   // → 42

b.audit.beginTrace() #

0.1.0

Mint a fresh 32-hex-char trace id apps thread through linked events via metadata.traceId. Width matches the W3C traceparent trace-id format (16 random bytes hex-encoded), so the id is interoperable with OpenTelemetry / W3C Trace Context propagation.

var traceId = b.audit.beginTrace();
await b.audit.record({
  action:   "subject.export.requested",
  outcome:  "success",
  metadata: { traceId: traceId },
});
await b.audit.record({
  action:   "subject.export.delivered",
  outcome:  "success",
  metadata: { traceId: traceId, parentEventId: "..." },
});

b.audit.checkpoint(opts) #

0.4.0soc2pci-dsssox-404
{
  skipIfUnchanged: boolean,   // null-return when tip didn't move
}

Anchor the current chain tip with a fresh post-quantum signature (the configured b.auditSign algorithm — SLH-DSA-SHAKE-256f by default, ML-DSA-87 / ML-DSA-65 optional). Inserts a row into audit_checkpoints and updates the boot-time rollback-detection sidecar (single-node) or the cluster audit-tip row (cluster mode, fencing-token guarded). Cluster mode requires the caller hold leader status — cluster.requireLeader() throws otherwise.

Returns the inserted checkpoint row, or null when the chain is empty / skipIfUnchanged and the tip hasn't advanced.

var ckpt = await b.audit.checkpoint({ skipIfUnchanged: true });
if (ckpt) {
  console.log("anchored at counter", ckpt.atMonotonicCounter);
}

b.audit.verifyCheckpoints() #

0.4.0soc2pci-dsssox-404

Walk every checkpoint and verify (a) the public-key fingerprint matches the current signing key, (b) the post-quantum signature over the payload still verifies, (c) the audit_log row at the anchored counter still has the recorded rowHash. Catches tampering that recomputed chain hashes after holding the vault key, because the off-chain signature anchor is unforgeable without the signing key.

Returns { ok: true, checkpointsVerified } on success, or { ok: false, checkpointsVerified, breakAt, checkpointId, reason } at the first break.

var result = await b.audit.verifyCheckpoints();
if (!result.ok) {
  throw new Error("audit checkpoint break at " + result.breakAt +
    ": " + result.reason);
}
result.checkpointsVerified;   // → 17

b.audit.verify(opts) #

0.1.0hipaapci-dssgdprsoc2sox-404
{
  from:  number,   // start counter (incremental verify after a known-good checkpoint)
  to:    number,   // end counter
}

Walk every audit_log row in monotonic order and recompute each rowHash against the canonicalized columns + nonce, confirming each row's prevHash matches the previous row's rowHash. Catches any insert / delete / mutation between checkpoints. Runs at boot in db.init(); operators also call it from a periodic job.

Returns { ok: true, rowsVerified } on a clean chain, or { ok: false, rowsVerified, breakAt, reason } at the first break.

var result = await b.audit.verify();
if (!result.ok) {
  console.error("audit chain break at row", result.breakAt);
  process.exit(1);
}

b.audit.emit(event) #

0.1.0

Synchronous fire-and-forget emit — events buffer in an AsyncHandler and drain serially through record(). Returns immediately; never returns a Promise. Unlike safeEmit(), emit() does NOT normalize outcome / action and does NOT redact metadata — callers pass already- shaped events. Most call sites should prefer safeEmit instead; emit is the lower-level surface the framework's own bound-actor wrapper uses.

b.audit.emit({
  actor:    { userId: "u-42" },
  action:   "system.config.reloaded",
  outcome:  "success",
  metadata: { source: "SIGHUP" },
});

b.audit.safeEmit(event) #

0.1.0hipaapci-dssgdprsoc2
{
  actor:     { userId, ip, userAgent, sessionId },
  action:    "namespace.verb[.qualifier]",
  resource:  { kind, id },
  outcome:   string,            // normalized
  reason:    string,            // redacted
  metadata:  object,            // redacted
  requestId: string,
}

Hot-path-safe fire-and-forget audit emit. Drop-silent on malformed input by design — safeEmit runs from request middleware, log-stream hooks, and finalizers where throwing on a missing action would crash the request that triggered the audit attempt. Operators who need durability guarantees call record() and await it.

Built-in normalization: action segments with hyphens become underscores ("biometric-id" → "biometric_id"); outcome aliases collapse to {success, failure, denied} ("ok" → "success", "error" → "failure", "refused" → "denied"). Actor / reason / metadata pass through b.redact.redact() so connection strings, JWTs, PEM blocks, AWS keys, and SSNs are scrubbed before they reach the chain.

b.audit.safeEmit({
  actor:    { userId: req.user && req.user.id },
  action:   "auth.login",
  outcome:  "success",
  metadata: { traceId: req.traceId, ua: req.headers["user-agent"] },
});

b.audit.namespaced(prefix, opts?) #

stable0.15.13hipaapci-dssgdprsoc2
{
  audit:  boolean,   // false disables the emitter (default on); passing a bare boolean === { audit }
  sink:   object,    // alternate audit target with a .safeEmit(event) (defaults to b.audit)
}

Build a drop-silent emitter bound to one action namespace — the shape every framework primitive hand-rolled as a private _emitAudit(action, outcome, metadata) closure (or inline) (if (!on) return; try { safeEmit({ action: "ns." + action, outcome, metadata }); } catch {}). The returned function prefixes action with prefix + ".", fills metadata with {} when omitted, and routes through safeEmit (so the same redaction + outcome normalization applies).

Every caller drives the SAME 4-argument emitter (action, outcome, metadata, extra?): extra is an object whose fields are merged onto the event, which carries the only per-emit variations seen across the framework — actor (constant { type: "system" } for an unattended worker, or a per-request ctx.actor) and resource. So a hand-rolled emitter with extra event fields is never an exception — pass them through extra. opts is the gate flag for the common case OR { audit, sink }, where sink emits to an operator-supplied audit object instead of the framework chain (the emitter is a no-op if that sink has no safeEmit, matching the hand-rolled sink guard).

A falsy prefix (null / "") builds the no-namespace variant: action passes through verbatim (no prefix + "."). This serves the primitives whose audit actions are already fully-qualified at the call site (emitAudit( "system.outbox.started", …)) — the same gated drop-silent passthrough, without re-homing the qualifier.

var emitAudit = b.audit.namespaced("gdpr.ropa", opts.audit);
emitAudit("activity_added", "success", { activityId: id });
// → safeEmit({ action: "gdpr.ropa.activity_added", outcome: "success",
//             metadata: { activityId: id } })

var emitGate = b.audit.namespaced("guardSql.gate");
emitGate("refused", "denied", { route: r }, { actor: ctx.actor });  // per-call actor

b.audit.flush() #

0.1.0

Drain the AsyncHandler buffer — every queued emit() / safeEmit() lands in the audit chain before the returned Promise resolves. Tests, graceful shutdown, and any code that needs to read audit_log immediately after emitting awaits flush().

b.audit.safeEmit({ action: "system.shutdown.requested", outcome: "success" });
await b.audit.flush();
var rows = await b.audit.query({ action: "system.shutdown.requested" });
rows.length;   // → 1

b.audit.bindActor(actorId, opts) #

0.7.0sox-404soc2
{
  roleEquivalent: function (actorId, sqlRole) -> boolean,
}

Wrap safeEmit / record so any event whose actor.userId doesn't match the bound id is refused (and an audit.actor_binding.violation event is recorded under the bound actor). When opts.roleEquivalent is provided and the caller is inside a db-role-context.runWithRole scope, the SQL-bound role and bound actor must agree per the operator-supplied mapping.

Pair with generateActorBindingTriggerSql() for SQL-side enforcement — application-layer binding catches typos; the trigger catches privileged callers bypassing the framework.

var bound = b.audit.bindActor("u-42");
bound.safeEmit({
  actor:   { userId: "u-42" },
  action:  "orders.shipped",
  outcome: "success",
});
bound.safeEmit({
  actor:   { userId: "u-other" },
  action:  "orders.shipped",
  outcome: "success",
});
// → drops + records "audit.actor_binding.violation" under u-42

b.audit.generateActorBindingTriggerSql(opts) #

0.7.0sox-404soc2
{
  column:         string,             // default "actorUserId"
  tableName:      string,             // default "_blamejs_audit_log"
  roleMappingFn:  string,             // SQL fn name mapping actor → role
  allowRoles:     string[],           // roles that bypass the check
}

Emit Postgres trigger DDL that refuses INSERTs into the audit_log table whose stored actorUserId column doesn't match the SQL session's current_user. Operators apply the returned up script via b.externalDb.migrate under sox-404 / soc2 posture so a privileged caller (operator script, migration runner) can't write audit rows under a different actor identity.

Returns { up, down, functionName, triggerName } for migration runner symmetry.

var ddl = b.audit.generateActorBindingTriggerSql({
  allowRoles: ["blamejs_service"],
});
await db.query(ddl.up);

b.audit.assertSegregation(opts) #

0.7.0sox-404soc2
{
  db:            { query(sql, params) -> { rows } },   // required
  functionName:  string,
  triggerName:   string,
}

Boot-time check that confirms the actor-binding trigger function and trigger row exist in the externalDb's pg_proc / pg_trigger catalogs. Throws AuditSegregationError with the missing artifacts named when either is absent — operators wire this into the sox-404 / soc2 boot sequence so a forgotten migration refuses-to-boot instead of silently shipping without enforcement.

await b.audit.assertSegregation({ db: externalDb });
// throws if the trigger DDL hasn't been applied

b.audit.applyPosture(posture) #

0.7.27hipaapci-dssgdprsoc2sox-404

Cascade hook called by b.compliance.set(posture) to record the active regulatory regime. The chain itself is posture-agnostic — every posture audits with the same SLH-DSA-SHAKE-256f signing key — but downstream tooling (forensic export, SIEM correlation) reads the stored posture to filter / route. Returns { posture } on accept, null on a non-string / empty argument.

b.audit.applyPosture("hipaa");
b.audit.activePosture();   // → "hipaa"

b.audit.activePosture() #

0.7.27

Return the posture string most recently passed to applyPosture(), or null if none has been set. Read-only accessor for downstream tooling that wants to tag audit-derived artifacts with the regime.

b.audit.applyPosture("pci-dss");
b.audit.activePosture();   // → "pci-dss"

Last updated 2026-08-08T16:39:49.652Z by seeder.