@basaltkit/audit-viewer 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,82 @@
1
+ # @basaltkit/audit-viewer
2
+
3
+ **Read-only** viewer for the audit trail produced by [`@basaltkit/audit`](https://www.npmjs.com/package/@basaltkit/audit): **per-tenant**, filterable and paginated queries, with aggregated **statistics**, and a self-contained **HTML page** to browse it. You need this module when you want to give admins (or yourself) a way to review who did what — for support, compliance, or debugging.
4
+
5
+ ## What this module solves
6
+
7
+ `@basaltkit/audit` writes an *append-only* (immutable) trail of everything that happens. This module is the lens for reading it: filter by event/actor/period/source, paginate, view totals and distributions — plus a page ready to open in the browser. It never writes to or alters the trail.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @basaltkit/audit-viewer @basaltkit/audit
13
+ ```
14
+
15
+ Depends on `@basaltkit/core`, `@basaltkit/audit`, and `@basaltkit/fastify`. Requires the `auditPlugin` to be registered (that's where the trail comes from).
16
+
17
+ ## Get started in 5 minutes
18
+
19
+ ```ts
20
+ import { createApp } from '@basaltkit/core'
21
+ import { auditPlugin } from '@basaltkit/audit'
22
+ import { auditViewerPlugin, auditViewerRoutes, AUDIT_VIEWER } from '@basaltkit/audit-viewer'
23
+ import { fastifyPlugin } from '@basaltkit/fastify'
24
+
25
+ const app = await createApp({
26
+ plugins: [
27
+ auditPlugin(),
28
+ auditViewerPlugin(),
29
+ fastifyPlugin({ routes: [...auditViewerRoutes()] }),
30
+ ],
31
+ }).boot()
32
+
33
+ // programmatically
34
+ const viewer = app.container.get(AUDIT_VIEWER)
35
+ const page = await viewer.page({ tenantId: 'acme', event: 'auth:**', limit: 50 })
36
+ const stats = await viewer.stats({ tenantId: 'acme' })
37
+ ```
38
+
39
+ ## Routes
40
+
41
+ `auditViewerRoutes()` (all require login — add your own admin *guard* on top):
42
+
43
+ | Route | Description |
44
+ |---|---|
45
+ | `GET /audit?event=&actorId=&source=&since=&until=&limit=&offset=` | Page of entries (most recent first) + `total`. |
46
+ | `GET /audit/stats?…` | Aggregates: by event, by actor, by source, timeline. |
47
+ | `GET /audit/:id` | A single entry. |
48
+ | `GET /audit/view` | HTML page for browsing (filters + table + pagination). |
49
+
50
+ All are **tenant-isolated** (the tenant comes from the request context).
51
+
52
+ ## The HTML page
53
+
54
+ `GET /audit/view` serves a vanilla page (no build step, no dependencies) that calls the JSON routes and shows a filterable table with pagination. Customize the title/base path:
55
+
56
+ ```ts
57
+ auditViewerRoutes({ title: 'Audit — Acme', apiBase: '/admin' })
58
+ ```
59
+
60
+ ## API reference
61
+
62
+ ### `auditViewerPlugin({ bucketMs?, topN? })`
63
+
64
+ Registers the `AUDIT_VIEWER` token. `bucketMs` is the timeline bucket size (default 1 day); `topN` limits the per-event/actor tables (default 20).
65
+
66
+ ### `class AuditViewer`
67
+
68
+ | Method | Description |
69
+ |---|---|
70
+ | `page(query)` | `{ entries, total, limit, offset }`. |
71
+ | `stats(query)` | `{ total, byEvent, byActor, bySource, timeline }`. |
72
+ | `get(id, tenantId?)` | A single entry, or `null`. |
73
+
74
+ `ViewerQuery`: `event` (wildcard), `actorId`, `tenantId`, `source` (`hook`/`event`/`manual`), `since`, `until`, `limit`, `offset`. Without `tenantId`, it uses `ctx().tenant.id` (otherwise `AuditTenantRequiredError`).
75
+
76
+ > 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.
77
+
78
+ ## How it connects to other modules
79
+
80
+ - **`@basaltkit/audit`** — the immutable source of the trail (this module only reads it).
81
+ - **`@basaltkit/permissions`** — adds a *guard* (`meta.can: 'audit:read'`) to restrict access to admins.
82
+ - **`@basaltkit/exports`** — exports a query's result to CSV for compliance reports.
@@ -0,0 +1,92 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+ import { BasaltError } from '@basaltkit/core';
3
+ import { Audit, AuditEntry } from '@basaltkit/audit';
4
+ import { BasaltRoute } from '@basaltkit/fastify';
5
+
6
+ declare class AuditTenantRequiredError extends BasaltError {
7
+ readonly status = 400;
8
+ constructor();
9
+ }
10
+ interface ViewerQuery {
11
+ /** Wildcard pattern over the event name (e.g. `auth:**`). */
12
+ event?: string;
13
+ actorId?: string;
14
+ tenantId?: string;
15
+ source?: AuditEntry['source'];
16
+ /** Lower/upper time bounds (ms epoch). */
17
+ since?: number;
18
+ until?: number;
19
+ limit?: number;
20
+ offset?: number;
21
+ }
22
+ interface AuditPage {
23
+ entries: AuditEntry[];
24
+ total: number;
25
+ limit: number;
26
+ offset: number;
27
+ }
28
+ interface AuditStats {
29
+ total: number;
30
+ byEvent: {
31
+ event: string;
32
+ count: number;
33
+ }[];
34
+ byActor: {
35
+ actorId: string;
36
+ count: number;
37
+ }[];
38
+ bySource: Record<string, number>;
39
+ /** Counts bucketed by `bucketMs` (default one day), oldest first. */
40
+ timeline: {
41
+ at: number;
42
+ count: number;
43
+ }[];
44
+ }
45
+ interface AuditViewerOptions {
46
+ /** Timeline bucket size in ms. Default one day. */
47
+ bucketMs?: number;
48
+ /** How many rows the byEvent/byActor breakdowns return. Default 20. */
49
+ topN?: number;
50
+ }
51
+ /**
52
+ * Read-only lens over the append-only audit trail: tenant-scoped, filterable,
53
+ * paginated queries plus aggregate stats. Wraps {@link Audit}; the trail itself
54
+ * stays immutable.
55
+ */
56
+ declare class AuditViewer {
57
+ private readonly audit;
58
+ private readonly bucketMs;
59
+ private readonly topN;
60
+ constructor(audit: Audit, options?: AuditViewerOptions);
61
+ page(query?: ViewerQuery): Promise<AuditPage>;
62
+ get(id: string, tenantId?: string): Promise<AuditEntry | null>;
63
+ stats(query?: ViewerQuery): Promise<AuditStats>;
64
+ /** Matching entries (newest first), after the extra source/until filters. */
65
+ private match;
66
+ private top;
67
+ private tenant;
68
+ }
69
+
70
+ interface AuditViewerHtmlOptions {
71
+ /** Base path where the audit JSON API is mounted. Default '' (same origin). */
72
+ apiBase?: string;
73
+ title?: string;
74
+ }
75
+ /**
76
+ * A self-contained HTML page (no dependencies, no build) that browses the audit
77
+ * trail by calling `GET {apiBase}/audit` and `/audit/stats`. Serve it from a
78
+ * route (see `auditViewerRoutes`).
79
+ */
80
+ declare function auditViewerHtml(options?: AuditViewerHtmlOptions): string;
81
+
82
+ declare const AUDIT_VIEWER: _basaltkit_core.Token<AuditViewer>;
83
+ type AuditViewerPluginOptions = AuditViewerOptions;
84
+ declare function auditViewerPlugin(options?: AuditViewerPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
85
+ /**
86
+ * Read-only audit routes for the current tenant, requiring a logged-in user
87
+ * (add your own admin guard on top): `GET /audit`, `/audit/stats`,
88
+ * `/audit/:id`, and a browsable HTML page at `/audit/view`.
89
+ */
90
+ declare function auditViewerRoutes(options?: AuditViewerHtmlOptions): BasaltRoute[];
91
+
92
+ export { AUDIT_VIEWER, type AuditPage, type AuditStats, AuditTenantRequiredError, AuditViewer, type AuditViewerHtmlOptions, type AuditViewerOptions, type AuditViewerPluginOptions, type ViewerQuery, auditViewerHtml, auditViewerPlugin, auditViewerRoutes };
package/dist/index.js ADDED
@@ -0,0 +1,222 @@
1
+ // src/viewer.ts
2
+ import { BasaltError, tryCtx } from "@basaltkit/core";
3
+ var AuditTenantRequiredError = class extends BasaltError {
4
+ status = 400;
5
+ constructor() {
6
+ super("AUDIT_TENANT_REQUIRED", "A tenant is required \u2014 pass tenantId or run inside a tenant context.");
7
+ }
8
+ };
9
+ var DAY = 864e5;
10
+ var AuditViewer = class {
11
+ constructor(audit, options = {}) {
12
+ this.audit = audit;
13
+ this.bucketMs = options.bucketMs ?? DAY;
14
+ this.topN = options.topN ?? 20;
15
+ }
16
+ audit;
17
+ bucketMs;
18
+ topN;
19
+ async page(query = {}) {
20
+ const all = await this.match(query);
21
+ const limit = query.limit ?? 50;
22
+ const offset = query.offset ?? 0;
23
+ return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset };
24
+ }
25
+ async get(id, tenantId) {
26
+ const all = await this.match({ ...tenantId !== void 0 ? { tenantId } : {} });
27
+ return all.find((entry) => entry.id === id) ?? null;
28
+ }
29
+ async stats(query = {}) {
30
+ const all = await this.match(query);
31
+ const byEvent = /* @__PURE__ */ new Map();
32
+ const byActor = /* @__PURE__ */ new Map();
33
+ const bySource = {};
34
+ const timeline = /* @__PURE__ */ new Map();
35
+ for (const entry of all) {
36
+ byEvent.set(entry.event, (byEvent.get(entry.event) ?? 0) + 1);
37
+ if (entry.actorId) byActor.set(entry.actorId, (byActor.get(entry.actorId) ?? 0) + 1);
38
+ bySource[entry.source] = (bySource[entry.source] ?? 0) + 1;
39
+ const bucket = Math.floor(entry.at / this.bucketMs) * this.bucketMs;
40
+ timeline.set(bucket, (timeline.get(bucket) ?? 0) + 1);
41
+ }
42
+ return {
43
+ total: all.length,
44
+ byEvent: this.top(byEvent).map(([event, count]) => ({ event, count })),
45
+ byActor: this.top(byActor).map(([actorId, count]) => ({ actorId, count })),
46
+ bySource,
47
+ timeline: [...timeline.entries()].sort((a, b) => a[0] - b[0]).map(([at, count]) => ({ at, count }))
48
+ };
49
+ }
50
+ /** Matching entries (newest first), after the extra source/until filters. */
51
+ async match(query) {
52
+ const tenantId = this.tenant(query.tenantId);
53
+ const trail = await this.audit.trail({
54
+ tenantId,
55
+ ...query.event !== void 0 ? { event: query.event } : {},
56
+ ...query.actorId !== void 0 ? { actorId: query.actorId } : {},
57
+ ...query.since !== void 0 ? { since: query.since } : {}
58
+ });
59
+ return trail.filter(
60
+ (entry) => (query.source === void 0 || entry.source === query.source) && (query.until === void 0 || entry.at <= query.until)
61
+ );
62
+ }
63
+ top(counts) {
64
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, this.topN);
65
+ }
66
+ tenant(explicit) {
67
+ const id = explicit ?? tryCtx()?.["tenant"]?.id;
68
+ if (!id) throw new AuditTenantRequiredError();
69
+ return id;
70
+ }
71
+ };
72
+
73
+ // src/html.ts
74
+ function auditViewerHtml(options = {}) {
75
+ const apiBase = options.apiBase ?? "";
76
+ const title = options.title ?? "Audit trail";
77
+ return `<!doctype html>
78
+ <html lang="en">
79
+ <head>
80
+ <meta charset="utf-8" />
81
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
82
+ <title>${title}</title>
83
+ <style>
84
+ :root { color-scheme: light dark; --bd: #8883; }
85
+ body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 1.5rem; max-width: 1100px; margin-inline: auto; }
86
+ h1 { font-size: 1.3rem; margin: 0 0 1rem; }
87
+ form { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
88
+ input, select { padding: .35rem .5rem; border: 1px solid var(--bd); border-radius: 6px; background: transparent; color: inherit; }
89
+ button { padding: .35rem .8rem; border: 1px solid var(--bd); border-radius: 6px; cursor: pointer; background: transparent; color: inherit; }
90
+ #stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; opacity: .85; }
91
+ table { width: 100%; border-collapse: collapse; }
92
+ th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--bd); vertical-align: top; }
93
+ th { position: sticky; top: 0; }
94
+ code { font-family: ui-monospace, monospace; }
95
+ .muted { opacity: .6; }
96
+ .nav { display: flex; gap: .5rem; align-items: center; margin-top: 1rem; }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <h1>${title}</h1>
101
+ <form id="filters">
102
+ <input name="event" placeholder="event (auth:**)" />
103
+ <input name="actorId" placeholder="actor id" />
104
+ <select name="source"><option value="">any source</option><option>hook</option><option>event</option><option>manual</option></select>
105
+ <button type="submit">Filter</button>
106
+ </form>
107
+ <div id="stats"></div>
108
+ <table><thead><tr><th>When</th><th>Event</th><th>Source</th><th>Actor</th></tr></thead><tbody id="rows"></tbody></table>
109
+ <div class="nav"><button id="prev">Prev</button><span id="page" class="muted"></span><button id="next">Next</button></div>
110
+ <script>
111
+ const API = ${JSON.stringify(apiBase)};
112
+ let offset = 0; const LIMIT = 50;
113
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
114
+ function params() {
115
+ const f = new FormData(document.getElementById('filters'));
116
+ const q = new URLSearchParams();
117
+ for (const [k, v] of f) if (v) q.set(k, v);
118
+ q.set('limit', LIMIT); q.set('offset', offset);
119
+ return q.toString();
120
+ }
121
+ async function load() {
122
+ const [page, stats] = await Promise.all([
123
+ fetch(API + '/audit?' + params()).then((r) => r.json()),
124
+ fetch(API + '/audit/stats?' + params()).then((r) => r.json()),
125
+ ]);
126
+ document.getElementById('rows').innerHTML = (page.entries || []).map((e) =>
127
+ '<tr><td class="muted">' + new Date(e.at).toLocaleString() + '</td><td><code>' + esc(e.event) +
128
+ '</code></td><td>' + esc(e.source) + '</td><td>' + esc(e.actorId || '') + '</td></tr>').join('');
129
+ const top = (stats.byEvent || []).slice(0, 3).map((x) => esc(x.event) + ' (' + x.count + ')').join(', ');
130
+ document.getElementById('stats').innerHTML = '<span><b>' + (stats.total || 0) + '</b> entries</span>' + (top ? '<span>Top: ' + top + '</span>' : '');
131
+ document.getElementById('page').textContent = 'showing ' + offset + '\u2013' + (offset + (page.entries || []).length) + ' of ' + (page.total || 0);
132
+ }
133
+ document.getElementById('filters').addEventListener('submit', (e) => { e.preventDefault(); offset = 0; load(); });
134
+ document.getElementById('next').addEventListener('click', () => { offset += LIMIT; load(); });
135
+ document.getElementById('prev').addEventListener('click', () => { offset = Math.max(0, offset - LIMIT); load(); });
136
+ load();
137
+ </script>
138
+ </body>
139
+ </html>`;
140
+ }
141
+
142
+ // src/plugin.ts
143
+ import { createToken, ctx, definePlugin } from "@basaltkit/core";
144
+ import { AUDIT } from "@basaltkit/audit";
145
+ import { route } from "@basaltkit/fastify";
146
+ import { z } from "zod";
147
+ var AUDIT_VIEWER = createToken("audit:viewer");
148
+ function auditViewerPlugin(options = {}) {
149
+ return definePlugin({
150
+ name: "basalt:audit-viewer",
151
+ register({ container }) {
152
+ container.singleton(AUDIT_VIEWER, () => new AuditViewer(container.get(AUDIT), options));
153
+ }
154
+ });
155
+ }
156
+ var viewer = () => ctx().container.get(AUDIT_VIEWER);
157
+ var querySchema = z.object({
158
+ event: z.string().optional(),
159
+ actorId: z.string().optional(),
160
+ source: z.enum(["hook", "event", "manual"]).optional(),
161
+ since: z.coerce.number().optional(),
162
+ until: z.coerce.number().optional(),
163
+ limit: z.coerce.number().optional(),
164
+ offset: z.coerce.number().optional()
165
+ });
166
+ var toQuery = (q) => ({
167
+ ...q.event !== void 0 ? { event: q.event } : {},
168
+ ...q.actorId !== void 0 ? { actorId: q.actorId } : {},
169
+ ...q.source !== void 0 ? { source: q.source } : {},
170
+ ...q.since !== void 0 ? { since: q.since } : {},
171
+ ...q.until !== void 0 ? { until: q.until } : {},
172
+ ...q.limit !== void 0 ? { limit: q.limit } : {},
173
+ ...q.offset !== void 0 ? { offset: q.offset } : {}
174
+ });
175
+ function auditViewerRoutes(options = {}) {
176
+ return [
177
+ route({
178
+ method: "GET",
179
+ url: "/audit",
180
+ meta: { auth: true },
181
+ query: querySchema,
182
+ async handler({ query }) {
183
+ return viewer().page(toQuery(query));
184
+ }
185
+ }),
186
+ route({
187
+ method: "GET",
188
+ url: "/audit/stats",
189
+ meta: { auth: true },
190
+ query: querySchema,
191
+ async handler({ query }) {
192
+ return viewer().stats(toQuery(query));
193
+ }
194
+ }),
195
+ route({
196
+ method: "GET",
197
+ url: "/audit/view",
198
+ meta: { auth: true },
199
+ async handler({ reply }) {
200
+ return reply.header("content-type", "text/html; charset=utf-8").send(auditViewerHtml(options));
201
+ }
202
+ }),
203
+ route({
204
+ method: "GET",
205
+ url: "/audit/:id",
206
+ meta: { auth: true },
207
+ params: z.object({ id: z.string() }),
208
+ async handler({ params, reply }) {
209
+ const entry = await viewer().get(params.id);
210
+ return entry ?? reply.code(404).send({ error: { code: "AUDIT_NOT_FOUND", message: "Entry not found." } });
211
+ }
212
+ })
213
+ ];
214
+ }
215
+ export {
216
+ AUDIT_VIEWER,
217
+ AuditTenantRequiredError,
218
+ AuditViewer,
219
+ auditViewerHtml,
220
+ auditViewerPlugin,
221
+ auditViewerRoutes
222
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@basaltkit/audit-viewer",
3
+ "version": "1.0.0",
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
+ "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/fastify": "^1.0.0",
19
+ "@basaltkit/audit": "^1.0.0"
20
+ },
21
+ "peerDependencies": {
22
+ "zod": "^3.24.0 || ^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.15.0",
26
+ "tsup": "^8.4.0",
27
+ "typescript": "^5.8.0",
28
+ "vitest": "^3.1.0",
29
+ "zod": "^3.24.0",
30
+ "@basaltkit/tenancy": "^1.0.0",
31
+ "@basaltkit/auth": "^1.0.0",
32
+ "@basaltkit/tsconfig": "^0.24.0"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/Zebedeu/basalt.git",
40
+ "directory": "packages/audit-viewer"
41
+ },
42
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/audit-viewer#readme",
43
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
44
+ "keywords": [
45
+ "basalt",
46
+ "typescript",
47
+ "saas",
48
+ "audit",
49
+ "compliance"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsup src/index.ts --format esm --dts --clean",
53
+ "test": "vitest run",
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }