@wtfalch/audit 0.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/dist/schema.js ADDED
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { EVENT_PATTERN } from './vocabulary.js';
3
+ /** The bounds every row honours, in characters; `jsonBytes` is `before` and `after`, each, serialised. Match `migrations/0001_audit.sql`. */
4
+ export const AUDIT_LIMITS = {
5
+ id: 256,
6
+ display: 256,
7
+ targetType: 64,
8
+ reason: 512,
9
+ reference: 512,
10
+ requestId: 128,
11
+ ip: 64,
12
+ userAgent: 1024,
13
+ jsonBytes: 65536,
14
+ };
15
+ /** A value that survives a JSON round trip unchanged: null, finite numbers, strings, booleans, arrays and plain objects of the same, with no cycle. */
16
+ export function isJsonValue(value, seen = new WeakSet()) {
17
+ if (value === null)
18
+ return true;
19
+ switch (typeof value) {
20
+ case 'string':
21
+ case 'boolean':
22
+ return true;
23
+ case 'number':
24
+ return Number.isFinite(value);
25
+ case 'object': {
26
+ const obj = value;
27
+ if (seen.has(obj))
28
+ return false;
29
+ seen.add(obj);
30
+ if (Array.isArray(obj))
31
+ return obj.every((v) => isJsonValue(v, seen));
32
+ const proto = Object.getPrototypeOf(obj);
33
+ if (proto !== Object.prototype && proto !== null)
34
+ return false;
35
+ return Object.values(obj).every((v) => isJsonValue(v, seen));
36
+ }
37
+ default:
38
+ return false;
39
+ }
40
+ }
41
+ const identifier = (max) => z.string().trim().min(1).max(max);
42
+ const jsonColumn = z.unknown().superRefine((value, ctx) => {
43
+ if (value === null || value === undefined)
44
+ return;
45
+ if (!isJsonValue(value)) {
46
+ ctx.addIssue({ code: 'custom', message: 'must be a JSON value with no cycle' });
47
+ return;
48
+ }
49
+ if (JSON.stringify(value).length > AUDIT_LIMITS.jsonBytes) {
50
+ ctx.addIssue({
51
+ code: 'custom',
52
+ message: `must serialise to at most ${AUDIT_LIMITS.jsonBytes} characters`,
53
+ });
54
+ }
55
+ });
56
+ const enumOf = (values) => z.enum([...values]);
57
+ /**
58
+ * One row as the ledger writes it, for a given vocabulary. Unknown keys are
59
+ * refused, so a misspelt column fails loudly instead of vanishing. A row in
60
+ * the break-glass context must carry the session, a reason from the closed
61
+ * code set and a reference; every other row's reason is free text.
62
+ */
63
+ export function rowSchema(vocabulary) {
64
+ const eventNames = Object.keys(vocabulary.events);
65
+ const reasonCodes = new Set(vocabulary.breakGlassReasonCodes ?? []);
66
+ return z
67
+ .strictObject({
68
+ occurred_at: z.iso.datetime({ offset: true }),
69
+ tenant_id: identifier(AUDIT_LIMITS.id).nullable(),
70
+ actor_class: enumOf(vocabulary.actorClasses),
71
+ actor_id: identifier(AUDIT_LIMITS.id),
72
+ actor_display: identifier(AUDIT_LIMITS.display),
73
+ action: enumOf(eventNames).refine((a) => EVENT_PATTERN.test(a)),
74
+ target_type: identifier(AUDIT_LIMITS.targetType),
75
+ target_id: identifier(AUDIT_LIMITS.id),
76
+ outcome: enumOf(vocabulary.outcomes),
77
+ context: enumOf(vocabulary.contexts),
78
+ session_id: identifier(AUDIT_LIMITS.id).nullable(),
79
+ reason: identifier(AUDIT_LIMITS.reason).nullable(),
80
+ reference: identifier(AUDIT_LIMITS.reference).nullable(),
81
+ request_id: z.string().max(AUDIT_LIMITS.requestId).nullable(),
82
+ ip: z.string().max(AUDIT_LIMITS.ip).nullable(),
83
+ user_agent: z.string().max(AUDIT_LIMITS.userAgent).nullable(),
84
+ tenant_visible: z.boolean(),
85
+ before: jsonColumn.nullable(),
86
+ after: jsonColumn.nullable(),
87
+ erased_at: z.iso.datetime({ offset: true }).nullable(),
88
+ schema_version: z.literal(1),
89
+ subject_class: enumOf(vocabulary.actorClasses).nullable(),
90
+ subject_id: identifier(AUDIT_LIMITS.id).nullable(),
91
+ })
92
+ .superRefine((row, ctx) => {
93
+ if ((row.subject_id === null) !== (row.subject_class === null)) {
94
+ ctx.addIssue({
95
+ code: 'custom',
96
+ path: ['subject_id'],
97
+ message: 'subject_id and subject_class come together or not at all',
98
+ });
99
+ }
100
+ if (vocabulary.breakGlassContext === undefined ||
101
+ row.context !== vocabulary.breakGlassContext)
102
+ return;
103
+ for (const field of ['session_id', 'reason', 'reference']) {
104
+ if (row[field] === null) {
105
+ ctx.addIssue({
106
+ code: 'custom',
107
+ path: [field],
108
+ message: `${field} is required when context is ${vocabulary.breakGlassContext}`,
109
+ });
110
+ }
111
+ }
112
+ if (row.reason !== null && !reasonCodes.has(row.reason)) {
113
+ ctx.addIssue({
114
+ code: 'custom',
115
+ path: ['reason'],
116
+ message: `reason must be one of ${[...reasonCodes].join(', ')} when context is ${vocabulary.breakGlassContext}`,
117
+ });
118
+ }
119
+ });
120
+ }