@wtfalch/audit 0.1.0 → 0.2.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/README.md CHANGED
@@ -70,6 +70,25 @@ const touched = await ledger.erase(tx, { subject: personId, pseudonym, email });
70
70
  uses: `tenant.invoice_paid` is out, `invoice.paid` is in. The core's
71
71
  namespaces are the trusted base's.
72
72
 
73
+ ## A host's own columns
74
+
75
+ A host that needs a column beside the ledger's (a team, a region) declares
76
+ its table over the package's builders and hands it to the ledger:
77
+
78
+ ```ts
79
+ import { AUDIT_COLUMNS, auditIndexes, createLedger } from '@wtfalch/audit';
80
+
81
+ export const auditEvents = pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, (t) =>
82
+ auditIndexes(t),
83
+ );
84
+ const ledger = createLedger({ vocabulary, table: auditEvents, schemaVersion: core.version });
85
+ await ledger.sign(tx, { ...input, extra: { teamId } });
86
+ ```
87
+
88
+ `extra` takes host columns only: a ledger column there is refused, and so is a
89
+ key the table does not declare. The host's own migration adds the column; the
90
+ package's migration never learns of it.
91
+
73
92
  ## Tests
74
93
 
75
94
  ```sh
package/dist/ledger.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
- import { type AuditEventRow, auditEvents } from './tables.js';
2
+ import { type AuditEventRow, type AuditTable } from './tables.js';
3
3
  import type { LedgerVocabulary } from './vocabulary.js';
4
4
  /**
5
5
  * The host's drizzle handle or a transaction open on it. postgres-js in the
@@ -42,6 +42,12 @@ export interface SignInput {
42
42
  readonly id: string;
43
43
  } | null;
44
44
  readonly occurredAt?: Date;
45
+ /**
46
+ * Values for the host's own columns, by the drizzle key the host's table
47
+ * declares them under (`{ teamId }`), written beside the ledger's. Never a
48
+ * ledger column: those are validated above and refused here.
49
+ */
50
+ readonly extra?: Readonly<Record<string, unknown>>;
45
51
  }
46
52
  export interface PageOptions {
47
53
  /** One tenant's rows; `null` for rows with no tenant; omit for every tenant. */
@@ -79,10 +85,17 @@ export interface EraseInput {
79
85
  * or reads a session: the guarantee that a row's actor is real is the host's,
80
86
  * and it holds only while the signer stays private.
81
87
  */
88
+ export interface LedgerOptions {
89
+ readonly vocabulary: LedgerVocabulary;
90
+ /** The host's own table over `AUDIT_COLUMNS`, when it has columns beside the ledger's. Defaults to the package's `auditEvents`. */
91
+ readonly table?: AuditTable;
92
+ /** What `schema_version` every row carries. Defaults to 1; a host whose shared audit words are versioned passes theirs. */
93
+ readonly schemaVersion?: number;
94
+ }
82
95
  export interface Ledger {
83
96
  readonly vocabulary: LedgerVocabulary;
84
97
  readonly tables: {
85
- readonly events: typeof auditEvents;
98
+ readonly events: AuditTable;
86
99
  };
87
100
  /** Validates against the vocabulary and inserts, on the handle given: a transaction when the row must commit with the change it records. */
88
101
  sign(handle: Handle, input: SignInput): Promise<void>;
@@ -93,6 +106,4 @@ export interface Ledger {
93
106
  /** One tenant's rows, oldest first, for a tenant's export. Deterministic order; no secrets are in this table to omit. */
94
107
  exportRows(handle: Handle, tenantId: string): Promise<readonly AuditEventRow[]>;
95
108
  }
96
- export declare function createLedger(options: {
97
- vocabulary: LedgerVocabulary;
98
- }): Ledger;
109
+ export declare function createLedger(options: LedgerOptions): Ledger;
package/dist/ledger.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { and, asc, desc, eq, lt, or, sql } from 'drizzle-orm';
2
2
  import { rowSchema } from './schema.js';
3
- import { auditEvents } from './tables.js';
3
+ import { auditEvents as auditEvents_ } from './tables.js';
4
+ const LEDGER_KEYS = new Set(Object.keys(auditEvents_));
4
5
  export function createLedger(options) {
5
- const { vocabulary } = options;
6
- const schema = rowSchema(vocabulary);
6
+ const { vocabulary, table: auditEvents = auditEvents_, schemaVersion = 1 } = options;
7
+ const schema = rowSchema(vocabulary, { schemaVersion });
7
8
  async function sign(handle, input) {
8
9
  const meta = vocabulary.events[input.action];
9
10
  if (!meta) {
@@ -31,11 +32,19 @@ export function createLedger(options) {
31
32
  before: input.before ?? null,
32
33
  after: input.after ?? null,
33
34
  erased_at: null,
34
- schema_version: 1,
35
+ schema_version: schemaVersion,
35
36
  subject_class: input.subject?.class ?? null,
36
37
  subject_id: input.subject?.id ?? null,
37
38
  });
39
+ const extra = input.extra ?? {};
40
+ for (const key of Object.keys(extra)) {
41
+ if (LEDGER_KEYS.has(key))
42
+ throw new Error(`audit: "${key}" is a ledger column, not a host column`);
43
+ if (!(key in auditEvents))
44
+ throw new Error(`audit: the ledger's table has no column "${key}"`);
45
+ }
38
46
  await handle.insert(auditEvents).values({
47
+ ...extra,
39
48
  occurredAt,
40
49
  tenantId: row.tenant_id,
41
50
  actorClass: row.actor_class,
package/dist/schema.d.ts CHANGED
@@ -20,7 +20,9 @@ export declare function isJsonValue(value: unknown, seen?: WeakSet<object>): boo
20
20
  * the break-glass context must carry the session, a reason from the closed
21
21
  * code set and a reference; every other row's reason is free text.
22
22
  */
23
- export declare function rowSchema(vocabulary: LedgerVocabulary): z.ZodObject<{
23
+ export declare function rowSchema(vocabulary: LedgerVocabulary, options?: {
24
+ schemaVersion?: number;
25
+ }): z.ZodObject<{
24
26
  occurred_at: z.ZodISODateTime;
25
27
  tenant_id: z.ZodNullable<z.ZodString>;
26
28
  actor_class: z.ZodEnum<{
@@ -49,7 +51,7 @@ export declare function rowSchema(vocabulary: LedgerVocabulary): z.ZodObject<{
49
51
  before: z.ZodNullable<z.ZodUnknown>;
50
52
  after: z.ZodNullable<z.ZodUnknown>;
51
53
  erased_at: z.ZodNullable<z.ZodISODateTime>;
52
- schema_version: z.ZodLiteral<1>;
54
+ schema_version: z.ZodLiteral<number>;
53
55
  subject_class: z.ZodNullable<z.ZodEnum<{
54
56
  [x: string]: string;
55
57
  }>>;
package/dist/schema.js CHANGED
@@ -60,7 +60,8 @@ const enumOf = (values) => z.enum([...values]);
60
60
  * the break-glass context must carry the session, a reason from the closed
61
61
  * code set and a reference; every other row's reason is free text.
62
62
  */
63
- export function rowSchema(vocabulary) {
63
+ export function rowSchema(vocabulary, options = {}) {
64
+ const schemaVersion = options.schemaVersion ?? 1;
64
65
  const eventNames = Object.keys(vocabulary.events);
65
66
  const reasonCodes = new Set(vocabulary.breakGlassReasonCodes ?? []);
66
67
  return z
@@ -85,7 +86,7 @@ export function rowSchema(vocabulary) {
85
86
  before: jsonColumn.nullable(),
86
87
  after: jsonColumn.nullable(),
87
88
  erased_at: z.iso.datetime({ offset: true }).nullable(),
88
- schema_version: z.literal(1),
89
+ schema_version: z.literal(schemaVersion),
89
90
  subject_class: enumOf(vocabulary.actorClasses).nullable(),
90
91
  subject_id: identifier(AUDIT_LIMITS.id).nullable(),
91
92
  })
package/dist/tables.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ExtraConfigColumn } from 'drizzle-orm/pg-core';
1
2
  /**
2
3
  * The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
3
4
  * of truth: the SQL carries the CHECKs, the append-only triggers, the partial
@@ -5,6 +6,162 @@
5
6
  * re-exports this from its own schema module so its drizzle instance and its
6
7
  * types know it.
7
8
  */
9
+ /**
10
+ * The ledger's columns, as drizzle builders, so a host that needs a column
11
+ * of its own beside them (a team, a region) declares
12
+ * `pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, …)`
13
+ * and hands that table to `createLedger`. The package's own `auditEvents`
14
+ * is the same call with nothing added.
15
+ */
16
+ export declare const AUDIT_COLUMNS: {
17
+ id: import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").NotNull<import("drizzle-orm").IsIdentity<import("drizzle-orm/pg-core").PgBigInt53BuilderInitial<"id">, "always">>>;
18
+ occurredAt: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampBuilderInitial<"occurred_at">>>;
19
+ tenantId: import("drizzle-orm/pg-core").PgUUIDBuilderInitial<"tenant_id">;
20
+ actorClass: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
21
+ name: "actor_class";
22
+ dataType: "string";
23
+ columnType: "PgText";
24
+ data: string;
25
+ enumValues: [string, ...string[]];
26
+ driverParam: string;
27
+ }>>;
28
+ actorId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
29
+ name: "actor_id";
30
+ dataType: "string";
31
+ columnType: "PgText";
32
+ data: string;
33
+ enumValues: [string, ...string[]];
34
+ driverParam: string;
35
+ }>>;
36
+ actorDisplay: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
37
+ name: "actor_display";
38
+ dataType: "string";
39
+ columnType: "PgText";
40
+ data: string;
41
+ enumValues: [string, ...string[]];
42
+ driverParam: string;
43
+ }>>;
44
+ action: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
45
+ name: "action";
46
+ dataType: "string";
47
+ columnType: "PgText";
48
+ data: string;
49
+ enumValues: [string, ...string[]];
50
+ driverParam: string;
51
+ }>>;
52
+ targetType: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
53
+ name: "target_type";
54
+ dataType: "string";
55
+ columnType: "PgText";
56
+ data: string;
57
+ enumValues: [string, ...string[]];
58
+ driverParam: string;
59
+ }>>;
60
+ targetId: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
61
+ name: "target_id";
62
+ dataType: "string";
63
+ columnType: "PgText";
64
+ data: string;
65
+ enumValues: [string, ...string[]];
66
+ driverParam: string;
67
+ }>>;
68
+ outcome: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
69
+ name: "outcome";
70
+ dataType: "string";
71
+ columnType: "PgText";
72
+ data: string;
73
+ enumValues: [string, ...string[]];
74
+ driverParam: string;
75
+ }>>;
76
+ context: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTextBuilder<{
77
+ name: "context";
78
+ dataType: "string";
79
+ columnType: "PgText";
80
+ data: string;
81
+ enumValues: [string, ...string[]];
82
+ driverParam: string;
83
+ }>>;
84
+ sessionId: import("drizzle-orm/pg-core").PgTextBuilder<{
85
+ name: "session_id";
86
+ dataType: "string";
87
+ columnType: "PgText";
88
+ data: string;
89
+ enumValues: [string, ...string[]];
90
+ driverParam: string;
91
+ }>;
92
+ reason: import("drizzle-orm/pg-core").PgTextBuilder<{
93
+ name: "reason";
94
+ dataType: "string";
95
+ columnType: "PgText";
96
+ data: string;
97
+ enumValues: [string, ...string[]];
98
+ driverParam: string;
99
+ }>;
100
+ reference: import("drizzle-orm/pg-core").PgTextBuilder<{
101
+ name: "reference";
102
+ dataType: "string";
103
+ columnType: "PgText";
104
+ data: string;
105
+ enumValues: [string, ...string[]];
106
+ driverParam: string;
107
+ }>;
108
+ requestId: import("drizzle-orm/pg-core").PgTextBuilder<{
109
+ name: "request_id";
110
+ dataType: "string";
111
+ columnType: "PgText";
112
+ data: string;
113
+ enumValues: [string, ...string[]];
114
+ driverParam: string;
115
+ }>;
116
+ ip: import("drizzle-orm/pg-core").PgTextBuilder<{
117
+ name: "ip";
118
+ dataType: "string";
119
+ columnType: "PgText";
120
+ data: string;
121
+ enumValues: [string, ...string[]];
122
+ driverParam: string;
123
+ }>;
124
+ userAgent: import("drizzle-orm/pg-core").PgTextBuilder<{
125
+ name: "user_agent";
126
+ dataType: "string";
127
+ columnType: "PgText";
128
+ data: string;
129
+ enumValues: [string, ...string[]];
130
+ driverParam: string;
131
+ }>;
132
+ tenantVisible: import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgBooleanBuilderInitial<"tenant_visible">>;
133
+ before: import("drizzle-orm/pg-core").PgJsonbBuilderInitial<"before">;
134
+ after: import("drizzle-orm/pg-core").PgJsonbBuilderInitial<"after">;
135
+ erasedAt: import("drizzle-orm/pg-core").PgTimestampBuilderInitial<"erased_at">;
136
+ schemaVersion: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgSmallIntBuilderInitial<"schema_version">>>;
137
+ subjectClass: import("drizzle-orm/pg-core").PgTextBuilder<{
138
+ name: "subject_class";
139
+ dataType: "string";
140
+ columnType: "PgText";
141
+ data: string;
142
+ enumValues: [string, ...string[]];
143
+ driverParam: string;
144
+ }>;
145
+ subjectId: import("drizzle-orm/pg-core").PgTextBuilder<{
146
+ name: "subject_id";
147
+ dataType: "string";
148
+ columnType: "PgText";
149
+ data: string;
150
+ enumValues: [string, ...string[]];
151
+ driverParam: string;
152
+ }>;
153
+ };
154
+ /** The indexes every ledger table carries, for a host declaring its own table over `AUDIT_COLUMNS`. */
155
+ export declare function auditIndexes(t: {
156
+ [K in keyof typeof AUDIT_COLUMNS]: ExtraConfigColumn;
157
+ }): import("drizzle-orm/pg-core").IndexBuilder[];
158
+ /**
159
+ * The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
160
+ * of truth: the SQL carries the CHECKs, the append-only triggers, the partial
161
+ * indexes and the erasure function that drizzle-kit does not generate. A host
162
+ * re-exports this from its own schema module so its drizzle instance and its
163
+ * types know it, or declares a wider table over `AUDIT_COLUMNS`.
164
+ */
8
165
  export declare const auditEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
9
166
  name: "audit_events";
10
167
  schema: undefined;
@@ -420,6 +577,8 @@ export declare const auditEvents: import("drizzle-orm/pg-core").PgTableWithColum
420
577
  };
421
578
  dialect: "pg";
422
579
  }>;
580
+ /** The package's table, or a host's declared over `AUDIT_COLUMNS` with more columns; structurally either. */
581
+ export type AuditTable = typeof auditEvents;
423
582
  export type AuditEventRow = typeof auditEvents.$inferSelect;
424
583
  export type AuditEventInsert = typeof auditEvents.$inferInsert;
425
584
  export declare const tables: {
package/dist/tables.js CHANGED
@@ -7,7 +7,14 @@ import { bigint, boolean, index, jsonb, pgTable, smallint, text, timestamp, uuid
7
7
  * re-exports this from its own schema module so its drizzle instance and its
8
8
  * types know it.
9
9
  */
10
- export const auditEvents = pgTable('audit_events', {
10
+ /**
11
+ * The ledger's columns, as drizzle builders, so a host that needs a column
12
+ * of its own beside them (a team, a region) declares
13
+ * `pgTable('audit_events', { ...AUDIT_COLUMNS, teamId: uuid('team_id') }, …)`
14
+ * and hands that table to `createLedger`. The package's own `auditEvents`
15
+ * is the same call with nothing added.
16
+ */
17
+ export const AUDIT_COLUMNS = {
11
18
  id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
12
19
  occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
13
20
  tenantId: uuid('tenant_id'),
@@ -32,15 +39,27 @@ export const auditEvents = pgTable('audit_events', {
32
39
  schemaVersion: smallint('schema_version').notNull().default(1),
33
40
  subjectClass: text('subject_class'),
34
41
  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
- ]);
42
+ };
43
+ /** The indexes every ledger table carries, for a host declaring its own table over `AUDIT_COLUMNS`. */
44
+ export function auditIndexes(t) {
45
+ return [
46
+ index('audit_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
47
+ index('audit_events_actor_time_idx').on(t.actorId, t.occurredAt.desc()),
48
+ index('audit_events_action_time_idx').on(t.action, t.occurredAt.desc()),
49
+ index('audit_events_subject_time_idx')
50
+ .on(t.subjectClass, t.subjectId, t.occurredAt.desc(), t.id.desc())
51
+ .where(sql `${t.subjectId} is not null`),
52
+ index('audit_events_request_idx')
53
+ .on(t.requestId, t.occurredAt.desc())
54
+ .where(sql `${t.requestId} is not null`),
55
+ ];
56
+ }
57
+ /**
58
+ * The ledger, mirrored from `migrations/0001_audit.sql`, which is the source
59
+ * of truth: the SQL carries the CHECKs, the append-only triggers, the partial
60
+ * indexes and the erasure function that drizzle-kit does not generate. A host
61
+ * re-exports this from its own schema module so its drizzle instance and its
62
+ * types know it, or declares a wider table over `AUDIT_COLUMNS`.
63
+ */
64
+ export const auditEvents = pgTable('audit_events', AUDIT_COLUMNS, (t) => auditIndexes(t));
46
65
  export const tables = { events: auditEvents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/audit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "repository": {
6
6
  "type": "git",