@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/tables.js ADDED
@@ -0,0 +1,46 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { bigint, boolean, index, jsonb, pgTable, smallint, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
3
+ /**
4
+ * The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
5
+ * of truth: the SQL carries the CHECKs, the append-only triggers, the partial
6
+ * indexes and the erasure function that drizzle-kit does not generate. A host
7
+ * re-exports this from its own schema module so its drizzle instance and its
8
+ * types know it.
9
+ */
10
+ export const auditEvents = pgTable('audit_events', {
11
+ id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
12
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
13
+ tenantId: uuid('tenant_id'),
14
+ actorClass: text('actor_class').notNull(),
15
+ actorId: text('actor_id').notNull(),
16
+ actorDisplay: text('actor_display').notNull(),
17
+ action: text('action').notNull(),
18
+ targetType: text('target_type').notNull(),
19
+ targetId: text('target_id').notNull(),
20
+ outcome: text('outcome').notNull(),
21
+ context: text('context').notNull(),
22
+ sessionId: text('session_id'),
23
+ reason: text('reason'),
24
+ reference: text('reference'),
25
+ requestId: text('request_id'),
26
+ ip: text('ip'),
27
+ userAgent: text('user_agent'),
28
+ tenantVisible: boolean('tenant_visible').notNull(),
29
+ before: jsonb('before'),
30
+ after: jsonb('after'),
31
+ erasedAt: timestamp('erased_at', { withTimezone: true }),
32
+ schemaVersion: smallint('schema_version').notNull().default(1),
33
+ subjectClass: text('subject_class'),
34
+ subjectId: text('subject_id'),
35
+ }, (t) => [
36
+ index('audit_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
37
+ index('audit_events_actor_time_idx').on(t.actorId, t.occurredAt.desc()),
38
+ index('audit_events_action_time_idx').on(t.action, t.occurredAt.desc()),
39
+ index('audit_events_subject_time_idx')
40
+ .on(t.subjectClass, t.subjectId, t.occurredAt.desc(), t.id.desc())
41
+ .where(sql `${t.subjectId} is not null`),
42
+ index('audit_events_request_idx')
43
+ .on(t.requestId, t.occurredAt.desc())
44
+ .where(sql `${t.requestId} is not null`),
45
+ ]);
46
+ export const tables = { events: auditEvents };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The closed sets a host admits into its ledger. The package enforces them
3
+ * in TypeScript at write time; the database CHECKs that enumerate the same
4
+ * sets are the host's own migration, when it has one.
5
+ *
6
+ * Built by a host from `@wtfalch/authz`'s `core` (the estate's shared audit
7
+ * words: the twenty-six authority events, four actor classes, three contexts,
8
+ * three outcomes, five break-glass reason codes) plus whatever events the
9
+ * host's own modules declare, through `ledgerVocabulary` below. This package
10
+ * does not import that package; it takes the words as data.
11
+ */
12
+ export declare const EVENT_PATTERN: RegExp;
13
+ export interface LedgerEventMeta {
14
+ /** Whether the tenant's own security log shows rows of this kind. Decided per event, never per write. */
15
+ readonly tenantVisible: boolean;
16
+ /** Free for the host: the permission its seam checks before signing this kind. Opaque here. */
17
+ readonly requires?: string;
18
+ }
19
+ export interface LedgerVocabulary {
20
+ readonly events: Readonly<Record<string, LedgerEventMeta>>;
21
+ readonly actorClasses: readonly string[];
22
+ readonly contexts: readonly string[];
23
+ readonly outcomes: readonly string[];
24
+ /** The context whose rows must carry a session, a reason from `breakGlassReasonCodes` and a reference. Omit for a host with no support sessions. */
25
+ readonly breakGlassContext?: string;
26
+ readonly breakGlassReasonCodes?: readonly string[];
27
+ }
28
+ export interface LedgerVocabularyInput {
29
+ readonly events: Readonly<Record<string, LedgerEventMeta>>;
30
+ readonly actorClasses: readonly string[];
31
+ readonly contexts: readonly string[];
32
+ readonly outcomes: readonly string[];
33
+ readonly breakGlassContext?: string;
34
+ readonly breakGlassReasonCodes?: readonly string[];
35
+ }
36
+ export declare class LedgerVocabularyError extends Error {
37
+ readonly problems: readonly string[];
38
+ constructor(problems: readonly string[]);
39
+ }
40
+ /**
41
+ * Validates and freezes. Every problem is named at once, so a fix is one
42
+ * edit rather than a loop.
43
+ */
44
+ export declare function ledgerVocabulary(input: LedgerVocabularyInput): LedgerVocabulary;
45
+ /** The shape of `@wtfalch/authz`'s `core`, as much of it as a ledger needs. Taken structurally. */
46
+ export interface AuthzCoreLike {
47
+ readonly events: readonly string[];
48
+ readonly tenantVisible: readonly string[];
49
+ readonly actorClasses: readonly string[];
50
+ readonly contexts: readonly string[];
51
+ readonly outcomes: readonly string[];
52
+ readonly breakGlass: {
53
+ readonly reasonCodes: readonly string[];
54
+ };
55
+ }
56
+ /**
57
+ * The estate's usual construction: `@wtfalch/authz`'s core words plus the
58
+ * host's own events. An app's event may not sit in a namespace the core
59
+ * uses, so `tenant.invoice_paid` is refused while `invoice.paid` is not:
60
+ * the core's namespaces are the trusted base's, and a module claiming one
61
+ * would be writing rows a reader takes for authority changes.
62
+ */
63
+ export declare function ledgerVocabularyFromCore(core: AuthzCoreLike, own?: Readonly<Record<string, LedgerEventMeta>>): LedgerVocabulary;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The closed sets a host admits into its ledger. The package enforces them
3
+ * in TypeScript at write time; the database CHECKs that enumerate the same
4
+ * sets are the host's own migration, when it has one.
5
+ *
6
+ * Built by a host from `@wtfalch/authz`'s `core` (the estate's shared audit
7
+ * words: the twenty-six authority events, four actor classes, three contexts,
8
+ * three outcomes, five break-glass reason codes) plus whatever events the
9
+ * host's own modules declare, through `ledgerVocabulary` below. This package
10
+ * does not import that package; it takes the words as data.
11
+ */
12
+ export const EVENT_PATTERN = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
13
+ export class LedgerVocabularyError extends Error {
14
+ problems;
15
+ constructor(problems) {
16
+ super(`ledger vocabulary: ${problems.join('; ')}`);
17
+ this.name = 'LedgerVocabularyError';
18
+ this.problems = problems;
19
+ }
20
+ }
21
+ const distinct = (values) => new Set(values).size === values.length;
22
+ /**
23
+ * Validates and freezes. Every problem is named at once, so a fix is one
24
+ * edit rather than a loop.
25
+ */
26
+ export function ledgerVocabulary(input) {
27
+ const problems = [];
28
+ const names = Object.keys(input.events);
29
+ if (names.length === 0)
30
+ problems.push('at least one event is required');
31
+ for (const name of names) {
32
+ if (!EVENT_PATTERN.test(name))
33
+ problems.push(`event "${name}" is not namespace.name`);
34
+ if (typeof input.events[name]?.tenantVisible !== 'boolean')
35
+ problems.push(`event "${name}" needs tenantVisible`);
36
+ }
37
+ for (const [label, values] of [
38
+ ['actorClasses', input.actorClasses],
39
+ ['contexts', input.contexts],
40
+ ['outcomes', input.outcomes],
41
+ ]) {
42
+ if (values.length === 0)
43
+ problems.push(`${label} is empty`);
44
+ if (!distinct(values))
45
+ problems.push(`${label} repeats a value`);
46
+ for (const v of values) {
47
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(v))
48
+ problems.push(`${label} value "${v}" is not a lower-case word`);
49
+ }
50
+ }
51
+ if (input.breakGlassContext !== undefined) {
52
+ if (!input.contexts.includes(input.breakGlassContext))
53
+ problems.push(`breakGlassContext "${input.breakGlassContext}" is not one of the contexts`);
54
+ if (!input.breakGlassReasonCodes || input.breakGlassReasonCodes.length === 0)
55
+ problems.push('breakGlassContext needs breakGlassReasonCodes');
56
+ }
57
+ else if (input.breakGlassReasonCodes) {
58
+ problems.push('breakGlassReasonCodes without a breakGlassContext');
59
+ }
60
+ if (problems.length > 0)
61
+ throw new LedgerVocabularyError(problems);
62
+ return Object.freeze({
63
+ events: Object.freeze({ ...input.events }),
64
+ actorClasses: Object.freeze([...input.actorClasses]),
65
+ contexts: Object.freeze([...input.contexts]),
66
+ outcomes: Object.freeze([...input.outcomes]),
67
+ breakGlassContext: input.breakGlassContext,
68
+ breakGlassReasonCodes: input.breakGlassReasonCodes
69
+ ? Object.freeze([...input.breakGlassReasonCodes])
70
+ : undefined,
71
+ });
72
+ }
73
+ /**
74
+ * The estate's usual construction: `@wtfalch/authz`'s core words plus the
75
+ * host's own events. An app's event may not sit in a namespace the core
76
+ * uses, so `tenant.invoice_paid` is refused while `invoice.paid` is not:
77
+ * the core's namespaces are the trusted base's, and a module claiming one
78
+ * would be writing rows a reader takes for authority changes.
79
+ */
80
+ export function ledgerVocabularyFromCore(core, own = {}) {
81
+ const visible = new Set(core.tenantVisible);
82
+ const reserved = new Set(core.events.map((e) => e.split('.')[0] ?? e));
83
+ const problems = [];
84
+ const events = {};
85
+ for (const name of core.events)
86
+ events[name] = { tenantVisible: visible.has(name) };
87
+ for (const [name, meta] of Object.entries(own)) {
88
+ const ns = name.split('.')[0] ?? name;
89
+ if (name in events)
90
+ problems.push(`event "${name}" is already the core's`);
91
+ else if (reserved.has(ns))
92
+ problems.push(`event "${name}" sits in the reserved namespace "${ns}"`);
93
+ else
94
+ events[name] = meta;
95
+ }
96
+ if (problems.length > 0)
97
+ throw new LedgerVocabularyError(problems);
98
+ return ledgerVocabulary({
99
+ events,
100
+ actorClasses: core.actorClasses,
101
+ contexts: core.contexts,
102
+ outcomes: core.outcomes,
103
+ breakGlassContext: 'break_glass',
104
+ breakGlassReasonCodes: core.breakGlass.reasonCodes,
105
+ });
106
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@wtfalch/audit",
3
+ "version": "0.1.0",
4
+ "description": "The estate's append-only audit ledger: the table's shape and walls, erasure, export and readers, with a signer a host builds once inside its trusted base and never exports. Per-app Postgres, a host-supplied vocabulary of closed sets, no framework.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/wtfalch/audit",
8
+ "directory": "packages/audit"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": ["dist"],
13
+ "bin": {
14
+ "audit-migrations": "dist/bin/migrations.js"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./migrations/*.sql": "./dist/migrations/*.sql",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.0.0"
30
+ },
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/",
33
+ "prepack": "pnpm build",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run"
36
+ },
37
+ "peerDependencies": {
38
+ "drizzle-orm": ">=0.39.0",
39
+ "zod": "^4.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@electric-sql/pglite": "^0.5.8",
43
+ "@types/node": "^22",
44
+ "drizzle-orm": "^0.39.3",
45
+ "postgres": "^3.4.5",
46
+ "typescript": "^5.9.0",
47
+ "vitest": "^4.1.6",
48
+ "zod": "^4.5.4"
49
+ }
50
+ }