@basaltkit/audit-viewer 1.0.0 → 1.0.1
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 +1 -1
- package/README.md +6 -0
- package/dist/html.d.ts +11 -0
- package/dist/html.js +72 -0
- package/dist/index.d.ts +3 -92
- package/dist/index.js +3 -222
- package/dist/plugin.d.ts +12 -0
- package/dist/plugin.js +79 -0
- package/dist/viewer.d.ts +65 -0
- package/dist/viewer.js +76 -0
- package/package.json +14 -15
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://basaltkit-docs.pages.dev">
|
|
3
|
+
<img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# @basaltkit/audit-viewer
|
|
2
8
|
|
|
3
9
|
**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.
|
package/dist/html.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface AuditViewerHtmlOptions {
|
|
2
|
+
/** Base path where the audit JSON API is mounted. Default '' (same origin). */
|
|
3
|
+
apiBase?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A self-contained HTML page (no dependencies, no build) that browses the audit
|
|
8
|
+
* trail by calling `GET {apiBase}/audit` and `/audit/stats`. Serve it from a
|
|
9
|
+
* route (see `auditViewerRoutes`).
|
|
10
|
+
*/
|
|
11
|
+
export declare function auditViewerHtml(options?: AuditViewerHtmlOptions): string;
|
package/dist/html.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A self-contained HTML page (no dependencies, no build) that browses the audit
|
|
3
|
+
* trail by calling `GET {apiBase}/audit` and `/audit/stats`. Serve it from a
|
|
4
|
+
* route (see `auditViewerRoutes`).
|
|
5
|
+
*/
|
|
6
|
+
export function auditViewerHtml(options = {}) {
|
|
7
|
+
const apiBase = options.apiBase ?? '';
|
|
8
|
+
const title = options.title ?? 'Audit trail';
|
|
9
|
+
return `<!doctype html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8" />
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
14
|
+
<title>${title}</title>
|
|
15
|
+
<style>
|
|
16
|
+
:root { color-scheme: light dark; --bd: #8883; }
|
|
17
|
+
body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 1.5rem; max-width: 1100px; margin-inline: auto; }
|
|
18
|
+
h1 { font-size: 1.3rem; margin: 0 0 1rem; }
|
|
19
|
+
form { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
20
|
+
input, select { padding: .35rem .5rem; border: 1px solid var(--bd); border-radius: 6px; background: transparent; color: inherit; }
|
|
21
|
+
button { padding: .35rem .8rem; border: 1px solid var(--bd); border-radius: 6px; cursor: pointer; background: transparent; color: inherit; }
|
|
22
|
+
#stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; opacity: .85; }
|
|
23
|
+
table { width: 100%; border-collapse: collapse; }
|
|
24
|
+
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--bd); vertical-align: top; }
|
|
25
|
+
th { position: sticky; top: 0; }
|
|
26
|
+
code { font-family: ui-monospace, monospace; }
|
|
27
|
+
.muted { opacity: .6; }
|
|
28
|
+
.nav { display: flex; gap: .5rem; align-items: center; margin-top: 1rem; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<h1>${title}</h1>
|
|
33
|
+
<form id="filters">
|
|
34
|
+
<input name="event" placeholder="event (auth:**)" />
|
|
35
|
+
<input name="actorId" placeholder="actor id" />
|
|
36
|
+
<select name="source"><option value="">any source</option><option>hook</option><option>event</option><option>manual</option></select>
|
|
37
|
+
<button type="submit">Filter</button>
|
|
38
|
+
</form>
|
|
39
|
+
<div id="stats"></div>
|
|
40
|
+
<table><thead><tr><th>When</th><th>Event</th><th>Source</th><th>Actor</th></tr></thead><tbody id="rows"></tbody></table>
|
|
41
|
+
<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) => ({'&':'&','<':'<','>':'>'}[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>
|
|
70
|
+
</body>
|
|
71
|
+
</html>`;
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,92 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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 };
|
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1,222 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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) => ({'&':'&','<':'<','>':'>'}[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
|
-
};
|
|
1
|
+
export { AuditViewer, AuditTenantRequiredError, } from './viewer.js';
|
|
2
|
+
export { auditViewerHtml } from './html.js';
|
|
3
|
+
export { auditViewerPlugin, auditViewerRoutes, AUDIT_VIEWER, } from './plugin.js';
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BasaltRoute } from '@basaltkit/fastify';
|
|
2
|
+
import { AuditViewer, type AuditViewerOptions } from './viewer.js';
|
|
3
|
+
import { type AuditViewerHtmlOptions } from './html.js';
|
|
4
|
+
export declare const AUDIT_VIEWER: import("@basaltkit/core").Token<AuditViewer>;
|
|
5
|
+
export type AuditViewerPluginOptions = AuditViewerOptions;
|
|
6
|
+
export declare function auditViewerPlugin(options?: AuditViewerPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Read-only audit routes for the current tenant, requiring a logged-in user
|
|
9
|
+
* (add your own admin guard on top): `GET /audit`, `/audit/stats`,
|
|
10
|
+
* `/audit/:id`, and a browsable HTML page at `/audit/view`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function auditViewerRoutes(options?: AuditViewerHtmlOptions): BasaltRoute[];
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createToken, ctx, definePlugin } from '@basaltkit/core';
|
|
2
|
+
import { AUDIT } from '@basaltkit/audit';
|
|
3
|
+
import { route } from '@basaltkit/fastify';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { AuditViewer } from './viewer.js';
|
|
6
|
+
import { auditViewerHtml } from './html.js';
|
|
7
|
+
export const AUDIT_VIEWER = createToken('audit:viewer');
|
|
8
|
+
export function auditViewerPlugin(options = {}) {
|
|
9
|
+
return definePlugin({
|
|
10
|
+
name: 'basalt:audit-viewer',
|
|
11
|
+
register({ container }) {
|
|
12
|
+
container.singleton(AUDIT_VIEWER, () => new AuditViewer(container.get(AUDIT), options));
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const viewer = () => ctx().container.get(AUDIT_VIEWER);
|
|
17
|
+
const querySchema = z.object({
|
|
18
|
+
event: z.string().optional(),
|
|
19
|
+
actorId: z.string().optional(),
|
|
20
|
+
source: z.enum(['hook', 'event', 'manual']).optional(),
|
|
21
|
+
since: z.coerce.number().optional(),
|
|
22
|
+
until: z.coerce.number().optional(),
|
|
23
|
+
limit: z.coerce.number().optional(),
|
|
24
|
+
offset: z.coerce.number().optional(),
|
|
25
|
+
});
|
|
26
|
+
const toQuery = (q) => ({
|
|
27
|
+
...(q.event !== undefined ? { event: q.event } : {}),
|
|
28
|
+
...(q.actorId !== undefined ? { actorId: q.actorId } : {}),
|
|
29
|
+
...(q.source !== undefined ? { source: q.source } : {}),
|
|
30
|
+
...(q.since !== undefined ? { since: q.since } : {}),
|
|
31
|
+
...(q.until !== undefined ? { until: q.until } : {}),
|
|
32
|
+
...(q.limit !== undefined ? { limit: q.limit } : {}),
|
|
33
|
+
...(q.offset !== undefined ? { offset: q.offset } : {}),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Read-only audit routes for the current tenant, requiring a logged-in user
|
|
37
|
+
* (add your own admin guard on top): `GET /audit`, `/audit/stats`,
|
|
38
|
+
* `/audit/:id`, and a browsable HTML page at `/audit/view`.
|
|
39
|
+
*/
|
|
40
|
+
export function auditViewerRoutes(options = {}) {
|
|
41
|
+
return [
|
|
42
|
+
route({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
url: '/audit',
|
|
45
|
+
meta: { auth: true },
|
|
46
|
+
query: querySchema,
|
|
47
|
+
async handler({ query }) {
|
|
48
|
+
return viewer().page(toQuery(query));
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
route({
|
|
52
|
+
method: 'GET',
|
|
53
|
+
url: '/audit/stats',
|
|
54
|
+
meta: { auth: true },
|
|
55
|
+
query: querySchema,
|
|
56
|
+
async handler({ query }) {
|
|
57
|
+
return viewer().stats(toQuery(query));
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
route({
|
|
61
|
+
method: 'GET',
|
|
62
|
+
url: '/audit/view',
|
|
63
|
+
meta: { auth: true },
|
|
64
|
+
async handler({ reply }) {
|
|
65
|
+
return reply.header('content-type', 'text/html; charset=utf-8').send(auditViewerHtml(options));
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
route({
|
|
69
|
+
method: 'GET',
|
|
70
|
+
url: '/audit/:id',
|
|
71
|
+
meta: { auth: true },
|
|
72
|
+
params: z.object({ id: z.string() }),
|
|
73
|
+
async handler({ params, reply }) {
|
|
74
|
+
const entry = await viewer().get(params.id);
|
|
75
|
+
return entry ?? reply.code(404).send({ error: { code: 'AUDIT_NOT_FOUND', message: 'Entry not found.' } });
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
}
|
package/dist/viewer.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
import type { Audit, AuditEntry } from '@basaltkit/audit';
|
|
3
|
+
export declare class AuditTenantRequiredError extends BasaltError {
|
|
4
|
+
readonly status = 400;
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export interface ViewerQuery {
|
|
8
|
+
/** Wildcard pattern over the event name (e.g. `auth:**`). */
|
|
9
|
+
event?: string;
|
|
10
|
+
actorId?: string;
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
source?: AuditEntry['source'];
|
|
13
|
+
/** Lower/upper time bounds (ms epoch). */
|
|
14
|
+
since?: number;
|
|
15
|
+
until?: number;
|
|
16
|
+
limit?: number;
|
|
17
|
+
offset?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface AuditPage {
|
|
20
|
+
entries: AuditEntry[];
|
|
21
|
+
total: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
offset: number;
|
|
24
|
+
}
|
|
25
|
+
export interface AuditStats {
|
|
26
|
+
total: number;
|
|
27
|
+
byEvent: {
|
|
28
|
+
event: string;
|
|
29
|
+
count: number;
|
|
30
|
+
}[];
|
|
31
|
+
byActor: {
|
|
32
|
+
actorId: string;
|
|
33
|
+
count: number;
|
|
34
|
+
}[];
|
|
35
|
+
bySource: Record<string, number>;
|
|
36
|
+
/** Counts bucketed by `bucketMs` (default one day), oldest first. */
|
|
37
|
+
timeline: {
|
|
38
|
+
at: number;
|
|
39
|
+
count: number;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
export interface AuditViewerOptions {
|
|
43
|
+
/** Timeline bucket size in ms. Default one day. */
|
|
44
|
+
bucketMs?: number;
|
|
45
|
+
/** How many rows the byEvent/byActor breakdowns return. Default 20. */
|
|
46
|
+
topN?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read-only lens over the append-only audit trail: tenant-scoped, filterable,
|
|
50
|
+
* paginated queries plus aggregate stats. Wraps {@link Audit}; the trail itself
|
|
51
|
+
* stays immutable.
|
|
52
|
+
*/
|
|
53
|
+
export declare class AuditViewer {
|
|
54
|
+
private readonly audit;
|
|
55
|
+
private readonly bucketMs;
|
|
56
|
+
private readonly topN;
|
|
57
|
+
constructor(audit: Audit, options?: AuditViewerOptions);
|
|
58
|
+
page(query?: ViewerQuery): Promise<AuditPage>;
|
|
59
|
+
get(id: string, tenantId?: string): Promise<AuditEntry | null>;
|
|
60
|
+
stats(query?: ViewerQuery): Promise<AuditStats>;
|
|
61
|
+
/** Matching entries (newest first), after the extra source/until filters. */
|
|
62
|
+
private match;
|
|
63
|
+
private top;
|
|
64
|
+
private tenant;
|
|
65
|
+
}
|
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BasaltError, tryCtx } from '@basaltkit/core';
|
|
2
|
+
export class AuditTenantRequiredError extends BasaltError {
|
|
3
|
+
status = 400;
|
|
4
|
+
constructor() {
|
|
5
|
+
super('AUDIT_TENANT_REQUIRED', 'A tenant is required — pass tenantId or run inside a tenant context.');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const DAY = 86_400_000;
|
|
9
|
+
/**
|
|
10
|
+
* Read-only lens over the append-only audit trail: tenant-scoped, filterable,
|
|
11
|
+
* paginated queries plus aggregate stats. Wraps {@link Audit}; the trail itself
|
|
12
|
+
* stays immutable.
|
|
13
|
+
*/
|
|
14
|
+
export class AuditViewer {
|
|
15
|
+
audit;
|
|
16
|
+
bucketMs;
|
|
17
|
+
topN;
|
|
18
|
+
constructor(audit, options = {}) {
|
|
19
|
+
this.audit = audit;
|
|
20
|
+
this.bucketMs = options.bucketMs ?? DAY;
|
|
21
|
+
this.topN = options.topN ?? 20;
|
|
22
|
+
}
|
|
23
|
+
async page(query = {}) {
|
|
24
|
+
const all = await this.match(query);
|
|
25
|
+
const limit = query.limit ?? 50;
|
|
26
|
+
const offset = query.offset ?? 0;
|
|
27
|
+
return { entries: all.slice(offset, offset + limit), total: all.length, limit, offset };
|
|
28
|
+
}
|
|
29
|
+
async get(id, tenantId) {
|
|
30
|
+
const all = await this.match({ ...(tenantId !== undefined ? { tenantId } : {}) });
|
|
31
|
+
return all.find((entry) => entry.id === id) ?? null;
|
|
32
|
+
}
|
|
33
|
+
async stats(query = {}) {
|
|
34
|
+
const all = await this.match(query);
|
|
35
|
+
const byEvent = new Map();
|
|
36
|
+
const byActor = new Map();
|
|
37
|
+
const bySource = {};
|
|
38
|
+
const timeline = new Map();
|
|
39
|
+
for (const entry of all) {
|
|
40
|
+
byEvent.set(entry.event, (byEvent.get(entry.event) ?? 0) + 1);
|
|
41
|
+
if (entry.actorId)
|
|
42
|
+
byActor.set(entry.actorId, (byActor.get(entry.actorId) ?? 0) + 1);
|
|
43
|
+
bySource[entry.source] = (bySource[entry.source] ?? 0) + 1;
|
|
44
|
+
const bucket = Math.floor(entry.at / this.bucketMs) * this.bucketMs;
|
|
45
|
+
timeline.set(bucket, (timeline.get(bucket) ?? 0) + 1);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
total: all.length,
|
|
49
|
+
byEvent: this.top(byEvent).map(([event, count]) => ({ event, count })),
|
|
50
|
+
byActor: this.top(byActor).map(([actorId, count]) => ({ actorId, count })),
|
|
51
|
+
bySource,
|
|
52
|
+
timeline: [...timeline.entries()].sort((a, b) => a[0] - b[0]).map(([at, count]) => ({ at, count })),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Matching entries (newest first), after the extra source/until filters. */
|
|
56
|
+
async match(query) {
|
|
57
|
+
const tenantId = this.tenant(query.tenantId);
|
|
58
|
+
const trail = await this.audit.trail({
|
|
59
|
+
tenantId,
|
|
60
|
+
...(query.event !== undefined ? { event: query.event } : {}),
|
|
61
|
+
...(query.actorId !== undefined ? { actorId: query.actorId } : {}),
|
|
62
|
+
...(query.since !== undefined ? { since: query.since } : {}),
|
|
63
|
+
});
|
|
64
|
+
return trail.filter((entry) => (query.source === undefined || entry.source === query.source) &&
|
|
65
|
+
(query.until === undefined || entry.at <= query.until));
|
|
66
|
+
}
|
|
67
|
+
top(counts) {
|
|
68
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, this.topN);
|
|
69
|
+
}
|
|
70
|
+
tenant(explicit) {
|
|
71
|
+
const id = explicit ?? tryCtx()?.['tenant']?.id;
|
|
72
|
+
if (!id)
|
|
73
|
+
throw new AuditTenantRequiredError();
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/audit-viewer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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,21 +14,20 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@basaltkit/core": "^1.
|
|
18
|
-
"@basaltkit/fastify": "^1.
|
|
19
|
-
"@basaltkit/audit": "^1.
|
|
17
|
+
"@basaltkit/core": "^1.1.2",
|
|
18
|
+
"@basaltkit/fastify": "^1.6.1",
|
|
19
|
+
"@basaltkit/audit": "^1.2.2"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"zod": "^3.24.0 || ^4.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"@basaltkit/tenancy": "^1.
|
|
31
|
-
"@basaltkit/auth": "^1.0.0",
|
|
25
|
+
"@types/node": "^26.3.0",
|
|
26
|
+
"typescript": "^7.0.2",
|
|
27
|
+
"vitest": "^4.1.11",
|
|
28
|
+
"zod": "^3.24.0 || ^4.0.0",
|
|
29
|
+
"@basaltkit/auth": "^1.6.2",
|
|
30
|
+
"@basaltkit/tenancy": "^1.3.3",
|
|
32
31
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
33
32
|
},
|
|
34
33
|
"publishConfig": {
|
|
@@ -36,11 +35,11 @@
|
|
|
36
35
|
},
|
|
37
36
|
"repository": {
|
|
38
37
|
"type": "git",
|
|
39
|
-
"url": "git+https://github.com/
|
|
38
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
40
39
|
"directory": "packages/audit-viewer"
|
|
41
40
|
},
|
|
42
|
-
"homepage": "https://github.com/
|
|
43
|
-
"bugs": "https://github.com/
|
|
41
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/audit-viewer#readme",
|
|
42
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
44
43
|
"keywords": [
|
|
45
44
|
"basalt",
|
|
46
45
|
"typescript",
|
|
@@ -49,7 +48,7 @@
|
|
|
49
48
|
"compliance"
|
|
50
49
|
],
|
|
51
50
|
"scripts": {
|
|
52
|
-
"build": "
|
|
51
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
52
|
"test": "vitest run",
|
|
54
53
|
"typecheck": "tsc --noEmit"
|
|
55
54
|
}
|