@better-forge/audit 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +4 -2
- package/dist/audit.d.ts +31 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +107 -0
- package/dist/audit.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +19 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module.d.ts +8 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +5 -0
- package/dist/module.js.map +1 -0
- package/dist/pagination.d.ts +20 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +14 -0
- package/dist/pagination.js.map +1 -0
- package/dist/tables.d.ts +173 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +13 -0
- package/dist/tables.js.map +1 -0
- package/package.json +52 -4
- package/src/audit.ts +149 -0
- package/src/index.ts +4 -0
- package/src/migrations.ts +20 -0
- package/src/module.ts +6 -0
- package/src/pagination.ts +27 -0
- package/src/tables.ts +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterStatusPage 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
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @better-forge/audit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Audit log: write, read, entity-type registry and routes.
|
|
4
|
+
|
|
5
|
+
Status: skeleton. Depends on: `@better-forge/db`.
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { AuditAction } from '@better-forge/contracts';
|
|
3
|
+
import type { SqliteDatabase } from '@better-forge/db';
|
|
4
|
+
/** Keys whose values never reach the log. */
|
|
5
|
+
export declare const DEFAULT_SENSITIVE_KEYS: RegExp;
|
|
6
|
+
export interface Actor {
|
|
7
|
+
userId: number;
|
|
8
|
+
userEmail: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AuditOptions {
|
|
11
|
+
/** The application's database handle. It is used lazily, so it may be opened after this is created. */
|
|
12
|
+
db: SqliteDatabase;
|
|
13
|
+
/** Keys whose values are replaced by `[redacted]`. */
|
|
14
|
+
sensitiveKeys?: RegExp;
|
|
15
|
+
}
|
|
16
|
+
/** Audit log: writing entries, redacted diffs and the admin read route. All state is in the returned closure. */
|
|
17
|
+
export declare function createAudit(options: AuditOptions): {
|
|
18
|
+
write: (who: Actor, action: AuditAction, entityType: string, entityId: number | string | null, entityName: string, diff?: Record<string, unknown>) => Promise<void>;
|
|
19
|
+
diffObjects: (before: Record<string, unknown>, after: Record<string, unknown>) => Record<string, {
|
|
20
|
+
from: unknown;
|
|
21
|
+
to: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
snapshot: (obj: Record<string, unknown>) => Record<string, unknown>;
|
|
24
|
+
actor: (identity: {
|
|
25
|
+
userId: number;
|
|
26
|
+
email: string;
|
|
27
|
+
}) => Actor;
|
|
28
|
+
routes: FastifyPluginAsync;
|
|
29
|
+
};
|
|
30
|
+
export type Audit = ReturnType<typeof createAudit>;
|
|
31
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAEjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAItD,6CAA6C;AAC7C,eAAO,MAAM,sBAAsB,QAAqG,CAAA;AAExI,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,uGAAuG;IACvG,EAAE,EAAE,cAAc,CAAA;IAClB,sDAAsD;IACtD,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,iHAAiH;AACjH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY;iBAuDxC,KAAK,UACF,WAAW,cACP,MAAM,YACR,MAAM,GAAG,MAAM,GAAG,IAAI,cACpB,MAAM,SACX,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,OAAO,CAAC,IAAI,CAAC;0BArCN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,SACxB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,CAAC;oBAe1B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;sBAK/C;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAG,KAAK;;EA6EnE;AAED,MAAM,MAAM,KAAK,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAA"}
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm';
|
|
2
|
+
import { parsePagination } from './pagination.js';
|
|
3
|
+
import { auditLog } from './tables.js';
|
|
4
|
+
/** Keys whose values never reach the log. */
|
|
5
|
+
export const DEFAULT_SENSITIVE_KEYS = /(password|secret|token|authorization|cookie|credential|vaultconfig|encryptedvalue|recoverycode)/i;
|
|
6
|
+
/** Audit log: writing entries, redacted diffs and the admin read route. All state is in the returned closure. */
|
|
7
|
+
export function createAudit(options) {
|
|
8
|
+
const { db } = options;
|
|
9
|
+
const sensitiveKey = options.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
|
|
10
|
+
function redactValue(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.map(redactValue);
|
|
13
|
+
if (value && typeof value === 'object')
|
|
14
|
+
return redactObj(value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** Mask sensitive keys in a flat object. */
|
|
18
|
+
function redactObj(obj) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
21
|
+
out[k] = sensitiveKey.test(k) ? '[redacted]' : redactValue(v);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute a field-level diff between two flat objects.
|
|
27
|
+
* Only changed keys are included. Sensitive keys are masked.
|
|
28
|
+
*/
|
|
29
|
+
function diffObjects(before, after) {
|
|
30
|
+
const diff = {};
|
|
31
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
34
|
+
diff[key] = {
|
|
35
|
+
from: sensitiveKey.test(key) ? '[redacted]' : redactValue(before[key]),
|
|
36
|
+
to: sensitiveKey.test(key) ? '[redacted]' : redactValue(after[key]),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return diff;
|
|
41
|
+
}
|
|
42
|
+
/** Snapshot of an entity for a create/delete entry. Sensitive keys masked. */
|
|
43
|
+
function snapshot(obj) {
|
|
44
|
+
return redactObj(obj);
|
|
45
|
+
}
|
|
46
|
+
/** Audit actor for an authenticated identity; an API token appears under its own name, with user id 0 (no user). */
|
|
47
|
+
function actor(identity) {
|
|
48
|
+
return { userId: identity.userId, userEmail: identity.email };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Write an audit log entry. Failures are swallowed so they never break the
|
|
52
|
+
* primary operation.
|
|
53
|
+
*/
|
|
54
|
+
async function write(who, action, entityType, entityId, entityName, diff) {
|
|
55
|
+
try {
|
|
56
|
+
await db.insert(auditLog).values({
|
|
57
|
+
userId: who.userId,
|
|
58
|
+
userEmail: who.userEmail,
|
|
59
|
+
action,
|
|
60
|
+
entityType,
|
|
61
|
+
entityId: entityId !== null ? String(entityId) : null,
|
|
62
|
+
entityName,
|
|
63
|
+
diff: diff ? JSON.stringify(redactObj(diff)) : null,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error('[audit] write failed:', err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** `GET /`: the log, newest first, filterable. Register it under the prefix and the guards of your choice. */
|
|
72
|
+
const routes = async (app) => {
|
|
73
|
+
app.get('/', async (req) => {
|
|
74
|
+
const { page, limit, offset } = parsePagination(req.query, { defaultLimit: 50, maxLimit: 100 });
|
|
75
|
+
const conditions = [];
|
|
76
|
+
if (req.query.userEmail)
|
|
77
|
+
conditions.push(like(auditLog.userEmail, `%${req.query.userEmail}%`));
|
|
78
|
+
if (req.query.entityType)
|
|
79
|
+
conditions.push(eq(auditLog.entityType, req.query.entityType));
|
|
80
|
+
if (req.query.action)
|
|
81
|
+
conditions.push(eq(auditLog.action, req.query.action));
|
|
82
|
+
if (req.query.from)
|
|
83
|
+
conditions.push(gte(auditLog.timestamp, Number(req.query.from)));
|
|
84
|
+
if (req.query.to)
|
|
85
|
+
conditions.push(lte(auditLog.timestamp, Number(req.query.to)));
|
|
86
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
87
|
+
const [entries, countResult] = await Promise.all([
|
|
88
|
+
db.select().from(auditLog)
|
|
89
|
+
.where(where)
|
|
90
|
+
.orderBy(desc(auditLog.timestamp))
|
|
91
|
+
.limit(limit)
|
|
92
|
+
.offset(offset),
|
|
93
|
+
db.select({ count: sql `count(*)` }).from(auditLog).where(where),
|
|
94
|
+
]);
|
|
95
|
+
const total = countResult[0]?.count ?? 0;
|
|
96
|
+
return {
|
|
97
|
+
entries: entries.map((e) => ({ ...e, diff: e.diff ? JSON.parse(e.diff) : null })),
|
|
98
|
+
total,
|
|
99
|
+
page,
|
|
100
|
+
limit,
|
|
101
|
+
pages: Math.ceil(total / limit),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
return { write, diffObjects, snapshot, actor, routes };
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createAudit, DEFAULT_SENSITIVE_KEYS, type Actor, type Audit, type AuditOptions } from './audit.js';
|
|
2
|
+
export { auditDbModule } from './module.js';
|
|
3
|
+
export { auditLog } from './tables.js';
|
|
4
|
+
export { parsePagination, type Pagination, type PaginationOptions } from './pagination.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAAE,KAAK,KAAK,EAAE,KAAK,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAC3G,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtC,OAAO,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAA6C,MAAM,YAAY,CAAA;AAC3G,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtC,OAAO,EAAE,eAAe,EAA2C,MAAM,iBAAiB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.d.ts","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAErD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAgBnD,CAAA"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The audit log. Steps run in this order. */
|
|
2
|
+
export const auditMigrations = [
|
|
3
|
+
{ kind: 'exec', sql: `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
5
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
6
|
+
user_id INTEGER NOT NULL,
|
|
7
|
+
user_email TEXT NOT NULL,
|
|
8
|
+
action TEXT NOT NULL,
|
|
9
|
+
entity_type TEXT NOT NULL,
|
|
10
|
+
entity_id TEXT,
|
|
11
|
+
entity_name TEXT NOT NULL,
|
|
12
|
+
diff TEXT,
|
|
13
|
+
timestamp INTEGER NOT NULL
|
|
14
|
+
);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
|
|
17
|
+
` },
|
|
18
|
+
];
|
|
19
|
+
//# sourceMappingURL=migrations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.js","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAEA,8CAA8C;AAC9C,MAAM,CAAC,MAAM,eAAe,GAA6B;IACvD,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE;;;;;;;;;;;;;;CActB,EAAE;CACF,CAAA"}
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as tables from './tables.js';
|
|
2
|
+
/** The audit log table and its migrations, for the application's list of database modules. */
|
|
3
|
+
export declare const auditDbModule: {
|
|
4
|
+
id: string;
|
|
5
|
+
tables: typeof tables;
|
|
6
|
+
migrations: readonly import("@better-forge/db").MigrationStep[];
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AAGrC,8FAA8F;AAC9F,eAAO,MAAM,aAAa;;;;CAA0E,CAAA"}
|
package/dist/module.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import * as tables from './tables.js';
|
|
2
|
+
import { auditMigrations } from './migrations.js';
|
|
3
|
+
/** The audit log table and its migrations, for the application's list of database modules. */
|
|
4
|
+
export const auditDbModule = { id: 'audit', tables, migrations: auditMigrations };
|
|
5
|
+
//# sourceMappingURL=module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module.js","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,MAAM,aAAa,CAAA;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAEjD,8FAA8F;AAC9F,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe,EAAqB,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Pagination {
|
|
2
|
+
page: number;
|
|
3
|
+
limit: number;
|
|
4
|
+
offset: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PaginationOptions {
|
|
7
|
+
defaultLimit: number;
|
|
8
|
+
maxLimit: number;
|
|
9
|
+
/** Highest page accepted; keeps OFFSET within a sane range. */
|
|
10
|
+
maxPage?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Lenient paging for admin lists: missing, non-numeric or out-of-range values fall back to the
|
|
14
|
+
* default or are clamped into range, so a hand-edited URL never fails the page.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parsePagination(query: {
|
|
17
|
+
page?: string;
|
|
18
|
+
limit?: string;
|
|
19
|
+
}, options: PaginationOptions): Pagination;
|
|
20
|
+
//# sourceMappingURL=pagination.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAID;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAOhH"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const DEFAULT_MAX_PAGE = 100_000;
|
|
2
|
+
/**
|
|
3
|
+
* Lenient paging for admin lists: missing, non-numeric or out-of-range values fall back to the
|
|
4
|
+
* default or are clamped into range, so a hand-edited URL never fails the page.
|
|
5
|
+
*/
|
|
6
|
+
export function parsePagination(query, options) {
|
|
7
|
+
const maxPage = options.maxPage ?? DEFAULT_MAX_PAGE;
|
|
8
|
+
const rawPage = Math.floor(Number(query.page ?? 1)) || 1;
|
|
9
|
+
const rawLimit = Math.floor(Number(query.limit ?? options.defaultLimit)) || options.defaultLimit;
|
|
10
|
+
const page = Math.min(maxPage, Math.max(1, rawPage));
|
|
11
|
+
const limit = Math.min(options.maxLimit, Math.max(1, rawLimit));
|
|
12
|
+
return { page, limit, offset: (page - 1) * limit };
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=pagination.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pagination.js","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAaA,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEhC;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,KAAwC,EAAE,OAA0B;IAClG,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,gBAAgB,CAAA;IACnD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IACxD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC,IAAI,OAAO,CAAC,YAAY,CAAA;IAChG,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAA;IACpD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAA;IAC/D,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,KAAK,EAAE,CAAA;AACpD,CAAC"}
|
package/dist/tables.d.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export declare const auditLog: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
2
|
+
name: "audit_log";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "audit_log";
|
|
8
|
+
dataType: "number";
|
|
9
|
+
columnType: "SQLiteInteger";
|
|
10
|
+
data: number;
|
|
11
|
+
driverParam: number;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: true;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: undefined;
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
userId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
23
|
+
name: "user_id";
|
|
24
|
+
tableName: "audit_log";
|
|
25
|
+
dataType: "number";
|
|
26
|
+
columnType: "SQLiteInteger";
|
|
27
|
+
data: number;
|
|
28
|
+
driverParam: number;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: false;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: undefined;
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
userEmail: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
40
|
+
name: "user_email";
|
|
41
|
+
tableName: "audit_log";
|
|
42
|
+
dataType: "string";
|
|
43
|
+
columnType: "SQLiteText";
|
|
44
|
+
data: string;
|
|
45
|
+
driverParam: string;
|
|
46
|
+
notNull: true;
|
|
47
|
+
hasDefault: false;
|
|
48
|
+
isPrimaryKey: false;
|
|
49
|
+
isAutoincrement: false;
|
|
50
|
+
hasRuntimeDefault: false;
|
|
51
|
+
enumValues: [string, ...string[]];
|
|
52
|
+
baseColumn: never;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
}, {}, {
|
|
56
|
+
length: number | undefined;
|
|
57
|
+
}>;
|
|
58
|
+
action: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
59
|
+
name: "action";
|
|
60
|
+
tableName: "audit_log";
|
|
61
|
+
dataType: "string";
|
|
62
|
+
columnType: "SQLiteText";
|
|
63
|
+
data: string;
|
|
64
|
+
driverParam: string;
|
|
65
|
+
notNull: true;
|
|
66
|
+
hasDefault: false;
|
|
67
|
+
isPrimaryKey: false;
|
|
68
|
+
isAutoincrement: false;
|
|
69
|
+
hasRuntimeDefault: false;
|
|
70
|
+
enumValues: [string, ...string[]];
|
|
71
|
+
baseColumn: never;
|
|
72
|
+
identity: undefined;
|
|
73
|
+
generated: undefined;
|
|
74
|
+
}, {}, {
|
|
75
|
+
length: number | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
entityType: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
78
|
+
name: "entity_type";
|
|
79
|
+
tableName: "audit_log";
|
|
80
|
+
dataType: "string";
|
|
81
|
+
columnType: "SQLiteText";
|
|
82
|
+
data: string;
|
|
83
|
+
driverParam: string;
|
|
84
|
+
notNull: true;
|
|
85
|
+
hasDefault: false;
|
|
86
|
+
isPrimaryKey: false;
|
|
87
|
+
isAutoincrement: false;
|
|
88
|
+
hasRuntimeDefault: false;
|
|
89
|
+
enumValues: [string, ...string[]];
|
|
90
|
+
baseColumn: never;
|
|
91
|
+
identity: undefined;
|
|
92
|
+
generated: undefined;
|
|
93
|
+
}, {}, {
|
|
94
|
+
length: number | undefined;
|
|
95
|
+
}>;
|
|
96
|
+
entityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
97
|
+
name: "entity_id";
|
|
98
|
+
tableName: "audit_log";
|
|
99
|
+
dataType: "string";
|
|
100
|
+
columnType: "SQLiteText";
|
|
101
|
+
data: string;
|
|
102
|
+
driverParam: string;
|
|
103
|
+
notNull: false;
|
|
104
|
+
hasDefault: false;
|
|
105
|
+
isPrimaryKey: false;
|
|
106
|
+
isAutoincrement: false;
|
|
107
|
+
hasRuntimeDefault: false;
|
|
108
|
+
enumValues: [string, ...string[]];
|
|
109
|
+
baseColumn: never;
|
|
110
|
+
identity: undefined;
|
|
111
|
+
generated: undefined;
|
|
112
|
+
}, {}, {
|
|
113
|
+
length: number | undefined;
|
|
114
|
+
}>;
|
|
115
|
+
entityName: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
116
|
+
name: "entity_name";
|
|
117
|
+
tableName: "audit_log";
|
|
118
|
+
dataType: "string";
|
|
119
|
+
columnType: "SQLiteText";
|
|
120
|
+
data: string;
|
|
121
|
+
driverParam: string;
|
|
122
|
+
notNull: true;
|
|
123
|
+
hasDefault: false;
|
|
124
|
+
isPrimaryKey: false;
|
|
125
|
+
isAutoincrement: false;
|
|
126
|
+
hasRuntimeDefault: false;
|
|
127
|
+
enumValues: [string, ...string[]];
|
|
128
|
+
baseColumn: never;
|
|
129
|
+
identity: undefined;
|
|
130
|
+
generated: undefined;
|
|
131
|
+
}, {}, {
|
|
132
|
+
length: number | undefined;
|
|
133
|
+
}>;
|
|
134
|
+
diff: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
135
|
+
name: "diff";
|
|
136
|
+
tableName: "audit_log";
|
|
137
|
+
dataType: "string";
|
|
138
|
+
columnType: "SQLiteText";
|
|
139
|
+
data: string;
|
|
140
|
+
driverParam: string;
|
|
141
|
+
notNull: false;
|
|
142
|
+
hasDefault: false;
|
|
143
|
+
isPrimaryKey: false;
|
|
144
|
+
isAutoincrement: false;
|
|
145
|
+
hasRuntimeDefault: false;
|
|
146
|
+
enumValues: [string, ...string[]];
|
|
147
|
+
baseColumn: never;
|
|
148
|
+
identity: undefined;
|
|
149
|
+
generated: undefined;
|
|
150
|
+
}, {}, {
|
|
151
|
+
length: number | undefined;
|
|
152
|
+
}>;
|
|
153
|
+
timestamp: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
154
|
+
name: "timestamp";
|
|
155
|
+
tableName: "audit_log";
|
|
156
|
+
dataType: "number";
|
|
157
|
+
columnType: "SQLiteInteger";
|
|
158
|
+
data: number;
|
|
159
|
+
driverParam: number;
|
|
160
|
+
notNull: true;
|
|
161
|
+
hasDefault: false;
|
|
162
|
+
isPrimaryKey: false;
|
|
163
|
+
isAutoincrement: false;
|
|
164
|
+
hasRuntimeDefault: false;
|
|
165
|
+
enumValues: undefined;
|
|
166
|
+
baseColumn: never;
|
|
167
|
+
identity: undefined;
|
|
168
|
+
generated: undefined;
|
|
169
|
+
}, {}, {}>;
|
|
170
|
+
};
|
|
171
|
+
dialect: "sqlite";
|
|
172
|
+
}>;
|
|
173
|
+
//# sourceMappingURL=tables.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../src/tables.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUnB,CAAA"}
|
package/dist/tables.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
export const auditLog = sqliteTable('audit_log', {
|
|
3
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
4
|
+
userId: integer('user_id').notNull(),
|
|
5
|
+
userEmail: text('user_email').notNull(),
|
|
6
|
+
action: text('action').notNull(), // 'create' | 'update' | 'delete'
|
|
7
|
+
entityType: text('entity_type').notNull(),
|
|
8
|
+
entityId: text('entity_id'),
|
|
9
|
+
entityName: text('entity_name').notNull(),
|
|
10
|
+
diff: text('diff'), // JSON | null
|
|
11
|
+
timestamp: integer('timestamp').notNull(),
|
|
12
|
+
});
|
|
13
|
+
//# sourceMappingURL=tables.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.js","sourceRoot":"","sources":["../src/tables.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAEpE,MAAM,CAAC,MAAM,QAAQ,GAAG,WAAW,CAAC,WAAW,EAAE;IAC/C,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;IACrD,MAAM,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE;IACpC,SAAS,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;IACvC,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,OAAO,EAAE,EAAQ,iCAAiC;IACzE,UAAU,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC,OAAO,EAAE;IACzC,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC;IAC3B,UAAU,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC,OAAO,EAAE;IACzC,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,EAAsB,cAAc;IACtD,SAAS,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE;CAC1C,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-forge/audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Audit log: write, read, entity-type registry and routes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/audit"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/audit#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.*"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"drizzle-orm": "^0.45.2",
|
|
39
|
+
"fastify": "^5.3.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^26.6.2",
|
|
43
|
+
"drizzle-orm": "^0.45.2",
|
|
44
|
+
"fastify": "^5.3.2",
|
|
45
|
+
"typescript": "^5.7.3"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@better-forge/contracts": "0.1.1",
|
|
49
|
+
"@better-forge/db": "0.1.1"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=26.10.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify'
|
|
2
|
+
import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm'
|
|
3
|
+
import type { AuditAction } from '@better-forge/contracts'
|
|
4
|
+
import type { SqliteDatabase } from '@better-forge/db'
|
|
5
|
+
import { parsePagination } from './pagination.js'
|
|
6
|
+
import { auditLog } from './tables.js'
|
|
7
|
+
|
|
8
|
+
/** Keys whose values never reach the log. */
|
|
9
|
+
export const DEFAULT_SENSITIVE_KEYS = /(password|secret|token|authorization|cookie|credential|vaultconfig|encryptedvalue|recoverycode)/i
|
|
10
|
+
|
|
11
|
+
export interface Actor {
|
|
12
|
+
userId: number
|
|
13
|
+
userEmail: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuditOptions {
|
|
17
|
+
/** The application's database handle. It is used lazily, so it may be opened after this is created. */
|
|
18
|
+
db: SqliteDatabase
|
|
19
|
+
/** Keys whose values are replaced by `[redacted]`. */
|
|
20
|
+
sensitiveKeys?: RegExp
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Audit log: writing entries, redacted diffs and the admin read route. All state is in the returned closure. */
|
|
24
|
+
export function createAudit(options: AuditOptions) {
|
|
25
|
+
const { db } = options
|
|
26
|
+
const sensitiveKey = options.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS
|
|
27
|
+
|
|
28
|
+
function redactValue(value: unknown): unknown {
|
|
29
|
+
if (Array.isArray(value)) return value.map(redactValue)
|
|
30
|
+
if (value && typeof value === 'object') return redactObj(value as Record<string, unknown>)
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Mask sensitive keys in a flat object. */
|
|
35
|
+
function redactObj(obj: Record<string, unknown>): Record<string, unknown> {
|
|
36
|
+
const out: Record<string, unknown> = {}
|
|
37
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
38
|
+
out[k] = sensitiveKey.test(k) ? '[redacted]' : redactValue(v)
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute a field-level diff between two flat objects.
|
|
45
|
+
* Only changed keys are included. Sensitive keys are masked.
|
|
46
|
+
*/
|
|
47
|
+
function diffObjects(
|
|
48
|
+
before: Record<string, unknown>,
|
|
49
|
+
after: Record<string, unknown>,
|
|
50
|
+
): Record<string, { from: unknown; to: unknown }> {
|
|
51
|
+
const diff: Record<string, { from: unknown; to: unknown }> = {}
|
|
52
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
55
|
+
diff[key] = {
|
|
56
|
+
from: sensitiveKey.test(key) ? '[redacted]' : redactValue(before[key]),
|
|
57
|
+
to: sensitiveKey.test(key) ? '[redacted]' : redactValue(after[key]),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return diff
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Snapshot of an entity for a create/delete entry. Sensitive keys masked. */
|
|
65
|
+
function snapshot(obj: Record<string, unknown>): Record<string, unknown> {
|
|
66
|
+
return redactObj(obj)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Audit actor for an authenticated identity; an API token appears under its own name, with user id 0 (no user). */
|
|
70
|
+
function actor(identity: { userId: number; email: string }): Actor {
|
|
71
|
+
return { userId: identity.userId, userEmail: identity.email }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write an audit log entry. Failures are swallowed so they never break the
|
|
76
|
+
* primary operation.
|
|
77
|
+
*/
|
|
78
|
+
async function write(
|
|
79
|
+
who: Actor,
|
|
80
|
+
action: AuditAction,
|
|
81
|
+
entityType: string,
|
|
82
|
+
entityId: number | string | null,
|
|
83
|
+
entityName: string,
|
|
84
|
+
diff?: Record<string, unknown>,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
await db.insert(auditLog).values({
|
|
88
|
+
userId: who.userId,
|
|
89
|
+
userEmail: who.userEmail,
|
|
90
|
+
action,
|
|
91
|
+
entityType,
|
|
92
|
+
entityId: entityId !== null ? String(entityId) : null,
|
|
93
|
+
entityName,
|
|
94
|
+
diff: diff ? JSON.stringify(redactObj(diff)) : null,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
})
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[audit] write failed:', err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `GET /`: the log, newest first, filterable. Register it under the prefix and the guards of your choice. */
|
|
103
|
+
const routes: FastifyPluginAsync = async (app) => {
|
|
104
|
+
app.get<{
|
|
105
|
+
Querystring: {
|
|
106
|
+
page?: string
|
|
107
|
+
limit?: string
|
|
108
|
+
userEmail?: string
|
|
109
|
+
entityType?: string
|
|
110
|
+
action?: string
|
|
111
|
+
from?: string // unix ms
|
|
112
|
+
to?: string // unix ms
|
|
113
|
+
}
|
|
114
|
+
}>('/', async (req) => {
|
|
115
|
+
const { page, limit, offset } = parsePagination(req.query, { defaultLimit: 50, maxLimit: 100 })
|
|
116
|
+
|
|
117
|
+
const conditions = []
|
|
118
|
+
if (req.query.userEmail) conditions.push(like(auditLog.userEmail, `%${req.query.userEmail}%`))
|
|
119
|
+
if (req.query.entityType) conditions.push(eq(auditLog.entityType, req.query.entityType))
|
|
120
|
+
if (req.query.action) conditions.push(eq(auditLog.action, req.query.action))
|
|
121
|
+
if (req.query.from) conditions.push(gte(auditLog.timestamp, Number(req.query.from)))
|
|
122
|
+
if (req.query.to) conditions.push(lte(auditLog.timestamp, Number(req.query.to)))
|
|
123
|
+
|
|
124
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined
|
|
125
|
+
|
|
126
|
+
const [entries, countResult] = await Promise.all([
|
|
127
|
+
db.select().from(auditLog)
|
|
128
|
+
.where(where)
|
|
129
|
+
.orderBy(desc(auditLog.timestamp))
|
|
130
|
+
.limit(limit)
|
|
131
|
+
.offset(offset),
|
|
132
|
+
db.select({ count: sql<number>`count(*)` }).from(auditLog).where(where),
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
const total = countResult[0]?.count ?? 0
|
|
136
|
+
return {
|
|
137
|
+
entries: entries.map((e) => ({ ...e, diff: e.diff ? JSON.parse(e.diff) : null })),
|
|
138
|
+
total,
|
|
139
|
+
page,
|
|
140
|
+
limit,
|
|
141
|
+
pages: Math.ceil(total / limit),
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { write, diffObjects, snapshot, actor, routes }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type Audit = ReturnType<typeof createAudit>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createAudit, DEFAULT_SENSITIVE_KEYS, type Actor, type Audit, type AuditOptions } from './audit.js'
|
|
2
|
+
export { auditDbModule } from './module.js'
|
|
3
|
+
export { auditLog } from './tables.js'
|
|
4
|
+
export { parsePagination, type Pagination, type PaginationOptions } from './pagination.js'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MigrationStep } from '@better-forge/db'
|
|
2
|
+
|
|
3
|
+
/** The audit log. Steps run in this order. */
|
|
4
|
+
export const auditMigrations: readonly MigrationStep[] = [
|
|
5
|
+
{ kind: 'exec', sql: `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
user_id INTEGER NOT NULL,
|
|
9
|
+
user_email TEXT NOT NULL,
|
|
10
|
+
action TEXT NOT NULL,
|
|
11
|
+
entity_type TEXT NOT NULL,
|
|
12
|
+
entity_id TEXT,
|
|
13
|
+
entity_name TEXT NOT NULL,
|
|
14
|
+
diff TEXT,
|
|
15
|
+
timestamp INTEGER NOT NULL
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
|
|
19
|
+
` },
|
|
20
|
+
]
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DbModule } from '@better-forge/db'
|
|
2
|
+
import * as tables from './tables.js'
|
|
3
|
+
import { auditMigrations } from './migrations.js'
|
|
4
|
+
|
|
5
|
+
/** The audit log table and its migrations, for the application's list of database modules. */
|
|
6
|
+
export const auditDbModule = { id: 'audit', tables, migrations: auditMigrations } satisfies DbModule
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface Pagination {
|
|
2
|
+
page: number
|
|
3
|
+
limit: number
|
|
4
|
+
offset: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PaginationOptions {
|
|
8
|
+
defaultLimit: number
|
|
9
|
+
maxLimit: number
|
|
10
|
+
/** Highest page accepted; keeps OFFSET within a sane range. */
|
|
11
|
+
maxPage?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_PAGE = 100_000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lenient paging for admin lists: missing, non-numeric or out-of-range values fall back to the
|
|
18
|
+
* default or are clamped into range, so a hand-edited URL never fails the page.
|
|
19
|
+
*/
|
|
20
|
+
export function parsePagination(query: { page?: string; limit?: string }, options: PaginationOptions): Pagination {
|
|
21
|
+
const maxPage = options.maxPage ?? DEFAULT_MAX_PAGE
|
|
22
|
+
const rawPage = Math.floor(Number(query.page ?? 1)) || 1
|
|
23
|
+
const rawLimit = Math.floor(Number(query.limit ?? options.defaultLimit)) || options.defaultLimit
|
|
24
|
+
const page = Math.min(maxPage, Math.max(1, rawPage))
|
|
25
|
+
const limit = Math.min(options.maxLimit, Math.max(1, rawLimit))
|
|
26
|
+
return { page, limit, offset: (page - 1) * limit }
|
|
27
|
+
}
|
package/src/tables.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const auditLog = sqliteTable('audit_log', {
|
|
4
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
5
|
+
userId: integer('user_id').notNull(),
|
|
6
|
+
userEmail: text('user_email').notNull(),
|
|
7
|
+
action: text('action').notNull(), // 'create' | 'update' | 'delete'
|
|
8
|
+
entityType: text('entity_type').notNull(),
|
|
9
|
+
entityId: text('entity_id'),
|
|
10
|
+
entityName: text('entity_name').notNull(),
|
|
11
|
+
diff: text('diff'), // JSON | null
|
|
12
|
+
timestamp: integer('timestamp').notNull(),
|
|
13
|
+
})
|