@basaltkit/audit-viewer 1.0.2 → 1.2.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,39 @@ 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.
91
+
92
+ ## Content-Security-Policy
93
+
94
+ The route sets a route-scoped CSP by default: everything locked down and the
95
+ page's inline script allowed only by sha256 hash (exported as `auditViewerCsp`). It
96
+ works under `securityPlugin`'s strict app-wide CSP — do not disable CSP
97
+ globally. Override with `csp: '…'` or opt out with `csp: false`; if you serve
98
+ the raw HTML string yourself, set the matching CSP header on that route.
99
+ Server-side inputs are HTML-escaped and embedded state cannot terminate the
100
+ script block.
83
101
 
84
102
  ## How it connects to other modules
85
103
 
package/dist/html.d.ts CHANGED
@@ -3,6 +3,13 @@ export interface AuditViewerHtmlOptions {
3
3
  apiBase?: string;
4
4
  title?: string;
5
5
  }
6
+ /**
7
+ * The Content-Security-Policy matching {@link auditViewerHtml}: everything
8
+ * locked down, the page's inline script allowed by sha256 hash. Served by
9
+ * default from `auditViewerRoutes` so the page works under `securityPlugin`
10
+ * without weakening the app-wide policy.
11
+ */
12
+ export declare function auditViewerCsp(options?: AuditViewerHtmlOptions): string;
6
13
  /**
7
14
  * A self-contained HTML page (no dependencies, no build) that browses the audit
8
15
  * trail by calling `GET {apiBase}/audit` and `/audit/stats`. Serve it from a
package/dist/html.js CHANGED
@@ -1,3 +1,49 @@
1
+ import { escapeHtml, pageCsp, scriptJson } from '@basaltkit/http';
2
+ // The inline script, built separately so the page and its CSP hash (see
3
+ // auditViewerCsp) come from the exact same source text. Embedded state uses
4
+ // scriptJson (cannot terminate the script block); API responses render through
5
+ // esc(), whose charset includes quotes for attribute positions.
6
+ const pageScript = (apiBase) => `
7
+ const API = ${scriptJson(apiBase)};
8
+ let offset = 0; const LIMIT = 50;
9
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
10
+ function params() {
11
+ const f = new FormData(document.getElementById('filters'));
12
+ const q = new URLSearchParams();
13
+ for (const [k, v] of f) if (v) q.set(k, v);
14
+ q.set('limit', LIMIT); q.set('offset', offset);
15
+ return q.toString();
16
+ }
17
+ async function load() {
18
+ const [page, stats] = await Promise.all([
19
+ fetch(API + '/audit?' + params()).then((r) => r.json()),
20
+ fetch(API + '/audit/stats?' + params()).then((r) => r.json()),
21
+ ]);
22
+ document.getElementById('rows').innerHTML = (page.entries || []).map((e) =>
23
+ '<tr><td class="muted">' + new Date(e.at).toLocaleString() + '</td><td><code>' + esc(e.event) +
24
+ '</code></td><td>' + esc(e.source) + '</td><td>' + esc(e.actorId || '') + '</td></tr>').join('');
25
+ const top = (stats.byEvent || []).slice(0, 3).map((x) => esc(x.event) + ' (' + x.count + ')').join(', ');
26
+ document.getElementById('stats').innerHTML = '<span><b>' + (stats.total || 0) + '</b> entries</span>' + (top ? '<span>Top: ' + top + '</span>' : '');
27
+ document.getElementById('page').textContent = 'showing ' + offset + '–' + (offset + (page.entries || []).length) + ' of ' + (page.total || 0);
28
+ }
29
+ document.getElementById('filters').addEventListener('submit', (e) => { e.preventDefault(); offset = 0; load(); });
30
+ document.getElementById('next').addEventListener('click', () => { offset += LIMIT; load(); });
31
+ document.getElementById('prev').addEventListener('click', () => { offset = Math.max(0, offset - LIMIT); load(); });
32
+ load();
33
+ `;
34
+ /**
35
+ * The Content-Security-Policy matching {@link auditViewerHtml}: everything
36
+ * locked down, the page's inline script allowed by sha256 hash. Served by
37
+ * default from `auditViewerRoutes` so the page works under `securityPlugin`
38
+ * without weakening the app-wide policy.
39
+ */
40
+ export function auditViewerCsp(options = {}) {
41
+ const apiBase = options.apiBase ?? '';
42
+ return pageCsp({
43
+ scripts: [pageScript(apiBase)],
44
+ ...(/^https?:\/\//.test(apiBase) ? { connect: [new URL(apiBase).origin] } : {}),
45
+ });
46
+ }
1
47
  /**
2
48
  * A self-contained HTML page (no dependencies, no build) that browses the audit
3
49
  * trail by calling `GET {apiBase}/audit` and `/audit/stats`. Serve it from a
@@ -5,7 +51,7 @@
5
51
  */
6
52
  export function auditViewerHtml(options = {}) {
7
53
  const apiBase = options.apiBase ?? '';
8
- const title = options.title ?? 'Audit trail';
54
+ const title = escapeHtml(options.title ?? 'Audit trail');
9
55
  return `<!doctype html>
10
56
  <html lang="en">
11
57
  <head>
@@ -39,34 +85,7 @@ export function auditViewerHtml(options = {}) {
39
85
  <div id="stats"></div>
40
86
  <table><thead><tr><th>When</th><th>Event</th><th>Source</th><th>Actor</th></tr></thead><tbody id="rows"></tbody></table>
41
87
  <div class="nav"><button id="prev">Prev</button><span id="page" class="muted"></span><button id="next">Next</button></div>
42
- <script>
43
- const API = ${JSON.stringify(apiBase)};
44
- let offset = 0; const LIMIT = 50;
45
- const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
46
- function params() {
47
- const f = new FormData(document.getElementById('filters'));
48
- const q = new URLSearchParams();
49
- for (const [k, v] of f) if (v) q.set(k, v);
50
- q.set('limit', LIMIT); q.set('offset', offset);
51
- return q.toString();
52
- }
53
- async function load() {
54
- const [page, stats] = await Promise.all([
55
- fetch(API + '/audit?' + params()).then((r) => r.json()),
56
- fetch(API + '/audit/stats?' + params()).then((r) => r.json()),
57
- ]);
58
- document.getElementById('rows').innerHTML = (page.entries || []).map((e) =>
59
- '<tr><td class="muted">' + new Date(e.at).toLocaleString() + '</td><td><code>' + esc(e.event) +
60
- '</code></td><td>' + esc(e.source) + '</td><td>' + esc(e.actorId || '') + '</td></tr>').join('');
61
- const top = (stats.byEvent || []).slice(0, 3).map((x) => esc(x.event) + ' (' + x.count + ')').join(', ');
62
- document.getElementById('stats').innerHTML = '<span><b>' + (stats.total || 0) + '</b> entries</span>' + (top ? '<span>Top: ' + top + '</span>' : '');
63
- document.getElementById('page').textContent = 'showing ' + offset + '–' + (offset + (page.entries || []).length) + ' of ' + (page.total || 0);
64
- }
65
- document.getElementById('filters').addEventListener('submit', (e) => { e.preventDefault(); offset = 0; load(); });
66
- document.getElementById('next').addEventListener('click', () => { offset += LIMIT; load(); });
67
- document.getElementById('prev').addEventListener('click', () => { offset = Math.max(0, offset - LIMIT); load(); });
68
- load();
69
- </script>
88
+ <script>${pageScript(apiBase)}</script>
70
89
  </body>
71
90
  </html>`;
72
91
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { AuditViewer, AuditTenantRequiredError, type ViewerQuery, type AuditPage, type AuditStats, type AuditViewerOptions, } from './viewer.js';
2
- export { auditViewerHtml, type AuditViewerHtmlOptions } from './html.js';
3
- export { auditViewerPlugin, auditViewerRoutes, AUDIT_VIEWER, type AuditViewerPluginOptions, } from './plugin.js';
2
+ export { auditViewerCsp, auditViewerHtml, type AuditViewerHtmlOptions } from './html.js';
3
+ export { auditViewerPlugin, auditViewerRoutes, AUDIT_VIEWER, type AuditViewerPluginOptions, type AuditViewerRoutesOptions, } from './plugin.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { AuditViewer, AuditTenantRequiredError, } from './viewer.js';
2
- export { auditViewerHtml } from './html.js';
2
+ export { auditViewerCsp, auditViewerHtml } from './html.js';
3
3
  export { auditViewerPlugin, auditViewerRoutes, AUDIT_VIEWER, } from './plugin.js';
package/dist/plugin.d.ts CHANGED
@@ -4,9 +4,16 @@ import { type AuditViewerHtmlOptions } from './html.js';
4
4
  export declare const AUDIT_VIEWER: import("@basaltkit/core").Token<AuditViewer>;
5
5
  export type AuditViewerPluginOptions = AuditViewerOptions;
6
6
  export declare function auditViewerPlugin(options?: AuditViewerPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
7
+ export interface AuditViewerRoutesOptions extends AuditViewerHtmlOptions {
8
+ /**
9
+ * Content-Security-Policy for the HTML page. Default: the hash-locked
10
+ * {@link auditViewerCsp}. Pass a string to override, or `false` to send none.
11
+ */
12
+ csp?: string | false;
13
+ }
7
14
  /**
8
15
  * Read-only audit routes for the current tenant, requiring a logged-in user
9
16
  * (add your own admin guard on top): `GET /audit`, `/audit/stats`,
10
17
  * `/audit/:id`, and a browsable HTML page at `/audit/view`.
11
18
  */
12
- export declare function auditViewerRoutes(options?: AuditViewerHtmlOptions): BasaltRoute[];
19
+ export declare function auditViewerRoutes(options?: AuditViewerRoutesOptions): BasaltRoute[];
package/dist/plugin.js CHANGED
@@ -3,7 +3,7 @@ import { AUDIT } from '@basaltkit/audit';
3
3
  import { route } from '@basaltkit/http';
4
4
  import { z } from 'zod';
5
5
  import { AuditViewer } from './viewer.js';
6
- import { auditViewerHtml } from './html.js';
6
+ import { auditViewerCsp, auditViewerHtml } from './html.js';
7
7
  export const AUDIT_VIEWER = createToken('audit:viewer');
8
8
  export function auditViewerPlugin(options = {}) {
9
9
  return definePlugin({
@@ -38,6 +38,7 @@ const toQuery = (q) => ({
38
38
  * `/audit/:id`, and a browsable HTML page at `/audit/view`.
39
39
  */
40
40
  export function auditViewerRoutes(options = {}) {
41
+ const csp = options.csp === false ? undefined : (options.csp ?? auditViewerCsp(options));
41
42
  return [
42
43
  route({
43
44
  method: 'GET',
@@ -62,6 +63,8 @@ export function auditViewerRoutes(options = {}) {
62
63
  url: '/audit/view',
63
64
  meta: { auth: true },
64
65
  async handler({ reply }) {
66
+ if (csp !== undefined)
67
+ reply.header('content-security-policy', csp);
65
68
  return reply.header('content-type', 'text/html; charset=utf-8').send(auditViewerHtml(options));
66
69
  },
67
70
  }),
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,
@@ -54,11 +66,13 @@ export declare class AuditViewer {
54
66
  private readonly audit;
55
67
  private readonly bucketMs;
56
68
  private readonly topN;
69
+ private readonly maxScan;
57
70
  constructor(audit: Audit, options?: AuditViewerOptions);
58
71
  page(query?: ViewerQuery): Promise<AuditPage>;
59
72
  get(id: string, tenantId?: string): Promise<AuditEntry | null>;
60
73
  stats(query?: ViewerQuery): Promise<AuditStats>;
61
74
  /** Matching entries (newest first), after the extra source/until filters. */
75
+ /** Reads at most `maxScan` rows and reports whether the trail had more. */
62
76
  private match;
63
77
  private top;
64
78
  private tenant;
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
@@ -15,23 +16,25 @@ export class AuditViewer {
15
16
  audit;
16
17
  bucketMs;
17
18
  topN;
19
+ maxScan;
18
20
  constructor(audit, options = {}) {
19
21
  this.audit = audit;
20
22
  this.bucketMs = options.bucketMs ?? DAY;
21
23
  this.topN = options.topN ?? 20;
24
+ this.maxScan = options.maxScan ?? DEFAULT_MAX_SCAN;
22
25
  }
23
26
  async page(query = {}) {
24
- const all = await this.match(query);
27
+ const { entries: all, truncated } = await this.match(query);
25
28
  const limit = query.limit ?? 50;
26
29
  const offset = query.offset ?? 0;
27
- return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset };
30
+ return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset, truncated };
28
31
  }
29
32
  async get(id, tenantId) {
30
- const all = await this.match({ ...(tenantId !== undefined ? { tenantId } : {}) });
31
- return all.find((entry) => entry.id === id) ?? null;
33
+ const { entries } = await this.match({ ...(tenantId !== undefined ? { tenantId } : {}) });
34
+ return entries.find((entry) => entry.id === id) ?? null;
32
35
  }
33
36
  async stats(query = {}) {
34
- const all = await this.match(query);
37
+ const { entries: all, truncated } = await this.match(query);
35
38
  const byEvent = new Map();
36
39
  const byActor = new Map();
37
40
  const bySource = {};
@@ -46,6 +49,7 @@ export class AuditViewer {
46
49
  }
47
50
  return {
48
51
  total: all.length,
52
+ truncated,
49
53
  byEvent: this.top(byEvent).map(([event, count]) => ({ event, count })),
50
54
  byActor: this.top(byActor).map(([actorId, count]) => ({ actorId, count })),
51
55
  bySource,
@@ -53,16 +57,19 @@ export class AuditViewer {
53
57
  };
54
58
  }
55
59
  /** Matching entries (newest first), after the extra source/until filters. */
60
+ /** Reads at most `maxScan` rows and reports whether the trail had more. */
56
61
  async match(query) {
57
62
  const tenantId = this.tenant(query.tenantId);
58
63
  const trail = await this.audit.trail({
59
64
  tenantId,
65
+ limit: this.maxScan,
60
66
  ...(query.event !== undefined ? { event: query.event } : {}),
61
67
  ...(query.actorId !== undefined ? { actorId: query.actorId } : {}),
62
68
  ...(query.since !== undefined ? { since: query.since } : {}),
63
69
  });
64
- return trail.filter((entry) => (query.source === undefined || entry.source === query.source) &&
70
+ const entries = trail.filter((entry) => (query.source === undefined || entry.source === query.source) &&
65
71
  (query.until === undefined || entry.at <= query.until));
72
+ return { entries, truncated: trail.length >= this.maxScan };
66
73
  }
67
74
  top(counts) {
68
75
  return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, this.topN);
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@basaltkit/audit-viewer",
3
- "version": "1.0.2",
3
+ "version": "1.2.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.1.2",
19
- "@basaltkit/http": "^1.9.1"
21
+ "@basaltkit/audit": "^1.3.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.6.1",
31
- "@basaltkit/tenancy": "^1.3.3",
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": {