@basaltkit/audit-viewer 1.1.0 → 1.3.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
@@ -65,21 +65,29 @@ auditViewerRoutes({ title: 'Audit — Acme', apiBase: '/admin' })
65
65
 
66
66
  ## API reference
67
67
 
68
- ### `auditViewerPlugin({ bucketMs?, topN? })`
68
+ ### `auditViewerPlugin({ bucketMs?, topN?, maxScan? })`
69
69
 
70
- Registers the `AUDIT_VIEWER` token. `bucketMs` is the timeline bucket size (default 1 day); `topN` limits the per-event/actor tables (default 20).
70
+ Registers the `AUDIT_VIEWER` token.
71
+
72
+ | Option | Type | Default | Purpose |
73
+ |---|---|---|---|
74
+ | `bucketMs` | `number` | `86_400_000` (1 day) | Timeline bucket size. |
75
+ | `topN` | `number` | `20` | Rows returned by the per-event / per-actor breakdowns. |
76
+ | `maxScan` | `number` | `10_000` | Upper bound on rows read from the store per call. |
77
+
78
+ `maxScan` exists because the trail is unbounded and these routes forward client input: an unbounded read is an OOM vector. When a call hits the bound, the result carries `truncated: true` and `total` means "matches within the window", not a grand total.
71
79
 
72
80
  ### `class AuditViewer`
73
81
 
74
82
  | Method | Description |
75
83
  |---|---|
76
- | `page(query)` | `{ entries, total, limit, offset }`. |
77
- | `stats(query)` | `{ total, byEvent, byActor, bySource, timeline }`. |
84
+ | `page(query)` | `{ entries, total, limit, offset, truncated }`. |
85
+ | `stats(query)` | `{ total, truncated, byEvent, byActor, bySource, timeline }`. |
78
86
  | `get(id, tenantId?)` | A single entry, or `null`. |
79
87
 
80
88
  `ViewerQuery`: `event` (wildcard), `actorId`, `tenantId`, `source` (`hook`/`event`/`manual`), `since`, `until`, `limit`, `offset`. Without `tenantId`, it uses `ctx().tenant.id` (otherwise `AuditTenantRequiredError`).
81
89
 
82
- > Note: the extra filtering (source/until) and the aggregation happen in memory over the result of `Audit.trail`. For very large trails, use an `AuditStore` with rich database querying.
90
+ > Note: the extra filtering (source/until) and the aggregation happen in memory over the result of `Audit.trail`, bounded by `maxScan`. `truncated: true` means the trail had more matches than the window — raise `maxScan`, narrow the query (`since`/`until`/`event`), or use an `AuditStore` with richer database querying.
83
91
 
84
92
  ## Content-Security-Policy
85
93
 
package/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createToken, ctx, definePlugin } from '@basaltkit/core';
1
+ import { createToken, ctx, definePlugin, ensureMetadata } from '@basaltkit/core';
2
2
  import { AUDIT } from '@basaltkit/audit';
3
3
  import { route } from '@basaltkit/http';
4
4
  import { z } from 'zod';
@@ -9,7 +9,10 @@ export function auditViewerPlugin(options = {}) {
9
9
  return definePlugin({
10
10
  name: 'basalt:audit-viewer',
11
11
  register({ container }) {
12
- container.singleton(AUDIT_VIEWER, () => new AuditViewer(container.get(AUDIT), options));
12
+ // 'tenancy:active' is tenancyPlugin's marker: how a generic package
13
+ // learns the app is multi-tenant without importing @basaltkit/tenancy.
14
+ const metadata = ensureMetadata(container);
15
+ container.singleton(AUDIT_VIEWER, () => new AuditViewer(container.get(AUDIT), options, () => metadata.get('tenancy:active').length > 0));
13
16
  },
14
17
  });
15
18
  }
package/dist/viewer.d.ts CHANGED
@@ -18,12 +18,18 @@ export interface ViewerQuery {
18
18
  }
19
19
  export interface AuditPage {
20
20
  entries: AuditEntry[];
21
+ /** Matches found within the scan window — see `truncated`. */
21
22
  total: number;
22
23
  limit: number;
23
24
  offset: number;
25
+ /** True when the scan hit `maxScan`: there are more matches than `total` reports. */
26
+ truncated: boolean;
24
27
  }
25
28
  export interface AuditStats {
29
+ /** Entries counted within the scan window — see `truncated`. */
26
30
  total: number;
31
+ /** True when the scan hit `maxScan`: the aggregates cover only the newest rows. */
32
+ truncated: boolean;
27
33
  byEvent: {
28
34
  event: string;
29
35
  count: number;
@@ -44,6 +50,12 @@ export interface AuditViewerOptions {
44
50
  bucketMs?: number;
45
51
  /** How many rows the byEvent/byActor breakdowns return. Default 20. */
46
52
  topN?: number;
53
+ /**
54
+ * Upper bound on rows read from the store per call. The trail is unbounded, so
55
+ * an unbounded read is an OOM vector on any endpoint that forwards client input.
56
+ * Results past the bound are reported via `truncated`. Default 10 000.
57
+ */
58
+ maxScan?: number;
47
59
  }
48
60
  /**
49
61
  * Read-only lens over the append-only audit trail: tenant-scoped, filterable,
@@ -52,14 +64,44 @@ export interface AuditViewerOptions {
52
64
  */
53
65
  export declare class AuditViewer {
54
66
  private readonly audit;
67
+ /**
68
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
69
+ * is registered. `auditViewerPlugin` wires this to the container's
70
+ * `'tenancy:active'` metadata marker — a signal, not an import, so this
71
+ * generic package never depends on the opt-in SaaS layer.
72
+ *
73
+ * Defaults to `false`: a hand-built viewer behaves single-tenant.
74
+ */
75
+ private readonly tenancyActive;
55
76
  private readonly bucketMs;
56
77
  private readonly topN;
57
- constructor(audit: Audit, options?: AuditViewerOptions);
78
+ private readonly maxScan;
79
+ constructor(audit: Audit, options?: AuditViewerOptions,
80
+ /**
81
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
82
+ * is registered. `auditViewerPlugin` wires this to the container's
83
+ * `'tenancy:active'` metadata marker — a signal, not an import, so this
84
+ * generic package never depends on the opt-in SaaS layer.
85
+ *
86
+ * Defaults to `false`: a hand-built viewer behaves single-tenant.
87
+ */
88
+ tenancyActive?: () => boolean);
58
89
  page(query?: ViewerQuery): Promise<AuditPage>;
59
90
  get(id: string, tenantId?: string): Promise<AuditEntry | null>;
60
91
  stats(query?: ViewerQuery): Promise<AuditStats>;
61
92
  /** Matching entries (newest first), after the extra source/until filters. */
93
+ /** Reads at most `maxScan` rows and reports whether the trail had more. */
62
94
  private match;
63
95
  private top;
96
+ /**
97
+ * The tenant to scope a read to, or `undefined` when the app has no tenant
98
+ * dimension at all.
99
+ *
100
+ * In a multi-tenant app an unresolvable tenant is an error — an unscoped read
101
+ * would cross tenants. In a single-tenant app (no `tenancyPlugin`) there is
102
+ * nothing to scope to and nothing to cross, so the read proceeds unscoped;
103
+ * `Audit.trail()` applies the same rule one layer down and still forces the
104
+ * ambient tenant whenever one exists.
105
+ */
64
106
  private tenant;
65
107
  }
package/dist/viewer.js CHANGED
@@ -6,6 +6,7 @@ export class AuditTenantRequiredError extends BasaltError {
6
6
  }
7
7
  }
8
8
  const DAY = 86_400_000;
9
+ const DEFAULT_MAX_SCAN = 10_000;
9
10
  /**
10
11
  * Read-only lens over the append-only audit trail: tenant-scoped, filterable,
11
12
  * paginated queries plus aggregate stats. Wraps {@link Audit}; the trail itself
@@ -13,25 +14,38 @@ const DAY = 86_400_000;
13
14
  */
14
15
  export class AuditViewer {
15
16
  audit;
17
+ tenancyActive;
16
18
  bucketMs;
17
19
  topN;
18
- constructor(audit, options = {}) {
20
+ maxScan;
21
+ constructor(audit, options = {},
22
+ /**
23
+ * Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
24
+ * is registered. `auditViewerPlugin` wires this to the container's
25
+ * `'tenancy:active'` metadata marker — a signal, not an import, so this
26
+ * generic package never depends on the opt-in SaaS layer.
27
+ *
28
+ * Defaults to `false`: a hand-built viewer behaves single-tenant.
29
+ */
30
+ tenancyActive = () => false) {
19
31
  this.audit = audit;
32
+ this.tenancyActive = tenancyActive;
20
33
  this.bucketMs = options.bucketMs ?? DAY;
21
34
  this.topN = options.topN ?? 20;
35
+ this.maxScan = options.maxScan ?? DEFAULT_MAX_SCAN;
22
36
  }
23
37
  async page(query = {}) {
24
- const all = await this.match(query);
38
+ const { entries: all, truncated } = await this.match(query);
25
39
  const limit = query.limit ?? 50;
26
40
  const offset = query.offset ?? 0;
27
- return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset };
41
+ return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset, truncated };
28
42
  }
29
43
  async get(id, tenantId) {
30
- const all = await this.match({ ...(tenantId !== undefined ? { tenantId } : {}) });
31
- return all.find((entry) => entry.id === id) ?? null;
44
+ const { entries } = await this.match({ ...(tenantId !== undefined ? { tenantId } : {}) });
45
+ return entries.find((entry) => entry.id === id) ?? null;
32
46
  }
33
47
  async stats(query = {}) {
34
- const all = await this.match(query);
48
+ const { entries: all, truncated } = await this.match(query);
35
49
  const byEvent = new Map();
36
50
  const byActor = new Map();
37
51
  const bySource = {};
@@ -46,6 +60,7 @@ export class AuditViewer {
46
60
  }
47
61
  return {
48
62
  total: all.length,
63
+ truncated,
49
64
  byEvent: this.top(byEvent).map(([event, count]) => ({ event, count })),
50
65
  byActor: this.top(byActor).map(([actorId, count]) => ({ actorId, count })),
51
66
  bySource,
@@ -53,24 +68,39 @@ export class AuditViewer {
53
68
  };
54
69
  }
55
70
  /** Matching entries (newest first), after the extra source/until filters. */
71
+ /** Reads at most `maxScan` rows and reports whether the trail had more. */
56
72
  async match(query) {
57
73
  const tenantId = this.tenant(query.tenantId);
58
74
  const trail = await this.audit.trail({
59
- tenantId,
75
+ ...(tenantId !== undefined ? { tenantId } : {}),
76
+ limit: this.maxScan,
60
77
  ...(query.event !== undefined ? { event: query.event } : {}),
61
78
  ...(query.actorId !== undefined ? { actorId: query.actorId } : {}),
62
79
  ...(query.since !== undefined ? { since: query.since } : {}),
63
80
  });
64
- return trail.filter((entry) => (query.source === undefined || entry.source === query.source) &&
81
+ const entries = trail.filter((entry) => (query.source === undefined || entry.source === query.source) &&
65
82
  (query.until === undefined || entry.at <= query.until));
83
+ return { entries, truncated: trail.length >= this.maxScan };
66
84
  }
67
85
  top(counts) {
68
86
  return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, this.topN);
69
87
  }
88
+ /**
89
+ * The tenant to scope a read to, or `undefined` when the app has no tenant
90
+ * dimension at all.
91
+ *
92
+ * In a multi-tenant app an unresolvable tenant is an error — an unscoped read
93
+ * would cross tenants. In a single-tenant app (no `tenancyPlugin`) there is
94
+ * nothing to scope to and nothing to cross, so the read proceeds unscoped;
95
+ * `Audit.trail()` applies the same rule one layer down and still forces the
96
+ * ambient tenant whenever one exists.
97
+ */
70
98
  tenant(explicit) {
71
99
  const id = explicit ?? tryCtx()?.['tenant']?.id;
72
- if (!id)
100
+ if (id)
101
+ return id;
102
+ if (this.tenancyActive())
73
103
  throw new AuditTenantRequiredError();
74
- return id;
104
+ return undefined;
75
105
  }
76
106
  }
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/audit-viewer",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
+ "engines": {
5
+ "node": ">=22.5.0"
6
+ },
4
7
  "description": "Read-only viewer for the @basaltkit/audit trail: tenant-scoped, filterable, paginated queries with aggregate stats, plus a self-contained HTML browser.",
5
8
  "license": "MIT",
6
9
  "type": "module",
10
+ "sideEffects": false,
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -14,9 +18,9 @@
14
18
  "dist"
15
19
  ],
16
20
  "dependencies": {
17
- "@basaltkit/audit": "^1.2.2",
18
- "@basaltkit/core": "^1.3.0",
19
- "@basaltkit/http": "^1.11.0"
21
+ "@basaltkit/audit": "^1.4.0",
22
+ "@basaltkit/core": "^1.3.1",
23
+ "@basaltkit/http": "^1.14.0"
20
24
  },
21
25
  "peerDependencies": {
22
26
  "zod": "^3.24.0 || ^4.0.0"
@@ -26,9 +30,9 @@
26
30
  "typescript": "^7.0.2",
27
31
  "vitest": "^4.1.11",
28
32
  "zod": "^3.24.0 || ^4.0.0",
29
- "@basaltkit/auth": "^1.6.3",
30
- "@basaltkit/fastify": "^1.7.0",
31
- "@basaltkit/tenancy": "^1.4.0",
33
+ "@basaltkit/auth": "^1.8.0",
34
+ "@basaltkit/fastify": "^1.8.1",
35
+ "@basaltkit/tenancy": "^1.4.2",
32
36
  "@basaltkit/tsconfig": "^0.24.0"
33
37
  },
34
38
  "publishConfig": {