audit-logger: a module that records every event on the mesh
The universal consumer from ADR 0046 — no privilege, just a module that
consumes '#' and writes each event to an append-only trail. Its whole code
is on('#', record); the manifest declares consumes:['#'] and a log dir.
Records module.*, mesh.* and node.* events alike (ADR 0047's namespace).
Type-checks against @novox/mesh-sdk; the handler test records a module
event and a node event with their metadata; the manifest parses against
the consumes-enabled schema.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
// The audit handler — appends one line per event to an append-only audit log. It is the whole of
|
||||
// the module's code: an audit logger is not a privileged component, only a module that listens to
|
||||
// everything and writes it down (novox/hq ADR 0046).
|
||||
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { Event } from "@novox/mesh-sdk/events";
|
||||
|
||||
/** Where the trail is written. A directory the host applies; one file per node. */
|
||||
export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log";
|
||||
}
|
||||
|
||||
/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. */
|
||||
export async function record(event: Event, path: string): Promise<void> {
|
||||
const line =
|
||||
JSON.stringify({
|
||||
id: event.type + "@" + event.at, // a stable-ish key until x-event-id headers land (ADR 0047)
|
||||
type: event.type,
|
||||
source: event.source,
|
||||
node: event.node,
|
||||
at: event.at,
|
||||
body: event.body,
|
||||
}) + "\n";
|
||||
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
||||
await appendFile(path, line, { mode: 0o600 });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// The audit-logger's entrypoint. The per-node module runtime imports this once the broker is
|
||||
// bound; the on("#") subscription is the whole handshake — it consumes every event on the mesh
|
||||
// (module.*, mesh.*, node.*) and writes each to the trail.
|
||||
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { auditLogPath, record } from "./audit.js";
|
||||
|
||||
const path = auditLogPath();
|
||||
|
||||
await on("#", async (event) => {
|
||||
try {
|
||||
await record(event, path);
|
||||
} catch (err) {
|
||||
// A failure to write the audit trail is worth a loud line, never a swallowed one — but it must
|
||||
// not throw back into the broker and wedge the subscription.
|
||||
console.error(`[audit-logger] could not record ${event.type}: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[audit-logger] recording all mesh events to ${path}`);
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"module": "audit-logger",
|
||||
"version": "1",
|
||||
"consumes": [
|
||||
"#"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "log",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/audit-logger",
|
||||
"mode": "0700"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-audit-logger",
|
||||
"version": "0.1.0",
|
||||
"description": "audit-logger — records every event on the mesh (module, mesh and node) to an append-only trail.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { useBroker } from "@novox/mesh-sdk/messaging";
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { record } from "../audit.ts";
|
||||
|
||||
test("audit-logger records every event to the trail as one line each", async () => {
|
||||
const broker = memBroker();
|
||||
useBroker(() => broker);
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-"));
|
||||
const path = join(dir, "audit.log");
|
||||
|
||||
// The audit-logger's whole behaviour: consume everything, record it.
|
||||
await on("#", async (event) => record(event, path));
|
||||
|
||||
process.env.MESH_MODULE = "umami";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
await emit("module.umami.site.created", { domain: "my-app" });
|
||||
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one
|
||||
|
||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]);
|
||||
assert.equal(lines[0].source, "umami");
|
||||
assert.equal(lines[0].node, "anchor");
|
||||
assert.equal(lines[0].body.domain, "my-app");
|
||||
|
||||
delete process.env.MESH_MODULE;
|
||||
delete process.env.MESH_NODE;
|
||||
});
|
||||
|
||||
function memBroker() {
|
||||
const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise<void> }[] = [];
|
||||
return {
|
||||
async request() {
|
||||
throw new Error("unused");
|
||||
},
|
||||
async handle() {
|
||||
return () => {};
|
||||
},
|
||||
async publish<T>(env: { key: string; node: string; body: T }): Promise<void> {
|
||||
for (const s of subs) if (match(s.pattern, env.key)) await s.handler(env);
|
||||
},
|
||||
async subscribe<T>(pattern: string, handler: (env: { key: string; node: string; body: T }) => Promise<void>): Promise<() => void> {
|
||||
subs.push({ pattern, handler: handler as (env: { key: string; node: string; body: unknown }) => Promise<void> });
|
||||
return () => {};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
}
|
||||
|
||||
function match(pattern: string, key: string): boolean {
|
||||
return pattern === "#" || pattern === key;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["audit.ts", "index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user