@basaltkit/audit-viewer 1.0.1 → 1.1.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
@@ -81,6 +81,16 @@ Registers the `AUDIT_VIEWER` token. `bucketMs` is the timeline bucket size (defa
81
81
 
82
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.
83
83
 
84
+ ## Content-Security-Policy
85
+
86
+ The route sets a route-scoped CSP by default: everything locked down and the
87
+ page's inline script allowed only by sha256 hash (exported as `auditViewerCsp`). It
88
+ works under `securityPlugin`'s strict app-wide CSP — do not disable CSP
89
+ globally. Override with `csp: '…'` or opt out with `csp: false`; if you serve
90
+ the raw HTML string yourself, set the matching CSP header on that route.
91
+ Server-side inputs are HTML-escaped and embedded state cannot terminate the
92
+ script block.
93
+
84
94
  ## How it connects to other modules
85
95
 
86
96
  - **`@basaltkit/audit`** — the immutable source of the trail (this module only reads it).
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
@@ -1,12 +1,19 @@
1
- import { type BasaltRoute } from '@basaltkit/fastify';
1
+ import { type BasaltRoute } from '@basaltkit/http';
2
2
  import { AuditViewer, type AuditViewerOptions } from './viewer.js';
3
3
  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
@@ -1,9 +1,9 @@
1
1
  import { createToken, ctx, definePlugin } from '@basaltkit/core';
2
2
  import { AUDIT } from '@basaltkit/audit';
3
- import { route } from '@basaltkit/fastify';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/audit-viewer",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Read-only viewer for the @basaltkit/audit trail: tenant-scoped, filterable, paginated queries with aggregate stats, plus a self-contained HTML browser.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,9 +14,9 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.1.2",
18
- "@basaltkit/fastify": "^1.6.1",
19
- "@basaltkit/audit": "^1.2.2"
17
+ "@basaltkit/audit": "^1.2.2",
18
+ "@basaltkit/core": "^1.3.0",
19
+ "@basaltkit/http": "^1.11.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "zod": "^3.24.0 || ^4.0.0"
@@ -26,8 +26,9 @@
26
26
  "typescript": "^7.0.2",
27
27
  "vitest": "^4.1.11",
28
28
  "zod": "^3.24.0 || ^4.0.0",
29
- "@basaltkit/auth": "^1.6.2",
30
- "@basaltkit/tenancy": "^1.3.3",
29
+ "@basaltkit/auth": "^1.6.3",
30
+ "@basaltkit/fastify": "^1.7.0",
31
+ "@basaltkit/tenancy": "^1.4.0",
31
32
  "@basaltkit/tsconfig": "^0.24.0"
32
33
  },
33
34
  "publishConfig": {