@basaltkit/audit 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @basaltkit/audit
2
+
3
+ Audit trail for Basalt applications: automatically records, in an immutable history, who did what and when — from lifecycle hooks, domain events, and manual records.
4
+
5
+ You need this module when you have to be able to answer questions like "who logged into this account?" or "who changed this billing plan?" — for security, support, or compliance reasons.
6
+
7
+ ---
8
+
9
+ ## What this module solves
10
+
11
+ **Auditing** is the systematic recording of relevant actions in a system: logins, billing changes, permission changes. Unlike technical logs (which are for developers and can be deleted), the audit trail is a business record: **append-only** (only added to, never altered or deleted) and enriched with the **actor** (who did it), the **tenant** (which organization it belongs to), and the **request** (requestId) — all captured automatically from the active context at record time.
12
+
13
+ The tedious part of auditing is remembering to record everywhere. This module solves that by hooking into what the application already emits: the `@basaltkit/core` lifecycle **hooks** (e.g. `auth:login`, `billing:subscribed`) and the `@basaltkit/events` **domain events** (e.g. `order.created`). You choose what gets recorded using wildcard patterns — by default, all `auth`, `billing`, `tenancy`, and `permission` activity (hooks) and **all** events.
14
+
15
+ Each entry is frozen (`Object.freeze`) — code can't tamper with the in-memory history, even by accident. To query, use `audit.trail()` with filters on event (with wildcards), tenant, actor, and date.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pnpm add @basaltkit/audit
21
+ ```
22
+
23
+ Depends on `@basaltkit/core` and `@basaltkit/events`. The default storage is in-memory (`MemoryAuditStore`) — for production you should provide a persistent `AuditStore` (see "Custom store").
24
+
25
+ ## Get started in 5 minutes
26
+
27
+ **1. Register the plugin (along with the events plugin, if you use it):**
28
+
29
+ ```ts
30
+ import { createApp } from '@basaltkit/core'
31
+ import { eventsPlugin } from '@basaltkit/events'
32
+ import { AUDIT, auditPlugin } from '@basaltkit/audit'
33
+
34
+ const app = await createApp({
35
+ plugins: [eventsPlugin(), auditPlugin()],
36
+ }).boot()
37
+ ```
38
+
39
+ **2. From here on, relevant hooks and events get recorded on their own.** For example, when the auth module emits the `auth:login` hook, an audit entry is created with the actor and tenant from context.
40
+
41
+ **3. Query the trail:**
42
+
43
+ ```ts
44
+ const audit = app.container.get(AUDIT)
45
+
46
+ const trail = await audit.trail() // everything, most recent first
47
+ const logins = await audit.trail({ event: 'auth:**' })
48
+ console.log(logins[0])
49
+ // {
50
+ // id: '4f1c…', source: 'hook', event: 'auth:login',
51
+ // payload: { user: { id: 'u1', email: 'a@b.c' } },
52
+ // actorId: 'u1', tenantId: 'acme', requestId: 'req-1', at: 1754500000000
53
+ // }
54
+ ```
55
+
56
+ **4. Manually record what hooks don't cover:**
57
+
58
+ ```ts
59
+ await audit.record('data.export', { format: 'csv' })
60
+ ```
61
+
62
+ ## Usage guide
63
+
64
+ ### Automatic hook capture
65
+
66
+ By default, hooks matching `auth:**`, `billing:**`, `tenancy:**`, or `permission:**` are recorded. You can replace the list:
67
+
68
+ ```ts
69
+ import { auditPlugin } from '@basaltkit/audit'
70
+
71
+ auditPlugin({
72
+ hooks: ['auth:**', 'billing:**', 'api-keys:**'], // replaces the defaults
73
+ })
74
+ ```
75
+
76
+ Enrichment comes from the active context: `ctx().user.id` → `actorId`, `ctx().tenant.id` → `tenantId`, `ctx().requestId` → `requestId`.
77
+
78
+ ### Automatic domain event capture
79
+
80
+ If the container has an `EventBus` (`@basaltkit/events` registered), the plugin subscribes to `**` and records events that match the patterns. By default it records **everything**; you can narrow or disable it:
81
+
82
+ ```ts
83
+ auditPlugin({ events: ['order.**', 'invoice.**'] }) // only these
84
+ auditPlugin({ events: [] }) // disable event capture
85
+ ```
86
+
87
+ ### Manual records
88
+
89
+ For actions that no hook/event covers:
90
+
91
+ ```ts
92
+ import { runWithContext } from '@basaltkit/core'
93
+
94
+ await runWithContext({ user: { id: 'u1' }, tenant: { id: 'acme' } }, async () => {
95
+ const entry = await audit.record('data.export', { format: 'csv' })
96
+ // entry.source === 'manual', entry.actorId === 'u1', entry.tenantId === 'acme'
97
+ })
98
+ ```
99
+
100
+ `record` returns the created entry (already frozen).
101
+
102
+ ### Querying the trail
103
+
104
+ `trail(query)` returns entries **most recent first**:
105
+
106
+ ```ts
107
+ await audit.trail({ event: 'auth:**' }) // wildcard over the name
108
+ await audit.trail({ tenantId: 'acme' }) // only for one tenant
109
+ await audit.trail({ actorId: 'u1' }) // only for one user
110
+ await audit.trail({ since: Date.now() - 86_400_000 }) // last 24h
111
+ await audit.trail({ limit: 50 }) // at most 50
112
+ ```
113
+
114
+ Event patterns support segments separated by `:` (hooks) or `.` (events): `*` matches one segment, `**` matches one or more. E.g.: `auth:*` matches `auth:login`; `order.**` matches `order.created` and `order.item.added`; `**` matches everything.
115
+
116
+ ### Custom store (production)
117
+
118
+ `MemoryAuditStore` loses everything when the process ends. In production, implement `AuditStore` over your database — the contract is append-only (no update or delete):
119
+
120
+ ```ts
121
+ import type { AuditEntry, AuditQuery, AuditStore } from '@basaltkit/audit'
122
+ import { auditPlugin } from '@basaltkit/audit'
123
+
124
+ class SqlAuditStore implements AuditStore {
125
+ async append(entry: AuditEntry): Promise<void> {
126
+ // INSERT into the audit_entries table…
127
+ }
128
+ async query(query: AuditQuery): Promise<AuditEntry[]> {
129
+ // SELECT with filters, ORDER BY at DESC, LIMIT…
130
+ return []
131
+ }
132
+ }
133
+
134
+ auditPlugin({ store: new SqlAuditStore() })
135
+ ```
136
+
137
+ ## API reference
138
+
139
+ ### `auditPlugin(options?: AuditPluginOptions)`
140
+
141
+ Registers an `Audit` (singleton, token `AUDIT`), hooks into **all** hooks (`hooks.onAny`) filtering by the patterns, and on `boot` subscribes to the `EventBus` (if present in the container) to record events.
142
+
143
+ | Option | Type | Required? | Default | Description |
144
+ |---|---|---|---|---|
145
+ | `store` | `AuditStore` | No | `new MemoryAuditStore()` | Where entries are stored. |
146
+ | `hooks` | `string[]` | No | `['auth:**', 'billing:**', 'tenancy:**', 'permission:**']` | Hook patterns recorded automatically (replaces the defaults). |
147
+ | `events` | `string[]` | No | `['**']` (everything) | EventBus event patterns recorded. `[]` disables it. |
148
+
149
+ ### `class Audit`
150
+
151
+ | Method | Signature | Description |
152
+ |---|---|---|
153
+ | `constructor` | `new Audit(store: AuditStore)` | Creates the facade over a store. |
154
+ | `record` | `(event: string, payload?: unknown) => Promise<AuditEntry>` | Manual entry (`source: 'manual'`), enriched from context. Returns the entry. |
155
+ | `trail` | `(query?: AuditQuery) => Promise<AuditEntry[]>` | Query, most recent first. |
156
+ | `capture` | `(source: 'hook' \| 'event', event, payload) => Promise<void>` | **Advanced/internal**: used by the plugin's listeners. |
157
+
158
+ ### `interface AuditEntry` (all fields `readonly`)
159
+
160
+ | Field | Type | Description |
161
+ |---|---|---|
162
+ | `id` | `string` | UUID generated at record time. |
163
+ | `source` | `'hook' \| 'event' \| 'manual'` | Origin of the entry. |
164
+ | `event` | `string` | Name of the hook/event/action. |
165
+ | `payload` | `unknown` | Associated data. |
166
+ | `actorId` | `string \| undefined` | `ctx().user.id` at record time. |
167
+ | `tenantId` | `string \| undefined` | `ctx().tenant.id` at record time. |
168
+ | `requestId` | `string \| undefined` | `ctx().requestId`. |
169
+ | `at` | `number` | Timestamp (`Date.now()`, milliseconds). |
170
+
171
+ ### `interface AuditQuery`
172
+
173
+ | Field | Type | Required? | Default | Description |
174
+ |---|---|---|---|---|
175
+ | `event` | `string` | No | all | Wildcard pattern over the name (e.g. `'auth:**'`). |
176
+ | `tenantId` | `string` | No | all | Filters by tenant. |
177
+ | `actorId` | `string` | No | all | Filters by actor. |
178
+ | `since` | `number` | No | since forever | Only entries with `at >= since`. |
179
+ | `limit` | `number` | No | no limit | Maximum number of results. |
180
+
181
+ ### `interface AuditStore`
182
+
183
+ Storage contract, **append-only by contract** (no update/delete):
184
+
185
+ - `append(entry: AuditEntry): Promise<void>`
186
+ - `query(query: AuditQuery): Promise<AuditEntry[]>` — must return most recent first and apply filters/limit.
187
+
188
+ ### `class MemoryAuditStore`
189
+
190
+ In-memory implementation of `AuditStore` (freezes each entry; filters and reverses on query). Ideal for dev and tests; does not persist.
191
+
192
+ ### `patternMatches(pattern: string, name: string): boolean`
193
+
194
+ Wildcard matcher over `:` and `.` segments — exported for reuse. `*` = one segment; `**` = one or more; `'**'` matches everything.
195
+
196
+ ```ts
197
+ import { patternMatches } from '@basaltkit/audit'
198
+
199
+ patternMatches('auth:**', 'auth:login') // true
200
+ patternMatches('order.*', 'order.created') // true
201
+ patternMatches('auth:**', 'billing:paid') // false
202
+ ```
203
+
204
+ ### Token
205
+
206
+ - `AUDIT: Token<Audit>` — `app.container.get(AUDIT)`.
207
+
208
+ ## Common errors and solutions (FAQ)
209
+
210
+ **Entries come back with empty `actorId`/`tenantId`.**
211
+ There was no active context at record time. Make sure the code runs inside `runWithContext({ user, tenant }, …)` — in HTTP, this is established by the middleware.
212
+
213
+ **Domain events aren't being recorded.**
214
+ Either `eventsPlugin()` isn't registered (`auditPlugin` only subscribes to the bus if `container.has(EVENTS)`), or you passed `events: []`, or the patterns don't match the event names.
215
+
216
+ **One of my hooks doesn't show up in the trail.**
217
+ The defaults only cover `auth/billing/tenancy/permission`. Pass `hooks: [...]` with your own patterns — note that the list **replaces** the defaults, so include the ones you want to keep too.
218
+
219
+ **I lost the history after restarting.**
220
+ `MemoryAuditStore` is volatile. In production, implement `AuditStore` over a database.
221
+
222
+ **Can I edit or delete an entry?**
223
+ No — the contract is append-only and entries are frozen. This is a feature, not a limitation: it's what gives the trail evidentiary value.
224
+
225
+ **What's the difference between `:` and `.` in names?**
226
+ Convention: lifecycle hooks use `:` (`auth:login`); domain events use `.` (`order.created`). `patternMatches` treats both as segment separators.
227
+
228
+ ## How it connects to other modules
229
+
230
+ - **`@basaltkit/core`** — lifecycle hooks (`hooks.onAny`) are the primary capture source; the ALS context (`tryCtx`) supplies actor/tenant/requestId; the plugin uses `definePlugin`/`createToken`.
231
+ - **`@basaltkit/events`** — secondary capture source: any domain event emitted on the `EventBus` can land in the trail (`events` patterns).
232
+ - **`@basaltkit/activity`** — sibling module with a different focus: **activity** is the "human-friendly" feed shown to the user ("Maria published the project"); **audit** is the automatic, immutable security/compliance record.
233
+ - **`@basaltkit/logger`** — logs are ephemeral technical diagnostics; audit is durable business record. Use both.
234
+ - **`@basaltkit/queue`** — since context travels to workers, entries recorded inside a job retain the actor/tenant of the original request.
@@ -0,0 +1,65 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+
3
+ /** One immutable line of the trail. */
4
+ interface AuditEntry {
5
+ readonly id: string;
6
+ /** Where it came from: a lifecycle hook, a domain event or a manual record. */
7
+ readonly source: 'hook' | 'event' | 'manual';
8
+ readonly event: string;
9
+ readonly payload: unknown;
10
+ /** Enriched from the ALS context at record time. */
11
+ readonly actorId?: string | undefined;
12
+ readonly tenantId?: string | undefined;
13
+ readonly requestId?: string | undefined;
14
+ readonly at: number;
15
+ }
16
+ interface AuditQuery {
17
+ /** Wildcard pattern over the event name (e.g. 'auth:**'). */
18
+ event?: string;
19
+ tenantId?: string;
20
+ actorId?: string;
21
+ since?: number;
22
+ limit?: number;
23
+ }
24
+ /** Append-only by contract: no update, no delete. */
25
+ interface AuditStore {
26
+ append(entry: AuditEntry): Promise<void>;
27
+ query(query: AuditQuery): Promise<AuditEntry[]>;
28
+ }
29
+ declare class MemoryAuditStore implements AuditStore {
30
+ private readonly entries;
31
+ append(entry: AuditEntry): Promise<void>;
32
+ query(query: AuditQuery): Promise<AuditEntry[]>;
33
+ }
34
+ /**
35
+ * Wildcard matcher over ':' and '.' segments: 'auth:**' matches 'auth:login',
36
+ * 'order.*' matches 'order.created', '**' matches everything.
37
+ */
38
+ declare function patternMatches(pattern: string, name: string): boolean;
39
+ declare class Audit {
40
+ private readonly store;
41
+ constructor(store: AuditStore);
42
+ /** Manual entry — for actions no hook covers. */
43
+ record(event: string, payload?: unknown): Promise<AuditEntry>;
44
+ /** @internal used by the plugin's hook/event taps. */
45
+ capture(source: 'hook' | 'event', event: string, payload: unknown): Promise<void>;
46
+ trail(query?: AuditQuery): Promise<AuditEntry[]>;
47
+ private build;
48
+ }
49
+ declare const AUDIT: _basaltkit_core.Token<Audit>;
50
+ interface AuditPluginOptions {
51
+ store?: AuditStore;
52
+ /**
53
+ * Lifecycle hook patterns to record automatically.
54
+ * Default: auth, billing, tenancy and permission activity.
55
+ */
56
+ hooks?: string[];
57
+ /**
58
+ * Domain event patterns recorded from the EventBus (when present).
59
+ * Default: everything. Pass [] to disable.
60
+ */
61
+ events?: string[];
62
+ }
63
+ declare function auditPlugin(options?: AuditPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
64
+
65
+ export { AUDIT, Audit, type AuditEntry, type AuditPluginOptions, type AuditQuery, type AuditStore, MemoryAuditStore, auditPlugin, patternMatches };
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ // src/index.ts
2
+ import { randomUUID } from "crypto";
3
+ import { createToken, definePlugin, tryCtx } from "@basaltkit/core";
4
+ import { EVENTS } from "@basaltkit/events";
5
+ var MemoryAuditStore = class {
6
+ entries = [];
7
+ async append(entry) {
8
+ this.entries.push(Object.freeze({ ...entry }));
9
+ }
10
+ async query(query) {
11
+ let results = this.entries.filter(
12
+ (entry) => (query.event === void 0 || patternMatches(query.event, entry.event)) && (query.tenantId === void 0 || entry.tenantId === query.tenantId) && (query.actorId === void 0 || entry.actorId === query.actorId) && (query.since === void 0 || entry.at >= query.since)
13
+ );
14
+ results = [...results].reverse();
15
+ return query.limit !== void 0 ? results.slice(0, query.limit) : results;
16
+ }
17
+ };
18
+ function patternMatches(pattern, name) {
19
+ if (pattern === name || pattern === "**") return true;
20
+ const split = (value) => value.split(/[.:]/);
21
+ const patternSegments = split(pattern);
22
+ const nameSegments = split(name);
23
+ for (let i = 0; i < patternSegments.length; i++) {
24
+ const segment = patternSegments[i];
25
+ if (segment === "**") return i < nameSegments.length;
26
+ if (i >= nameSegments.length) return false;
27
+ if (segment !== "*" && segment !== nameSegments[i]) return false;
28
+ }
29
+ return patternSegments.length === nameSegments.length;
30
+ }
31
+ var Audit = class {
32
+ constructor(store) {
33
+ this.store = store;
34
+ }
35
+ store;
36
+ /** Manual entry — for actions no hook covers. */
37
+ async record(event, payload) {
38
+ const entry = this.build("manual", event, payload);
39
+ await this.store.append(entry);
40
+ return entry;
41
+ }
42
+ /** @internal used by the plugin's hook/event taps. */
43
+ async capture(source, event, payload) {
44
+ await this.store.append(this.build(source, event, payload));
45
+ }
46
+ async trail(query = {}) {
47
+ return this.store.query(query);
48
+ }
49
+ build(source, event, payload) {
50
+ const context = tryCtx();
51
+ const user = context?.["user"];
52
+ const tenant = context?.["tenant"];
53
+ return Object.freeze({
54
+ id: randomUUID(),
55
+ source,
56
+ event,
57
+ payload,
58
+ actorId: user?.id,
59
+ tenantId: tenant?.id,
60
+ requestId: context?.requestId,
61
+ at: Date.now()
62
+ });
63
+ }
64
+ };
65
+ var AUDIT = createToken("audit");
66
+ var DEFAULT_HOOK_PATTERNS = ["auth:**", "billing:**", "tenancy:**", "permission:**"];
67
+ function auditPlugin(options = {}) {
68
+ const hookPatterns = options.hooks ?? DEFAULT_HOOK_PATTERNS;
69
+ const eventPatterns = options.events ?? ["**"];
70
+ return definePlugin({
71
+ name: "basalt:audit",
72
+ register({ container, hooks }) {
73
+ container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore()));
74
+ hooks.onAny(async (hook, payload) => {
75
+ if (!hookPatterns.some((pattern) => patternMatches(pattern, hook))) return;
76
+ await container.get(AUDIT).capture("hook", hook, payload);
77
+ });
78
+ },
79
+ boot({ container }) {
80
+ if (eventPatterns.length === 0 || !container.has(EVENTS)) return;
81
+ const bus = container.get(EVENTS);
82
+ bus.on("**", async (payload, meta) => {
83
+ if (!eventPatterns.some((pattern) => patternMatches(pattern, meta.name))) return;
84
+ await container.get(AUDIT).capture("event", meta.name, payload);
85
+ });
86
+ }
87
+ });
88
+ }
89
+ export {
90
+ AUDIT,
91
+ Audit,
92
+ MemoryAuditStore,
93
+ auditPlugin,
94
+ patternMatches
95
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@basaltkit/audit",
3
+ "version": "1.0.0",
4
+ "description": "Append-only audit trail for Basalt: automatically records lifecycle hooks and domain events, enriched with actor/tenant/request from the context.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "@basaltkit/core": "^1.0.0",
18
+ "@basaltkit/events": "^1.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.15.0",
22
+ "tsup": "^8.4.0",
23
+ "typescript": "^5.8.0",
24
+ "vitest": "^3.1.0",
25
+ "zod": "^3.24.0",
26
+ "@basaltkit/tsconfig": "^0.24.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Zebedeu/basalt.git",
34
+ "directory": "packages/audit"
35
+ },
36
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/audit#readme",
37
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
38
+ "keywords": [
39
+ "basalt",
40
+ "typescript",
41
+ "saas",
42
+ "audit",
43
+ "audit-log"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm --dts --clean",
47
+ "test": "vitest run",
48
+ "typecheck": "tsc --noEmit"
49
+ }
50
+ }