@basaltkit/audit 1.2.2 → 1.4.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/README.md CHANGED
@@ -117,6 +117,35 @@ await audit.trail({ since: Date.now() - 86_400_000 }) // last 24h
117
117
  await audit.trail({ limit: 50 }) // at most 50
118
118
  ```
119
119
 
120
+ #### `trail()` vs `systemTrail()` — how the tenant scope is decided
121
+
122
+ `@basaltkit/audit` is a **general-purpose** package: it works with or without
123
+ `@basaltkit/tenancy`, and `trail()` is always the everyday read. What it does
124
+ when it can't resolve a tenant depends on whether the app is multi-tenant at all
125
+ — detected through tenancy's `tenancy:active` metadata marker, not an import.
126
+
127
+ | Situation | `trail()` |
128
+ |---|---|
129
+ | Tenant in `ctx()` | **Forced** to that tenant. A caller-supplied `tenantId` is overridden, so forwarding client input can never widen the scope. |
130
+ | No context tenant, explicit `trail({ tenantId })` | Honoured — a system job or CLI pinning one tenant deliberately. |
131
+ | No context tenant, no `tenantId`, **no `tenancyPlugin`** | Returns the trail. A single-tenant app has no tenant dimension, so there is nothing to cross. |
132
+ | No context tenant, no `tenantId`, **`tenancyPlugin` registered** | **Throws.** Returning every tenant's records must be deliberate — use `systemTrail()`. |
133
+
134
+ `systemTrail(query)` is the **system-only** escape hatch: it reads across all
135
+ tenants, bypassing the auto-scoping above. Use it from trusted platform/admin
136
+ tooling only, and never pass client-controlled input into it — that re-opens
137
+ exactly the cross-tenant exposure `trail()` closes.
138
+
139
+ ```ts
140
+ // Single-tenant app (no tenancyPlugin): this is the normal read.
141
+ await audit.trail()
142
+
143
+ // Multi-tenant app: scoped automatically inside a request…
144
+ await audit.trail() // → only ctx().tenant's entries
145
+ // …and a platform-wide read is spelled out.
146
+ await audit.systemTrail({ event: 'billing:**' })
147
+ ```
148
+
120
149
  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.
121
150
 
122
151
  ### Custom store (production)
@@ -182,7 +211,7 @@ Registers an `Audit` (singleton, token `AUDIT`), hooks into **all** hooks (`hook
182
211
  | `tenantId` | `string` | No | all | Filters by tenant. |
183
212
  | `actorId` | `string` | No | all | Filters by actor. |
184
213
  | `since` | `number` | No | since forever | Only entries with `at >= since`. |
185
- | `limit` | `number` | No | no limit | Maximum number of results. |
214
+ | `limit` | `number` | No | no limit | Maximum number of results. The SQL-backed stores push it into the database, so a limited query never loads the whole trail. |
186
215
 
187
216
  ### `interface AuditStore`
188
217
 
@@ -191,10 +220,21 @@ Storage contract, **append-only by contract** (no update/delete):
191
220
  - `append(entry: AuditEntry): Promise<void>`
192
221
  - `query(query: AuditQuery): Promise<AuditEntry[]>` — must return most recent first and apply filters/limit.
193
222
 
223
+ Two helpers exist so a driver can push the limit down safely:
224
+
225
+ - `exactEventMatch(pattern?: string): string | undefined` — the event filter that may be pushed into SQL as an equality. Returns `undefined` for a pattern containing `*` (a wildcard) **or** `.` (because `patternMatches` treats `.` and `:` as interchangeable, so an equality would miss `a:b` for the pattern `a.b`); those must still be matched in code.
226
+ - `AUDIT_SCAN_PAGE: number` — rows a driver should read per round-trip when a wildcard forces a scan (500). Bounds peak memory.
227
+
194
228
  ### `class MemoryAuditStore`
195
229
 
196
230
  In-memory implementation of `AuditStore` (freezes each entry; filters and reverses on query). Ideal for dev and tests; does not persist.
197
231
 
232
+ ### Redaction
233
+
234
+ Payloads are scrubbed before they are persisted. `redactSensitive` masks values under secret-looking keys (`password`, `token`, `api_key`, `authorization`, …) as `'[redacted]'`; the opt-in `redactSensitiveAndPii` / `piiMinimizingRedactor` additionally replaces email/phone-shaped values with a stable `pii_<hash>` pseudonym.
235
+
236
+ Both walk **6 levels deep**. Anything deeper is replaced with `'[truncated]'` — not passed through. Payloads are arbitrary and the default subscription is `events: ['**']`, so returning the raw subtree meant a secret nested seven levels down reached the trail in cleartext. If your payloads are deeply nested, flatten them before recording rather than relying on depth.
237
+
198
238
  ### `patternMatches(pattern: string, name: string): boolean`
199
239
 
200
240
  Wildcard matcher over `:` and `.` segments — exported for reuse. `*` = one segment; `**` = one or more; `'**'` matches everything.
@@ -225,6 +265,9 @@ The defaults only cover `auth/billing/tenancy/permission`. Pass `hooks: [...]` w
225
265
  **I lost the history after restarting.**
226
266
  `MemoryAuditStore` is volatile. In production, implement `AuditStore` over a database.
227
267
 
268
+ **A deeply-nested field comes back as `'[truncated]'`.**
269
+ The redactors stop at 6 levels and drop everything below, so a secret can never slip past the depth bound. Flatten the payload (or record the interesting fields explicitly) if you need that data in the trail.
270
+
228
271
  **Can I edit or delete an entry?**
229
272
  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.
230
273
 
package/dist/index.d.ts CHANGED
@@ -34,6 +34,18 @@ export declare class MemoryAuditStore implements AuditStore {
34
34
  * 'order.*' matches 'order.created', '**' matches everything.
35
35
  */
36
36
  export declare function patternMatches(pattern: string, name: string): boolean;
37
+ /**
38
+ * The event filter a driver may push into SQL as an equality. A pattern with a
39
+ * wildcard must still be matched in code; so must one containing `.`, because
40
+ * {@link patternMatches} treats `.` and `:` as interchangeable separators and an
41
+ * equality would miss `a:b` for the pattern `a.b`.
42
+ */
43
+ export declare function exactEventMatch(pattern: string | undefined): string | undefined;
44
+ /**
45
+ * Rows a driver reads per round-trip when a wildcard pattern forces a scan.
46
+ * Bounds peak memory: a limited query no longer materialises the whole trail.
47
+ */
48
+ export declare const AUDIT_SCAN_PAGE = 500;
37
49
  /** Recursively masks sensitive fields so secrets/PII never reach the trail. */
38
50
  export declare function redactSensitive(value: unknown, depth?: number): unknown;
39
51
  export type AuditRedactor = (payload: unknown, event: string) => unknown;
@@ -65,26 +77,53 @@ export declare class Audit {
65
77
  private readonly store;
66
78
  /** Scrubs each payload before it is stored. Default masks common secret keys. */
67
79
  private readonly redact;
80
+ /**
81
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
82
+ * is registered. `auditPlugin` wires this to the container's
83
+ * `'tenancy:active'` metadata marker; it is a *signal*, never an import —
84
+ * `@basaltkit/audit` is a generic package and must not depend on tenancy.
85
+ *
86
+ * Defaults to `false`: a hand-built `new Audit(store)` behaves like a
87
+ * single-tenant app, which is the only thing it can safely assume.
88
+ */
89
+ private readonly tenancyActive;
68
90
  constructor(store: AuditStore,
69
91
  /** Scrubs each payload before it is stored. Default masks common secret keys. */
70
- redact?: AuditRedactor);
92
+ redact?: AuditRedactor,
93
+ /**
94
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
95
+ * is registered. `auditPlugin` wires this to the container's
96
+ * `'tenancy:active'` metadata marker; it is a *signal*, never an import —
97
+ * `@basaltkit/audit` is a generic package and must not depend on tenancy.
98
+ *
99
+ * Defaults to `false`: a hand-built `new Audit(store)` behaves like a
100
+ * single-tenant app, which is the only thing it can safely assume.
101
+ */
102
+ tenancyActive?: () => boolean);
71
103
  /** Manual entry — for actions no hook covers. */
72
104
  record(event: string, payload?: unknown): Promise<AuditEntry>;
73
105
  /** @internal used by the plugin's hook/event taps. */
74
106
  capture(source: 'hook' | 'event', event: string, payload: unknown): Promise<void>;
75
107
  /**
76
- * Reads the audit trail, **always scoped to the current tenant**.
108
+ * Reads the audit trail the everyday read.
77
109
  *
78
- * Security model (PII F2):
110
+ * Tenant scoping (PII F2), applied only where a tenant dimension exists:
79
111
  * - When a tenant is present in the ambient context, the read is FORCED to
80
112
  * that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
81
113
  * (the context tenant is spread LAST so it always wins), so a tenant-facing
82
114
  * handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
83
115
  * — can never widen the scope and read another tenant's trail.
84
- * - When there is NO tenant in context, an explicit single-tenant read
85
- * (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
86
- * returning every tenant's records must be a deliberate, system-only act via
87
- * {@link systemTrail}, never the silent default.
116
+ * - With no tenant in context, an explicit single-tenant read
117
+ * (`trail({ tenantId })`) is honoured.
118
+ * - With no tenant in context and no explicit `tenantId`, the behavior depends
119
+ * on whether the app is multi-tenant at all:
120
+ * - **Tenancy registered** (`@basaltkit/tenancy` present): the read is
121
+ * REFUSED. Returning every tenant's records must be a deliberate,
122
+ * system-only act via {@link systemTrail}, never the silent default.
123
+ * - **No tenancy** (single-tenant/non-SaaS app): there is no tenant
124
+ * dimension to scope to, so this is simply "read the trail" and returns
125
+ * the rows. `@basaltkit/audit` is a general-purpose package; it must work
126
+ * without the opt-in SaaS layer.
88
127
  */
89
128
  trail(query?: AuditQuery): Promise<AuditEntry[]>;
90
129
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { createToken, definePlugin, tryCtx } from '@basaltkit/core';
2
+ import { createToken, definePlugin, ensureMetadata, tryCtx } from '@basaltkit/core';
3
3
  import { EVENTS } from '@basaltkit/events';
4
4
  export class MemoryAuditStore {
5
5
  entries = [];
@@ -36,12 +36,37 @@ export function patternMatches(pattern, name) {
36
36
  }
37
37
  return patternSegments.length === nameSegments.length;
38
38
  }
39
+ /** How deep the redactors walk a payload before dropping the rest. */
40
+ const MAX_REDACT_DEPTH = 6;
41
+ /** Stand-in for a subtree deeper than {@link MAX_REDACT_DEPTH}. */
42
+ const TRUNCATED = '[truncated]';
43
+ /**
44
+ * The event filter a driver may push into SQL as an equality. A pattern with a
45
+ * wildcard must still be matched in code; so must one containing `.`, because
46
+ * {@link patternMatches} treats `.` and `:` as interchangeable separators and an
47
+ * equality would miss `a:b` for the pattern `a.b`.
48
+ */
49
+ export function exactEventMatch(pattern) {
50
+ if (pattern === undefined)
51
+ return undefined;
52
+ return /[*.]/.test(pattern) ? undefined : pattern;
53
+ }
54
+ /**
55
+ * Rows a driver reads per round-trip when a wildcard pattern forces a scan.
56
+ * Bounds peak memory: a limited query no longer materialises the whole trail.
57
+ */
58
+ export const AUDIT_SCAN_PAGE = 500;
39
59
  /** Object keys whose values are masked before an entry is persisted. */
40
60
  const SENSITIVE_KEY = /pass(word|wd)?|secret|token|authorization|api[-_]?key|credential|cookie|session|otp|mfa/i;
41
61
  /** Recursively masks sensitive fields so secrets/PII never reach the trail. */
42
62
  export function redactSensitive(value, depth = 0) {
43
- if (depth > 6 || value === null || typeof value !== 'object')
63
+ // Past the depth bound the subtree is dropped, NOT passed through: event
64
+ // payloads are arbitrary, and returning the raw value here let a secret nested
65
+ // deeper than the limit reach the trail in cleartext.
66
+ if (value === null || typeof value !== 'object')
44
67
  return value;
68
+ if (depth > MAX_REDACT_DEPTH)
69
+ return TRUNCATED;
45
70
  if (Array.isArray(value))
46
71
  return value.map((v) => redactSensitive(v, depth + 1));
47
72
  const out = {};
@@ -70,7 +95,7 @@ export function pseudonymize(value) {
70
95
  * entries correlatable.
71
96
  */
72
97
  export function redactSensitiveAndPii(value, depth = 0) {
73
- if (depth > 6 || value === null)
98
+ if (value === null)
74
99
  return value;
75
100
  // Bound the length before the regex: a real email is <= 254 chars (RFC 5321),
76
101
  // so only test plausibly-email-length strings — arbitrary logged values never
@@ -79,6 +104,8 @@ export function redactSensitiveAndPii(value, depth = 0) {
79
104
  return value.length <= 320 && EMAIL_VALUE.test(value) ? pseudonymize(value) : value;
80
105
  if (typeof value !== 'object')
81
106
  return value;
107
+ if (depth > MAX_REDACT_DEPTH)
108
+ return TRUNCATED;
82
109
  if (Array.isArray(value))
83
110
  return value.map((v) => redactSensitiveAndPii(v, depth + 1));
84
111
  const out = {};
@@ -105,11 +132,23 @@ export const piiMinimizingRedactor = (payload) => redactSensitiveAndPii(payload)
105
132
  export class Audit {
106
133
  store;
107
134
  redact;
135
+ tenancyActive;
108
136
  constructor(store,
109
137
  /** Scrubs each payload before it is stored. Default masks common secret keys. */
110
- redact = defaultAuditRedactor) {
138
+ redact = defaultAuditRedactor,
139
+ /**
140
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
141
+ * is registered. `auditPlugin` wires this to the container's
142
+ * `'tenancy:active'` metadata marker; it is a *signal*, never an import —
143
+ * `@basaltkit/audit` is a generic package and must not depend on tenancy.
144
+ *
145
+ * Defaults to `false`: a hand-built `new Audit(store)` behaves like a
146
+ * single-tenant app, which is the only thing it can safely assume.
147
+ */
148
+ tenancyActive = () => false) {
111
149
  this.store = store;
112
150
  this.redact = redact;
151
+ this.tenancyActive = tenancyActive;
113
152
  }
114
153
  /** Manual entry — for actions no hook covers. */
115
154
  async record(event, payload) {
@@ -122,18 +161,25 @@ export class Audit {
122
161
  await this.store.append(this.build(source, event, payload));
123
162
  }
124
163
  /**
125
- * Reads the audit trail, **always scoped to the current tenant**.
164
+ * Reads the audit trail the everyday read.
126
165
  *
127
- * Security model (PII F2):
166
+ * Tenant scoping (PII F2), applied only where a tenant dimension exists:
128
167
  * - When a tenant is present in the ambient context, the read is FORCED to
129
168
  * that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
130
169
  * (the context tenant is spread LAST so it always wins), so a tenant-facing
131
170
  * handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
132
171
  * — can never widen the scope and read another tenant's trail.
133
- * - When there is NO tenant in context, an explicit single-tenant read
134
- * (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
135
- * returning every tenant's records must be a deliberate, system-only act via
136
- * {@link systemTrail}, never the silent default.
172
+ * - With no tenant in context, an explicit single-tenant read
173
+ * (`trail({ tenantId })`) is honoured.
174
+ * - With no tenant in context and no explicit `tenantId`, the behavior depends
175
+ * on whether the app is multi-tenant at all:
176
+ * - **Tenancy registered** (`@basaltkit/tenancy` present): the read is
177
+ * REFUSED. Returning every tenant's records must be a deliberate,
178
+ * system-only act via {@link systemTrail}, never the silent default.
179
+ * - **No tenancy** (single-tenant/non-SaaS app): there is no tenant
180
+ * dimension to scope to, so this is simply "read the trail" and returns
181
+ * the rows. `@basaltkit/audit` is a general-purpose package; it must work
182
+ * without the opt-in SaaS layer.
137
183
  */
138
184
  async trail(query = {}) {
139
185
  const ctxTenantId = tryCtx()?.['tenant']?.id;
@@ -146,9 +192,14 @@ export class Audit {
146
192
  // No context, but the caller explicitly pinned a single tenant.
147
193
  return this.store.query(query);
148
194
  }
149
- // No tenant to scope to and no explicit tenant pinned: refuse to silently
150
- // return every tenant's records. Cross-tenant/system reads go through
151
- // systemTrail() so broad access is always deliberate.
195
+ if (!this.tenancyActive()) {
196
+ // Single-tenant app: no tenant dimension, so an unscoped read is correct
197
+ // and is the everyday call. Nothing to widen — every entry is "ours".
198
+ return this.store.query(query);
199
+ }
200
+ // Multi-tenant app with no tenant to scope to and no explicit tenant
201
+ // pinned: refuse to silently return every tenant's records. Cross-tenant /
202
+ // system reads go through systemTrail() so broad access is deliberate.
152
203
  throw new Error('Audit.trail() requires a tenant in context or an explicit `tenantId`. ' +
153
204
  'For a deliberate system-wide, cross-tenant read use Audit.systemTrail().');
154
205
  }
@@ -188,7 +239,14 @@ export function auditPlugin(options = {}) {
188
239
  return definePlugin({
189
240
  name: 'basalt:audit',
190
241
  register({ container, hooks }) {
191
- container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor));
242
+ // The 'tenancy:active' marker is set by tenancyPlugin. Reading it here
243
+ // (a string-keyed metadata bucket, not an import) is how a generic
244
+ // package learns the app is multi-tenant without depending on
245
+ // @basaltkit/tenancy — the same signal @basaltkit/cache uses. It is
246
+ // resolved per call, so plugin registration order does not matter.
247
+ const metadata = ensureMetadata(container);
248
+ const tenancyActive = () => metadata.get('tenancy:active').length > 0;
249
+ container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor, tenancyActive));
192
250
  hooks.onAny(async (hook, payload) => {
193
251
  if (!hookPatterns.some((pattern) => patternMatches(pattern, hook)))
194
252
  return;
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/audit",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
+ "engines": {
5
+ "node": ">=22.5.0"
6
+ },
4
7
  "description": "Append-only audit trail for Basalt: automatically records lifecycle hooks and domain events, enriched with actor/tenant/request from the context.",
5
8
  "license": "MIT",
6
9
  "type": "module",
10
+ "sideEffects": false,
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -14,8 +18,8 @@
14
18
  "dist"
15
19
  ],
16
20
  "dependencies": {
17
- "@basaltkit/events": "^1.0.1",
18
- "@basaltkit/core": "^1.1.2"
21
+ "@basaltkit/core": "^1.3.1",
22
+ "@basaltkit/events": "^1.1.1"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/node": "^26.3.0",