@murumets-ee/logging 0.57.0 → 0.58.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/admin.d.mts +40 -0
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/audit-client-BjJR8ho7.mjs +2 -0
- package/dist/audit-client-BjJR8ho7.mjs.map +1 -0
- package/dist/audit-client-Cx8nIF9Q.mjs +2 -0
- package/dist/audit-client-Cx8nIF9Q.mjs.map +1 -0
- package/dist/audit-client-OqTb6E9_.d.mts +104 -0
- package/dist/audit-client-OqTb6E9_.d.mts.map +1 -0
- package/dist/index.d.mts +36 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -4
- package/dist/audit-client-4XgZ_M3r.d.mts +0 -52
- package/dist/audit-client-4XgZ_M3r.d.mts.map +0 -1
- package/dist/audit-client-CkD795ec.mjs +0 -2
- package/dist/audit-client-CkD795ec.mjs.map +0 -1
- package/dist/audit-client-T2P2K970.mjs +0 -2
- package/dist/audit-client-T2P2K970.mjs.map +0 -1
package/dist/admin.d.mts
CHANGED
|
@@ -6,6 +6,21 @@ interface AuditLogQueryOptions {
|
|
|
6
6
|
action?: string | undefined;
|
|
7
7
|
entityType?: string | undefined;
|
|
8
8
|
entityId?: string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Several entity ids at once — one query for a whole SET of rows rather than
|
|
11
|
+
* one query per row.
|
|
12
|
+
*
|
|
13
|
+
* Added for `@murumets-ee/commerce`'s customer-facing per-line timeline, which
|
|
14
|
+
* needs every line of one order in a single read: the per-line alternative is
|
|
15
|
+
* `O(lines)` queries on a page load, which is the fan-out shape CLAUDE.md's
|
|
16
|
+
* amplification rule refuses.
|
|
17
|
+
*
|
|
18
|
+
* AND-ed with `entityId` when both are given, which is degenerate but not
|
|
19
|
+
* surprising; callers should pass one or the other. An EMPTY array matches
|
|
20
|
+
* nothing (the clause is emitted with an empty `in`), which is the right answer
|
|
21
|
+
* for "no ids" and not the same as omitting the filter.
|
|
22
|
+
*/
|
|
23
|
+
entityIds?: readonly string[] | undefined;
|
|
9
24
|
userId?: string | undefined;
|
|
10
25
|
dateFrom?: Date | undefined;
|
|
11
26
|
dateTo?: Date | undefined;
|
|
@@ -37,6 +52,31 @@ declare class AuditLogClient {
|
|
|
37
52
|
constructor(db: PostgresJsDatabase);
|
|
38
53
|
/** Write a single audit log entry */
|
|
39
54
|
write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Write N audit log entries in ONE multi-row INSERT (F124).
|
|
57
|
+
*
|
|
58
|
+
* `write()` called in a loop issues N round trips to the same pool a
|
|
59
|
+
* caller's own transaction may still be using — this is the primitive
|
|
60
|
+
* that lets a bulk route (dispatch, a future bulk admin action) emit one
|
|
61
|
+
* audit row per affected item without one INSERT per item. Delegates to
|
|
62
|
+
* `TableClient.insertMany`, which already caps a batch at 1000 rows —
|
|
63
|
+
* comfortably above any bounded fan-out in this codebase today
|
|
64
|
+
* (`MAX_DISPATCH_LINES` is 200).
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ **All-or-nothing, not chunked.** `insertMany` does ONE statement for
|
|
67
|
+
* the WHOLE array — there is no partial-success path. A caller that ever
|
|
68
|
+
* exceeds 1000 entries gets every row rejected, not the first 1000
|
|
69
|
+
* accepted and the rest dropped. Via `AuditLogger.logMany` that failure is
|
|
70
|
+
* swallowed and logged (never thrown, by contract), so an over-cap batch
|
|
71
|
+
* fails SILENTLY from the route's point of view — worse than the
|
|
72
|
+
* pre-F124 per-row loop, which degraded one row at a time under the same
|
|
73
|
+
* pressure. A future caller approaching 1000 needs its own chunking, not
|
|
74
|
+
* an assumption that this method will do it.
|
|
75
|
+
*
|
|
76
|
+
* A no-op on an empty array — matches `TableClient.insertMany`, and
|
|
77
|
+
* means a caller never has to guard `entries.length > 0` itself.
|
|
78
|
+
*/
|
|
79
|
+
writeMany(entries: readonly Omit<AuditLogEntry, 'id' | 'createdAt'>[]): Promise<void>;
|
|
40
80
|
/** Query audit logs with filters and pagination */
|
|
41
81
|
query(options?: AuditLogQueryOptions): Promise<AuditLogListResult>;
|
|
42
82
|
/** Get a single audit log entry by ID */
|
package/dist/admin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/audit-client.ts","../src/admin/routes.ts"],"mappings":";;;;UAQiB,oBAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA,GAAW,IAAA;EACX,MAAA,GAAS,IAAI;EALb
|
|
1
|
+
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/audit-client.ts","../src/admin/routes.ts"],"mappings":";;;;UAQiB,oBAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EAHmC;;;;;;;;;;;;;;EAkBnC,SAAA;EACA,MAAA;EACA,QAAA,GAAW,IAAA;EACX,MAAA,GAAS,IAAI;EAOA;EALb,MAAA;EAQ4B;EAN5B,KAAA;EACA,MAAA;EACA,SAAA;EACA,aAAA;AAAA;AAAA,UAGe,aAAA;EACf,EAAA;EACA,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,EAAS,MAAA;EACT,QAAA,EAAU,MAAA;EACV,SAAA,EAAW,IAAA;AAAA;AAAA,UAGI,kBAAA;EACf,KAAA,EAAO,aAAa;EACpB,KAAA;AAAA;AAAA,cAiCW,cAAA;EAAA,QACH,MAAA;cAEI,EAAA,EAAI,kBAAA;EAK6C;EAAvD,KAAA,CAAM,KAAA,EAAO,IAAA,CAAK,aAAA,wBAAqC,OAAA;EAoC3B;;;;;;;;;;;;;;;;;;;;;;;;EAA5B,SAAA,CAAU,OAAA,WAAkB,IAAA,CAAK,aAAA,0BAAuC,OAAA;EAgBxE;EAAA,KAAA,CAAM,OAAA,GAAS,oBAAA,GAA4B,OAAA,CAAQ,kBAAA;EAA7C;EAmBN,QAAA,CAAS,EAAA,WAAa,OAAA,CAAQ,aAAA;EAnBqB;EAwBnD,sBAAA,CAAA,GAA0B,OAAA;EALjB;EAYT,kBAAA,CAAA,GAAsB,OAAA;EAAA,QAQpB,UAAA;EAAA,QAuCA,YAAA;AAAA;;;;;;;;;;;;;;;;;;;UClKO,UAAA;EACf,EAAA;IACE,SAAA,EAAW,kBAAkB;EAAA;AAAA;;;;KAkD5B,uBAAA,GAA0B,IAAI,CACjC,cAAA;;;AD9DK;AAiCP;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCsEgB,SAAA,CAAU,SAAA,SAAkB,uBAAA,GAA0B,UAAA,CAAW,UAAA"}
|
package/dist/admin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{combineAdminRoutes as e,defineAdminRoute as t}from"@murumets-ee/admin-route";import{z as n}from"zod";const r=/^[a-zA-Z0-9_-]{1,255}$/,i=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,a=n.object({action:n.string().max(100).optional(),entityType:n.string().max(100).optional(),entityId:n.string().regex(r,`Invalid ID format`).optional(),userId:n.string().regex(r,`Invalid user ID format`).optional(),dateFrom:n.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),dateTo:n.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),search:n.string().max(200).optional(),limit:n.coerce.number().min(1).max(100).default(50),offset:n.coerce.number().min(0).default(0),sortField:n.enum([`createdAt`,`action`,`entityType`]).default(`createdAt`),sortDirection:n.enum([`asc`,`desc`]).default(`desc`)});function o(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function s(e,t){return o({error:e},t)}function c(n){return e([t({prefix:`logs`,path:``,method:`GET`,matchAnyPath:!0,permission:`audit-logs:view`,defaultRoles:[`admin`],description:`Read the immutable audit log`,handler:async(e,{segments:t,app:r})=>{let c=n?n():new(await(import(`./audit-client-
|
|
1
|
+
import{combineAdminRoutes as e,defineAdminRoute as t}from"@murumets-ee/admin-route";import{z as n}from"zod";const r=/^[a-zA-Z0-9_-]{1,255}$/,i=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,a=n.object({action:n.string().max(100).optional(),entityType:n.string().max(100).optional(),entityId:n.string().regex(r,`Invalid ID format`).optional(),userId:n.string().regex(r,`Invalid user ID format`).optional(),dateFrom:n.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),dateTo:n.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),search:n.string().max(200).optional(),limit:n.coerce.number().min(1).max(100).default(50),offset:n.coerce.number().min(0).default(0),sortField:n.enum([`createdAt`,`action`,`entityType`]).default(`createdAt`),sortDirection:n.enum([`asc`,`desc`]).default(`desc`)});function o(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function s(e,t){return o({error:e},t)}function c(n){return e([t({prefix:`logs`,path:``,method:`GET`,matchAnyPath:!0,permission:`audit-logs:view`,defaultRoles:[`admin`],description:`Read the immutable audit log`,handler:async(e,{segments:t,app:r})=>{let c=n?n():new(await(import(`./audit-client-BjJR8ho7.mjs`))).AuditLogClient(r.db.readWrite),l=t[0];if(t.length===1&&l===`filters`){let[e,t]=await Promise.all([c.getDistinctEntityTypes(),c.getDistinctActions()]);return o({entityTypes:e,actions:t})}if(t.length===1&&l!==void 0){if(!i.test(l))return s(`Audit log entry not found`,404);let e=await c.findById(l);return e?o(e):s(`Audit log entry not found`,404)}if(t.length>1)return s(`Not found`,404);let u=new URL(e.url),d=Object.fromEntries(u.searchParams),f=a.safeParse(d);if(!f.success)return s(`Invalid query params: ${f.error.issues.map(e=>e.message).join(`, `)}`,400);let{dateFrom:p,dateTo:m,...h}=f.data;return o(await c.query({...h,...p&&{dateFrom:new Date(p)},...m&&{dateTo:new Date(m)}}))}})])}export{c as logRoutes};
|
|
2
2
|
//# sourceMappingURL=admin.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{column as e,defineTable as t}from"@murumets-ee/db";const n=t({name:`toolkit_audit_logs`,columns:{id:e.uuid({primaryKey:!0,defaultRandom:!0}),action:e.varchar({length:100,notNull:!0}),entityType:e.varchar({length:100,pgName:`entity_type`}),entityId:e.varchar({length:255,pgName:`entity_id`}),userId:e.varchar({length:255,pgName:`user_id`}),userName:e.varchar({length:255,pgName:`user_name`}),changes:e.jsonb(),metadata:e.jsonb(),createdAt:e.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`})},indexes:[{on:[`entityType`,`createdAt`],name:`audit_entity_type_created_at_idx`},{on:[`userId`,`createdAt`],name:`audit_user_id_created_at_idx`},{on:[`action`,`createdAt`],name:`audit_action_created_at_idx`},{on:[`createdAt`],name:`audit_created_at_idx`},{on:[`entityId`],name:`audit_entity_id_idx`}]});n.table;var r=class{client;constructor(e){this.client=n.makeClient(e)}async write(e){await this.client.insert({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})}async writeMany(e){e.length!==0&&await this.client.insertMany(e.map(e=>({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})))}async query(e={}){let t=Math.min(Math.max(e.limit??50,1),100),n=Math.max(e.offset??0,0),r=this.buildWhere(e),i=this.buildOrderBy(e.sortField,e.sortDirection),[a,o]=await Promise.all([this.client.findMany({...r!==void 0&&{where:r},orderBy:i,limit:t,offset:n}),this.client.count(r)]);return{items:a,total:o}}async findById(e){return this.client.findOne({id:e})}async getDistinctEntityTypes(){return this.client.distinct(`entityType`,{orderBy:`asc`})}async getDistinctActions(){return this.client.distinct(`action`,{orderBy:`asc`})}buildWhere(e){let t=[];if(e.action&&t.push({action:e.action}),e.entityType&&t.push({entityType:e.entityType}),e.entityId&&t.push({entityId:e.entityId}),e.entityIds!==void 0&&t.push({entityId:{in:[...e.entityIds]}}),e.userId&&t.push({userId:e.userId}),e.dateFrom&&t.push({createdAt:{gte:e.dateFrom}}),e.dateTo&&t.push({createdAt:{lte:e.dateTo}}),e.search&&t.push({$or:[{action:{ilike:e.search}},{entityType:{ilike:e.search}},{userName:{ilike:e.search}}]}),t.length!==0)return t.length===1?t[0]:{$and:t}}buildOrderBy(e,t){let n=t===`asc`?`asc`:`desc`;switch(e){case`action`:return[{column:`action`,dir:n}];case`entityType`:return[{column:`entityType`,dir:n}];default:return[{column:`createdAt`,dir:n}]}}};export{r as AuditLogClient};
|
|
2
|
+
//# sourceMappingURL=audit-client-BjJR8ho7.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-client-BjJR8ho7.mjs","names":[],"sources":["../src/audit-table.ts","../src/audit-client.ts"],"sourcesContent":["/**\n * `toolkit_audit_logs` table definition via `defineTable`.\n *\n * Replaces the hand-written `pgTable` in `schema.ts`. Uses `pgName` to\n * preserve the existing snake_case Postgres column names so no migration\n * is required.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const auditLogTable = defineTable({\n name: 'toolkit_audit_logs',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n action: column.varchar({ length: 100, notNull: true }),\n entityType: column.varchar({ length: 100, pgName: 'entity_type' }),\n entityId: column.varchar({ length: 255, pgName: 'entity_id' }),\n userId: column.varchar({ length: 255, pgName: 'user_id' }),\n userName: column.varchar({ length: 255, pgName: 'user_name' }),\n changes: column.jsonb<Record<string, unknown>>(),\n metadata: column.jsonb<Record<string, unknown>>(),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n },\n indexes: [\n { on: ['entityType', 'createdAt'], name: 'audit_entity_type_created_at_idx' },\n { on: ['userId', 'createdAt'], name: 'audit_user_id_created_at_idx' },\n { on: ['action', 'createdAt'], name: 'audit_action_created_at_idx' },\n { on: ['createdAt'], name: 'audit_created_at_idx' },\n { on: ['entityId'], name: 'audit_entity_id_idx' },\n ],\n})\n\n/** Backward-compatible re-export — consumers importing `toolkitAuditLogs` see the same PgTable. */\nexport const toolkitAuditLogs = auditLogTable.table\n","import type { TableClient, WhereClause } from '@murumets-ee/db'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { auditLogTable } from './audit-table.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface AuditLogQueryOptions {\n action?: string | undefined\n entityType?: string | undefined\n entityId?: string | undefined\n /**\n * Several entity ids at once — one query for a whole SET of rows rather than\n * one query per row.\n *\n * Added for `@murumets-ee/commerce`'s customer-facing per-line timeline, which\n * needs every line of one order in a single read: the per-line alternative is\n * `O(lines)` queries on a page load, which is the fan-out shape CLAUDE.md's\n * amplification rule refuses.\n *\n * AND-ed with `entityId` when both are given, which is degenerate but not\n * surprising; callers should pass one or the other. An EMPTY array matches\n * nothing (the clause is emitted with an empty `in`), which is the right answer\n * for \"no ids\" and not the same as omitting the filter.\n */\n entityIds?: readonly string[] | undefined\n userId?: string | undefined\n dateFrom?: Date | undefined\n dateTo?: Date | undefined\n /** Free-text search across action, entityType, userName */\n search?: string | undefined\n /** Default 50, max 100 */\n limit?: number | undefined\n offset?: number | undefined\n sortField?: 'createdAt' | 'action' | 'entityType' | undefined\n sortDirection?: 'asc' | 'desc' | undefined\n}\n\nexport interface AuditLogEntry {\n id: string\n action: string\n entityType: string | null\n entityId: string | null\n userId: string | null\n userName: string | null\n changes: Record<string, unknown> | null\n metadata: Record<string, unknown> | null\n createdAt: Date\n}\n\nexport interface AuditLogListResult {\n items: AuditLogEntry[]\n total: number\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * The most rows one {@link AuditLogClient.query} will return, whatever `limit`\n * asks for.\n *\n * EXPORTED because a consumer that discloses truncation has to know the clamp:\n * `items.length >= <copy of 100>` is a claim about this constant, and a\n * hand-copied one goes silently false the day this number moves — the flag stops\n * firing, the disclosure disappears, and nothing tests it because the two live in\n * different packages. `@murumets-ee/commerce`'s customer-facing per-line timeline\n * is the caller that needs it.\n */\nexport const AUDIT_QUERY_MAX_LIMIT = 100\nconst MAX_LIMIT = AUDIT_QUERY_MAX_LIMIT\nconst DEFAULT_LIMIT = 50\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\ntype AuditCols = typeof auditLogTable.schema.columns\ntype AuditWhere = WhereClause<AuditCols>\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\nexport class AuditLogClient {\n private client: TableClient<AuditCols>\n\n constructor(db: PostgresJsDatabase) {\n this.client = auditLogTable.makeClient(db)\n }\n\n /** Write a single audit log entry */\n async write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void> {\n await this.client.insert({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })\n }\n\n /**\n * Write N audit log entries in ONE multi-row INSERT (F124).\n *\n * `write()` called in a loop issues N round trips to the same pool a\n * caller's own transaction may still be using — this is the primitive\n * that lets a bulk route (dispatch, a future bulk admin action) emit one\n * audit row per affected item without one INSERT per item. Delegates to\n * `TableClient.insertMany`, which already caps a batch at 1000 rows —\n * comfortably above any bounded fan-out in this codebase today\n * (`MAX_DISPATCH_LINES` is 200).\n *\n * ⚠️ **All-or-nothing, not chunked.** `insertMany` does ONE statement for\n * the WHOLE array — there is no partial-success path. A caller that ever\n * exceeds 1000 entries gets every row rejected, not the first 1000\n * accepted and the rest dropped. Via `AuditLogger.logMany` that failure is\n * swallowed and logged (never thrown, by contract), so an over-cap batch\n * fails SILENTLY from the route's point of view — worse than the\n * pre-F124 per-row loop, which degraded one row at a time under the same\n * pressure. A future caller approaching 1000 needs its own chunking, not\n * an assumption that this method will do it.\n *\n * A no-op on an empty array — matches `TableClient.insertMany`, and\n * means a caller never has to guard `entries.length > 0` itself.\n */\n async writeMany(entries: readonly Omit<AuditLogEntry, 'id' | 'createdAt'>[]): Promise<void> {\n if (entries.length === 0) return\n await this.client.insertMany(\n entries.map((entry) => ({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })),\n )\n }\n\n /** Query audit logs with filters and pagination */\n async query(options: AuditLogQueryOptions = {}): Promise<AuditLogListResult> {\n const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)\n const offset = Math.max(options.offset ?? 0, 0)\n\n const where = this.buildWhere(options)\n const orderBy = this.buildOrderBy(options.sortField, options.sortDirection)\n\n const [items, total] = await Promise.all([\n this.client.findMany({ ...(where !== undefined && { where }), orderBy, limit, offset }),\n this.client.count(where),\n ])\n\n return {\n items: items as AuditLogEntry[],\n total,\n }\n }\n\n /** Get a single audit log entry by ID */\n async findById(id: string): Promise<AuditLogEntry | null> {\n return this.client.findOne({ id }) as Promise<AuditLogEntry | null>\n }\n\n /** Get distinct entity types present in the audit log */\n async getDistinctEntityTypes(): Promise<string[]> {\n // entityType is nullable, but distinct() excludes nulls by default —\n // the cast is safe because includeNull is not set.\n return this.client.distinct('entityType', { orderBy: 'asc' }) as Promise<string[]>\n }\n\n /** Get distinct actions present in the audit log */\n async getDistinctActions(): Promise<string[]> {\n return this.client.distinct('action', { orderBy: 'asc' })\n }\n\n // -------------------------------------------------------------------------\n // Private helpers\n // -------------------------------------------------------------------------\n\n private buildWhere(options: AuditLogQueryOptions): AuditWhere | undefined {\n const parts: AuditWhere[] = []\n\n if (options.action) parts.push({ action: options.action })\n if (options.entityType) parts.push({ entityType: options.entityType })\n if (options.entityId) parts.push({ entityId: options.entityId })\n // `!== undefined`, not truthiness: an empty array is a real filter meaning\n // \"none of them\", and dropping it would silently widen the query to every\n // entity of this type — the opposite of what the caller asked for.\n if (options.entityIds !== undefined) parts.push({ entityId: { in: [...options.entityIds] } })\n if (options.userId) parts.push({ userId: options.userId })\n\n // Date range: push each bound as its own typed clause. The\n // ColumnOperators union allows exactly one operator per object —\n // combining gte+lte requires two clauses AND-ed at the top level\n // (which buildWhere does below).\n if (options.dateFrom) {\n parts.push({ createdAt: { gte: options.dateFrom } })\n }\n if (options.dateTo) {\n parts.push({ createdAt: { lte: options.dateTo } })\n }\n\n // Free-text search across action, entityType, userName\n if (options.search) {\n parts.push({\n $or: [\n { action: { ilike: options.search } },\n { entityType: { ilike: options.search } },\n { userName: { ilike: options.search } },\n ],\n })\n }\n\n if (parts.length === 0) return undefined\n if (parts.length === 1) return parts[0]\n return { $and: parts }\n }\n\n private buildOrderBy(\n sortField?: string,\n sortDirection?: string,\n ): { column: keyof AuditCols & string; dir: 'asc' | 'desc' }[] {\n const dir: 'asc' | 'desc' = sortDirection === 'asc' ? 'asc' : 'desc'\n switch (sortField) {\n case 'action':\n return [{ column: 'action', dir }]\n case 'entityType':\n return [{ column: 'entityType', dir }]\n default:\n return [{ column: 'createdAt', dir }]\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./audit-table-BImbXt_s.mjs";var t=Object.defineProperty,n=((e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r})({AUDIT_QUERY_MAX_LIMIT:()=>100,AuditLogClient:()=>i});const r=100;var i=class{client;constructor(t){this.client=e.makeClient(t)}async write(e){await this.client.insert({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})}async writeMany(e){e.length!==0&&await this.client.insertMany(e.map(e=>({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})))}async query(e={}){let t=Math.min(Math.max(e.limit??50,1),100),n=Math.max(e.offset??0,0),r=this.buildWhere(e),i=this.buildOrderBy(e.sortField,e.sortDirection),[a,o]=await Promise.all([this.client.findMany({...r!==void 0&&{where:r},orderBy:i,limit:t,offset:n}),this.client.count(r)]);return{items:a,total:o}}async findById(e){return this.client.findOne({id:e})}async getDistinctEntityTypes(){return this.client.distinct(`entityType`,{orderBy:`asc`})}async getDistinctActions(){return this.client.distinct(`action`,{orderBy:`asc`})}buildWhere(e){let t=[];if(e.action&&t.push({action:e.action}),e.entityType&&t.push({entityType:e.entityType}),e.entityId&&t.push({entityId:e.entityId}),e.entityIds!==void 0&&t.push({entityId:{in:[...e.entityIds]}}),e.userId&&t.push({userId:e.userId}),e.dateFrom&&t.push({createdAt:{gte:e.dateFrom}}),e.dateTo&&t.push({createdAt:{lte:e.dateTo}}),e.search&&t.push({$or:[{action:{ilike:e.search}},{entityType:{ilike:e.search}},{userName:{ilike:e.search}}]}),t.length!==0)return t.length===1?t[0]:{$and:t}}buildOrderBy(e,t){let n=t===`asc`?`asc`:`desc`;switch(e){case`action`:return[{column:`action`,dir:n}];case`entityType`:return[{column:`entityType`,dir:n}];default:return[{column:`createdAt`,dir:n}]}}};export{i as n,n as r,r as t};
|
|
2
|
+
//# sourceMappingURL=audit-client-Cx8nIF9Q.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-client-Cx8nIF9Q.mjs","names":[],"sources":["../src/audit-client.ts"],"sourcesContent":["import type { TableClient, WhereClause } from '@murumets-ee/db'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { auditLogTable } from './audit-table.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface AuditLogQueryOptions {\n action?: string | undefined\n entityType?: string | undefined\n entityId?: string | undefined\n /**\n * Several entity ids at once — one query for a whole SET of rows rather than\n * one query per row.\n *\n * Added for `@murumets-ee/commerce`'s customer-facing per-line timeline, which\n * needs every line of one order in a single read: the per-line alternative is\n * `O(lines)` queries on a page load, which is the fan-out shape CLAUDE.md's\n * amplification rule refuses.\n *\n * AND-ed with `entityId` when both are given, which is degenerate but not\n * surprising; callers should pass one or the other. An EMPTY array matches\n * nothing (the clause is emitted with an empty `in`), which is the right answer\n * for \"no ids\" and not the same as omitting the filter.\n */\n entityIds?: readonly string[] | undefined\n userId?: string | undefined\n dateFrom?: Date | undefined\n dateTo?: Date | undefined\n /** Free-text search across action, entityType, userName */\n search?: string | undefined\n /** Default 50, max 100 */\n limit?: number | undefined\n offset?: number | undefined\n sortField?: 'createdAt' | 'action' | 'entityType' | undefined\n sortDirection?: 'asc' | 'desc' | undefined\n}\n\nexport interface AuditLogEntry {\n id: string\n action: string\n entityType: string | null\n entityId: string | null\n userId: string | null\n userName: string | null\n changes: Record<string, unknown> | null\n metadata: Record<string, unknown> | null\n createdAt: Date\n}\n\nexport interface AuditLogListResult {\n items: AuditLogEntry[]\n total: number\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/**\n * The most rows one {@link AuditLogClient.query} will return, whatever `limit`\n * asks for.\n *\n * EXPORTED because a consumer that discloses truncation has to know the clamp:\n * `items.length >= <copy of 100>` is a claim about this constant, and a\n * hand-copied one goes silently false the day this number moves — the flag stops\n * firing, the disclosure disappears, and nothing tests it because the two live in\n * different packages. `@murumets-ee/commerce`'s customer-facing per-line timeline\n * is the caller that needs it.\n */\nexport const AUDIT_QUERY_MAX_LIMIT = 100\nconst MAX_LIMIT = AUDIT_QUERY_MAX_LIMIT\nconst DEFAULT_LIMIT = 50\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\ntype AuditCols = typeof auditLogTable.schema.columns\ntype AuditWhere = WhereClause<AuditCols>\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\nexport class AuditLogClient {\n private client: TableClient<AuditCols>\n\n constructor(db: PostgresJsDatabase) {\n this.client = auditLogTable.makeClient(db)\n }\n\n /** Write a single audit log entry */\n async write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void> {\n await this.client.insert({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })\n }\n\n /**\n * Write N audit log entries in ONE multi-row INSERT (F124).\n *\n * `write()` called in a loop issues N round trips to the same pool a\n * caller's own transaction may still be using — this is the primitive\n * that lets a bulk route (dispatch, a future bulk admin action) emit one\n * audit row per affected item without one INSERT per item. Delegates to\n * `TableClient.insertMany`, which already caps a batch at 1000 rows —\n * comfortably above any bounded fan-out in this codebase today\n * (`MAX_DISPATCH_LINES` is 200).\n *\n * ⚠️ **All-or-nothing, not chunked.** `insertMany` does ONE statement for\n * the WHOLE array — there is no partial-success path. A caller that ever\n * exceeds 1000 entries gets every row rejected, not the first 1000\n * accepted and the rest dropped. Via `AuditLogger.logMany` that failure is\n * swallowed and logged (never thrown, by contract), so an over-cap batch\n * fails SILENTLY from the route's point of view — worse than the\n * pre-F124 per-row loop, which degraded one row at a time under the same\n * pressure. A future caller approaching 1000 needs its own chunking, not\n * an assumption that this method will do it.\n *\n * A no-op on an empty array — matches `TableClient.insertMany`, and\n * means a caller never has to guard `entries.length > 0` itself.\n */\n async writeMany(entries: readonly Omit<AuditLogEntry, 'id' | 'createdAt'>[]): Promise<void> {\n if (entries.length === 0) return\n await this.client.insertMany(\n entries.map((entry) => ({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })),\n )\n }\n\n /** Query audit logs with filters and pagination */\n async query(options: AuditLogQueryOptions = {}): Promise<AuditLogListResult> {\n const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)\n const offset = Math.max(options.offset ?? 0, 0)\n\n const where = this.buildWhere(options)\n const orderBy = this.buildOrderBy(options.sortField, options.sortDirection)\n\n const [items, total] = await Promise.all([\n this.client.findMany({ ...(where !== undefined && { where }), orderBy, limit, offset }),\n this.client.count(where),\n ])\n\n return {\n items: items as AuditLogEntry[],\n total,\n }\n }\n\n /** Get a single audit log entry by ID */\n async findById(id: string): Promise<AuditLogEntry | null> {\n return this.client.findOne({ id }) as Promise<AuditLogEntry | null>\n }\n\n /** Get distinct entity types present in the audit log */\n async getDistinctEntityTypes(): Promise<string[]> {\n // entityType is nullable, but distinct() excludes nulls by default —\n // the cast is safe because includeNull is not set.\n return this.client.distinct('entityType', { orderBy: 'asc' }) as Promise<string[]>\n }\n\n /** Get distinct actions present in the audit log */\n async getDistinctActions(): Promise<string[]> {\n return this.client.distinct('action', { orderBy: 'asc' })\n }\n\n // -------------------------------------------------------------------------\n // Private helpers\n // -------------------------------------------------------------------------\n\n private buildWhere(options: AuditLogQueryOptions): AuditWhere | undefined {\n const parts: AuditWhere[] = []\n\n if (options.action) parts.push({ action: options.action })\n if (options.entityType) parts.push({ entityType: options.entityType })\n if (options.entityId) parts.push({ entityId: options.entityId })\n // `!== undefined`, not truthiness: an empty array is a real filter meaning\n // \"none of them\", and dropping it would silently widen the query to every\n // entity of this type — the opposite of what the caller asked for.\n if (options.entityIds !== undefined) parts.push({ entityId: { in: [...options.entityIds] } })\n if (options.userId) parts.push({ userId: options.userId })\n\n // Date range: push each bound as its own typed clause. The\n // ColumnOperators union allows exactly one operator per object —\n // combining gte+lte requires two clauses AND-ed at the top level\n // (which buildWhere does below).\n if (options.dateFrom) {\n parts.push({ createdAt: { gte: options.dateFrom } })\n }\n if (options.dateTo) {\n parts.push({ createdAt: { lte: options.dateTo } })\n }\n\n // Free-text search across action, entityType, userName\n if (options.search) {\n parts.push({\n $or: [\n { action: { ilike: options.search } },\n { entityType: { ilike: options.search } },\n { userName: { ilike: options.search } },\n ],\n })\n }\n\n if (parts.length === 0) return undefined\n if (parts.length === 1) return parts[0]\n return { $and: parts }\n }\n\n private buildOrderBy(\n sortField?: string,\n sortDirection?: string,\n ): { column: keyof AuditCols & string; dir: 'asc' | 'desc' }[] {\n const dir: 'asc' | 'desc' = sortDirection === 'asc' ? 'asc' : 'desc'\n switch (sortField) {\n case 'action':\n return [{ column: 'action', dir }]\n case 'entityType':\n return [{ column: 'entityType', dir }]\n default:\n return [{ column: 'createdAt', dir }]\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
|
|
3
|
+
//#region src/audit-client.d.ts
|
|
4
|
+
interface AuditLogQueryOptions {
|
|
5
|
+
action?: string | undefined;
|
|
6
|
+
entityType?: string | undefined;
|
|
7
|
+
entityId?: string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Several entity ids at once — one query for a whole SET of rows rather than
|
|
10
|
+
* one query per row.
|
|
11
|
+
*
|
|
12
|
+
* Added for `@murumets-ee/commerce`'s customer-facing per-line timeline, which
|
|
13
|
+
* needs every line of one order in a single read: the per-line alternative is
|
|
14
|
+
* `O(lines)` queries on a page load, which is the fan-out shape CLAUDE.md's
|
|
15
|
+
* amplification rule refuses.
|
|
16
|
+
*
|
|
17
|
+
* AND-ed with `entityId` when both are given, which is degenerate but not
|
|
18
|
+
* surprising; callers should pass one or the other. An EMPTY array matches
|
|
19
|
+
* nothing (the clause is emitted with an empty `in`), which is the right answer
|
|
20
|
+
* for "no ids" and not the same as omitting the filter.
|
|
21
|
+
*/
|
|
22
|
+
entityIds?: readonly string[] | undefined;
|
|
23
|
+
userId?: string | undefined;
|
|
24
|
+
dateFrom?: Date | undefined;
|
|
25
|
+
dateTo?: Date | undefined;
|
|
26
|
+
/** Free-text search across action, entityType, userName */
|
|
27
|
+
search?: string | undefined;
|
|
28
|
+
/** Default 50, max 100 */
|
|
29
|
+
limit?: number | undefined;
|
|
30
|
+
offset?: number | undefined;
|
|
31
|
+
sortField?: 'createdAt' | 'action' | 'entityType' | undefined;
|
|
32
|
+
sortDirection?: 'asc' | 'desc' | undefined;
|
|
33
|
+
}
|
|
34
|
+
interface AuditLogEntry {
|
|
35
|
+
id: string;
|
|
36
|
+
action: string;
|
|
37
|
+
entityType: string | null;
|
|
38
|
+
entityId: string | null;
|
|
39
|
+
userId: string | null;
|
|
40
|
+
userName: string | null;
|
|
41
|
+
changes: Record<string, unknown> | null;
|
|
42
|
+
metadata: Record<string, unknown> | null;
|
|
43
|
+
createdAt: Date;
|
|
44
|
+
}
|
|
45
|
+
interface AuditLogListResult {
|
|
46
|
+
items: AuditLogEntry[];
|
|
47
|
+
total: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The most rows one {@link AuditLogClient.query} will return, whatever `limit`
|
|
51
|
+
* asks for.
|
|
52
|
+
*
|
|
53
|
+
* EXPORTED because a consumer that discloses truncation has to know the clamp:
|
|
54
|
+
* `items.length >= <copy of 100>` is a claim about this constant, and a
|
|
55
|
+
* hand-copied one goes silently false the day this number moves — the flag stops
|
|
56
|
+
* firing, the disclosure disappears, and nothing tests it because the two live in
|
|
57
|
+
* different packages. `@murumets-ee/commerce`'s customer-facing per-line timeline
|
|
58
|
+
* is the caller that needs it.
|
|
59
|
+
*/
|
|
60
|
+
declare const AUDIT_QUERY_MAX_LIMIT = 100;
|
|
61
|
+
declare class AuditLogClient {
|
|
62
|
+
private client;
|
|
63
|
+
constructor(db: PostgresJsDatabase);
|
|
64
|
+
/** Write a single audit log entry */
|
|
65
|
+
write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Write N audit log entries in ONE multi-row INSERT (F124).
|
|
68
|
+
*
|
|
69
|
+
* `write()` called in a loop issues N round trips to the same pool a
|
|
70
|
+
* caller's own transaction may still be using — this is the primitive
|
|
71
|
+
* that lets a bulk route (dispatch, a future bulk admin action) emit one
|
|
72
|
+
* audit row per affected item without one INSERT per item. Delegates to
|
|
73
|
+
* `TableClient.insertMany`, which already caps a batch at 1000 rows —
|
|
74
|
+
* comfortably above any bounded fan-out in this codebase today
|
|
75
|
+
* (`MAX_DISPATCH_LINES` is 200).
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ **All-or-nothing, not chunked.** `insertMany` does ONE statement for
|
|
78
|
+
* the WHOLE array — there is no partial-success path. A caller that ever
|
|
79
|
+
* exceeds 1000 entries gets every row rejected, not the first 1000
|
|
80
|
+
* accepted and the rest dropped. Via `AuditLogger.logMany` that failure is
|
|
81
|
+
* swallowed and logged (never thrown, by contract), so an over-cap batch
|
|
82
|
+
* fails SILENTLY from the route's point of view — worse than the
|
|
83
|
+
* pre-F124 per-row loop, which degraded one row at a time under the same
|
|
84
|
+
* pressure. A future caller approaching 1000 needs its own chunking, not
|
|
85
|
+
* an assumption that this method will do it.
|
|
86
|
+
*
|
|
87
|
+
* A no-op on an empty array — matches `TableClient.insertMany`, and
|
|
88
|
+
* means a caller never has to guard `entries.length > 0` itself.
|
|
89
|
+
*/
|
|
90
|
+
writeMany(entries: readonly Omit<AuditLogEntry, 'id' | 'createdAt'>[]): Promise<void>;
|
|
91
|
+
/** Query audit logs with filters and pagination */
|
|
92
|
+
query(options?: AuditLogQueryOptions): Promise<AuditLogListResult>;
|
|
93
|
+
/** Get a single audit log entry by ID */
|
|
94
|
+
findById(id: string): Promise<AuditLogEntry | null>;
|
|
95
|
+
/** Get distinct entity types present in the audit log */
|
|
96
|
+
getDistinctEntityTypes(): Promise<string[]>;
|
|
97
|
+
/** Get distinct actions present in the audit log */
|
|
98
|
+
getDistinctActions(): Promise<string[]>;
|
|
99
|
+
private buildWhere;
|
|
100
|
+
private buildOrderBy;
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
export { AuditLogQueryOptions as a, AuditLogListResult as i, AuditLogClient as n, AuditLogEntry as r, AUDIT_QUERY_MAX_LIMIT as t };
|
|
104
|
+
//# sourceMappingURL=audit-client-OqTb6E9_.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-client-OqTb6E9_.d.mts","names":[],"sources":["../src/audit-client.ts"],"mappings":";;;UAQiB,oBAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;;;;;;;;;;;;;;;EAeA,SAAA;EACA,MAAA;EACA,QAAA,GAAW,IAAA;EACX,MAAA,GAAS,IAAI;EAUE;EARf,MAAA;;EAEA,KAAA;EACA,MAAA;EACA,SAAA;EACA,aAAA;AAAA;AAAA,UAGe,aAAA;EACf,EAAA;EACA,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,EAAS,MAAA;EACT,QAAA,EAAU,MAAA;EACV,SAAA,EAAW,IAAA;AAAA;AAAA,UAGI,kBAAA;EACf,KAAA,EAAO,aAAa;EACpB,KAAA;AAAA;;;;;;;;AAAK;AAkBP;;;cAAa,qBAAA;AAAA,cAeA,cAAA;EAAA,QACH,MAAA;cAEI,EAAA,EAAI,kBAAA;;EAKV,KAAA,CAAM,KAAA,EAAO,IAAA,CAAK,aAAA,wBAAqC,OAAA;EAArC;;;;;;;;;;;;;;;;;;;;;;;;EAoClB,SAAA,CAAU,OAAA,WAAkB,IAAA,CAAK,aAAA,0BAAuC,OAAA;EAA5C;EAgB5B,KAAA,CAAM,OAAA,GAAS,oBAAA,GAA4B,OAAA,CAAQ,kBAAA;EAhBzC;EAmCV,QAAA,CAAS,EAAA,WAAa,OAAA,CAAQ,aAAA;EAnB9B;EAwBA,sBAAA,CAAA,GAA0B,OAAA;EAxBpB;EA+BN,kBAAA,CAAA,GAAsB,OAAA;EAAA,QAQpB,UAAA;EAAA,QAuCA,YAAA;AAAA"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as AuditLogQueryOptions, i as AuditLogListResult, n as AuditLogClient, r as AuditLogEntry, t as AUDIT_QUERY_MAX_LIMIT } from "./audit-client-OqTb6E9_.mjs";
|
|
2
2
|
import pino, { Logger as Logger$1 } from "pino";
|
|
3
3
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
4
|
|
|
@@ -45,6 +45,16 @@ interface AuditConfig {
|
|
|
45
45
|
*/
|
|
46
46
|
logger: Pick<Logger, 'info' | 'error'>;
|
|
47
47
|
dbWriter?: (entry: AuditEntry) => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Batched counterpart to {@link dbWriter} (F124) — ONE multi-row INSERT
|
|
50
|
+
* for N entries. Optional: a config that omits it but supplies
|
|
51
|
+
* `dbWriter` still persists every entry via {@link AuditLogger.logMany},
|
|
52
|
+
* degraded to N single-row writes awaited in sequence — a batch is
|
|
53
|
+
* never silently dropped just because the batched writer isn't wired.
|
|
54
|
+
* A config that omits BOTH logs to stdout only, the same as an omitted
|
|
55
|
+
* `dbWriter` leaves {@link AuditLogger.log}.
|
|
56
|
+
*/
|
|
57
|
+
dbWriterMany?: (entries: AuditEntry[]) => Promise<void>;
|
|
48
58
|
/**
|
|
49
59
|
* REPLACE the deny-list of object keys whose values are replaced with
|
|
50
60
|
* `[REDACTED]` when found anywhere inside `changes` / `metadata` before
|
|
@@ -65,6 +75,22 @@ interface AuditConfig {
|
|
|
65
75
|
}
|
|
66
76
|
interface AuditLogger {
|
|
67
77
|
log: (entry: AuditEntry) => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Batched write (F124) — ONE round trip for N entries instead of N.
|
|
80
|
+
*
|
|
81
|
+
* Optional so an existing hand-rolled `AuditLogger` (a test double, an
|
|
82
|
+
* app that has not re-generated its route wiring) stays valid without
|
|
83
|
+
* change — `createAuditLogger`'s own return value always implements it.
|
|
84
|
+
*
|
|
85
|
+
* Unlike {@link log}, which fires the DB write and returns without
|
|
86
|
+
* waiting for it (`dbWriter(...).catch(...)`, never awaited), `logMany`
|
|
87
|
+
* AWAITS its batched write internally before resolving — that is what
|
|
88
|
+
* gives a caller something to await (F124's whole point). It still
|
|
89
|
+
* NEVER REJECTS: a `dbWriterMany` failure is caught and logged, exactly
|
|
90
|
+
* like a `dbWriter` failure is today, so a batched write can never turn
|
|
91
|
+
* an already-committed state change into a 500 the operator retries.
|
|
92
|
+
*/
|
|
93
|
+
logMany?: (entries: AuditEntry[]) => Promise<void>;
|
|
68
94
|
}
|
|
69
95
|
/**
|
|
70
96
|
* Create a dbWriter callback wired to AuditLogClient.
|
|
@@ -78,6 +104,14 @@ interface AuditLogger {
|
|
|
78
104
|
* ```
|
|
79
105
|
*/
|
|
80
106
|
declare function createAuditDbWriter(db: PostgresJsDatabase): (entry: AuditEntry) => Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Create a batched dbWriter callback wired to `AuditLogClient.writeMany` (F124).
|
|
109
|
+
*
|
|
110
|
+
* Pass the returned function as `dbWriterMany` to `createAuditLogger`,
|
|
111
|
+
* alongside `dbWriter: createAuditDbWriter(db)` — the two are independent
|
|
112
|
+
* capabilities on the same client, not alternatives.
|
|
113
|
+
*/
|
|
114
|
+
declare function createAuditDbWriterMany(db: PostgresJsDatabase): (entries: AuditEntry[]) => Promise<void>;
|
|
81
115
|
/**
|
|
82
116
|
* Create an audit logger.
|
|
83
117
|
*
|
|
@@ -265,5 +299,5 @@ walkNonPlain?: boolean): unknown;
|
|
|
265
299
|
*/
|
|
266
300
|
declare function buildRedactPaths(keys?: readonly string[]): string[];
|
|
267
301
|
//#endregion
|
|
268
|
-
export { type AuditConfig, type AuditEntry, AuditLogClient, type AuditLogEntry, type AuditLogListResult, type AuditLogQueryOptions, type AuditLogger, CYCLE, type Logger, type LoggerConfig, MAX_LOG_REDACT_DEPTH, MAX_REDACT_DEPTH, REDACTED, SENSITIVE_KEYS, TRUNCATED, UNREADABLE, buildRedactPaths, createAuditDbWriter, createAuditLogger, createLogger, redactDeep, toDenySet, toolkitLoggerOptions };
|
|
302
|
+
export { AUDIT_QUERY_MAX_LIMIT, type AuditConfig, type AuditEntry, AuditLogClient, type AuditLogEntry, type AuditLogListResult, type AuditLogQueryOptions, type AuditLogger, CYCLE, type Logger, type LoggerConfig, MAX_LOG_REDACT_DEPTH, MAX_REDACT_DEPTH, REDACTED, SENSITIVE_KEYS, TRUNCATED, UNREADABLE, buildRedactPaths, createAuditDbWriter, createAuditDbWriterMany, createAuditLogger, createLogger, redactDeep, toDenySet, toolkitLoggerOptions };
|
|
269
303
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/logger.ts","../src/audit.ts","../src/redaction.ts"],"mappings":";;;;;KAGY,MAAA,GAAS,QAAU;AAAA,UAgFd,YAAA;EACf,IAAA;EACA,KAAK;AAAA;;;;AAlFwB;AAgF/B;;;;AAEO;AAmBP;;iBAAgB,oBAAA,CAAA,GAAwB,IAAA,CAAK,aAAa;;AAAA;AAkD1D;;;iBAAgB,YAAA,CAAa,MAAA,GAAS,YAAA,GAAe,MAAM;;;UCrJ1C,UAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,GAAU,MAAA;EACV,QAAA,GAAW,MAAM;AAAA;AAAA,UAGF,WAAA;;;ADsEV;AAmBP;;ECnFE,MAAA,EAAQ,IAAA,CAAK,MAAA;EACb,QAAA,IAAY,KAAA,EAAO,UAAA,KAAe,OAAA;EDkFsB;AAkD1D;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/logger.ts","../src/audit.ts","../src/redaction.ts"],"mappings":";;;;;KAGY,MAAA,GAAS,QAAU;AAAA,UAgFd,YAAA;EACf,IAAA;EACA,KAAK;AAAA;;;;AAlFwB;AAgF/B;;;;AAEO;AAmBP;;iBAAgB,oBAAA,CAAA,GAAwB,IAAA,CAAK,aAAa;;AAAA;AAkD1D;;;iBAAgB,YAAA,CAAa,MAAA,GAAS,YAAA,GAAe,MAAM;;;UCrJ1C,UAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,GAAU,MAAA;EACV,QAAA,GAAW,MAAM;AAAA;AAAA,UAGF,WAAA;;;ADsEV;AAmBP;;ECnFE,MAAA,EAAQ,IAAA,CAAK,MAAA;EACb,QAAA,IAAY,KAAA,EAAO,UAAA,KAAe,OAAA;EDkFsB;AAkD1D;;;;;;;;EC1HE,YAAA,IAAgB,OAAA,EAAS,UAAA,OAAiB,OAAA;;;;AA3B5C;;;;;;;;;;;;;EA4CE,UAAA;AAAA;AAAA,UAGe,WAAA;EACf,GAAA,GAAM,KAAA,EAAO,UAAA,KAAe,OAAA;;;;;;;;;;;;;;;;EAgB5B,OAAA,IAAW,OAAA,EAAS,UAAA,OAAiB,OAAA;AAAA;;;;;;AApB3B;AAGZ;;;;;iBAkDgB,mBAAA,CAAoB,EAAA,EAAI,kBAAA,IAAsB,KAAA,EAAO,UAAA,KAAe,OAAA;;;;;;;;iBAqBpE,uBAAA,CACd,EAAA,EAAI,kBAAA,IACF,OAAA,EAAS,UAAA,OAAiB,OAAA;;;;;AAxDgB;AAiC9C;;;;;;;;;;;iBAuDgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,WAAW;;;;;;;;AD1JnE;;;;AAA+B;AAgF/B;;;;AAEO;AAmBP;;;;AAA0D;AAkD1D;;;;;;;;AAA2D;;;;ACrJ3D;;;;;cCiCa,cAAA;AAAA,cAaA,QAAA;AAAA,cACA,SAAA;AAAA,cACA,KAAA;;;;;;cAOA,gBAAA;AD7Cb;;;;;;;;;;;;AAAA,cC2Da,oBAAA;;iBAGG,SAAA,CAAU,IAAA,sBAA0B,WAAW;;cAOlD,UAAA;;;;;;;;ADnCD;AAGZ;;;;;;;;;;;;;;;;;;AAiB8C;AAiC9C;;;;;;;;;;;;;;AAA2F;AAqB3F;;;;;;;;;;;;;;AAEqC;AAgCrC;;;;;;;;AAAmE;;;iBCiDnD,UAAA,CACd,KAAA,WACA,QAAA,GAAU,WAAA,UACV,MAAA,GAAQ,GAAA,UACR,KAAA,WACA,QAAA,WACA,IAAA,GAAM,GAAA,SAAY,GAAA;AA9KpB;;;;AAWU;AAEV;;;;AAAqB;AACrB;;;;AAAsB;AACtB;;;;AAAkB;;AAoLhB,YAAA;;;;AA7K2B;AAc7B;;;;AAAiC;AAGjC;;iBAgTgB,gBAAA,CAAiB,IAAwC"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{n as e,t}from"./audit-client-Cx8nIF9Q.mjs";import n from"pino";const r=[`password`,`passwordhash`,`token`,`secret`,`apikey`,`accesstoken`,`refreshtoken`,`sessiontoken`,`authorization`,`cookie`],i=`[REDACTED]`,a=`[TRUNCATED]`,o=`[CYCLE]`,s=8,c=12;function l(e){return new Set(e.map(e=>e.toLowerCase()))}const u=l(r),d=`[UNREADABLE]`;function f(e,t,n){Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0})}function p(e){let t={};for(let n of Object.keys(e)){let r;try{r=e[n]}catch{r=d}f(t,n,r)}return t}function m(e){let t=Array(e.length);for(let n=0;n<e.length;n++)try{t[n]=e[n]}catch{t[n]=d}return t}function h(e,t=u,n=new Set,r=0,s=8,c=new Map,l=!1){if(typeof e!=`object`||!e)return e;if(r>=s)return a;let g=e;if(n.has(g))return o;let _=c.get(g)?.get(r);if(_!==void 0)return _;let v=e=>{let t=c.get(g);return t||(t=new Map,c.set(g,t)),t.set(r,e),e};if(Array.isArray(e)){n.add(g);let i=e;for(let a=0;a<e.length;a++){let o,u=!0;try{o=e[a]}catch{u=!1,o=d}let f=h(o,t,n,r+1,s,c,l);(!u||f!==o)&&(i===e&&(i=m(e)),i[a]=f)}return n.delete(g),v(i)}let y=Object.getPrototypeOf(e);if(!l&&y!==Object.prototype&&y!==null)return e;n.add(g);let b=e,x=b;for(let e of Object.keys(b)){let a,o=!0;try{a=b[e]}catch{o=!1,a=d}let u=t.has(e.toLowerCase())?i:h(a,t,n,r+1,s,c,l);(!o||u!==a)&&(x===b&&(x=p(b)),f(x,e,u))}return n.delete(g),v(x)}const g=new Map([[`apikey`,[`apiKey`,`API_KEY`]],[`passwordhash`,[`passwordHash`]],[`accesstoken`,[`accessToken`,`access_token`]],[`refreshtoken`,[`refreshToken`,`refresh_token`]],[`sessiontoken`,[`sessionToken`,`session_token`]]]);function _(e){let t=e.toLowerCase(),n=t.charAt(0).toUpperCase()+t.slice(1);return[...new Set([t,n,t.toUpperCase(),...g.get(t)??[]])]}function v(e=r){return e.flatMap(_)}function y(e){let t=l(e??r);return e=>{if(!e.changes&&!e.metadata)return e;let n=new Set;return{...e,...e.changes?{changes:h(e.changes,t,n)}:{},...e.metadata?{metadata:h(e.metadata,t,n)}:{}}}}function b(t){let n=new e(t);return e=>n.write({action:e.action,entityType:e.entityType??null,entityId:e.entityId??null,userId:e.userId??null,userName:e.userName??null,changes:e.changes??null,metadata:e.metadata??null})}function x(t){let n=new e(t);return e=>n.writeMany(e.map(e=>({action:e.action,entityType:e.entityType??null,entityId:e.entityId??null,userId:e.userId??null,userName:e.userName??null,changes:e.changes??null,metadata:e.metadata??null})))}function S(e){let t=y(e.redactKeys);return{log:async n=>{let r=t(n);e.logger.info({audit:!0,action:r.action,entityType:r.entityType,entityId:r.entityId,userId:r.userId,userName:r.userName,changes:r.changes,metadata:r.metadata},`Audit: ${r.action}`),e.dbWriter&&e.dbWriter(r).catch(t=>{e.logger.error({err:t,auditEntry:r},`Failed to write audit log to database`)})},logMany:async n=>{if(n.length===0)return;let r=n.map(t);for(let t of r)e.logger.info({audit:!0,action:t.action,entityType:t.entityType,entityId:t.entityId,userId:t.userId,userName:t.userName,changes:t.changes,metadata:t.metadata},`Audit: ${t.action}`);if(e.dbWriterMany)try{await e.dbWriterMany(r)}catch(t){e.logger.error({err:t,auditEntryCount:r.length},`Failed to batch-write audit logs to database`)}else if(e.dbWriter)for(let t of r)try{await e.dbWriter(t)}catch(n){e.logger.error({err:n,auditEntry:t},`Failed to write audit log to database`)}}}}function C(e){try{return h(e,void 0,void 0,0,12)}catch{return e}}function w(e){let t=n.stdSerializers.err(e);if(typeof t!=`object`||!t||Array.isArray(t))return t;let r=h({...t},void 0,void 0,0,12,void 0,!0);return T(r,t)?t:r}function T(e,t){if(typeof e!=`object`||!e)return!1;let n=e,r=Object.keys(t);return r.length===Object.keys(n).length?r.every(e=>Object.is(n[e],t[e])):!1}function E(){return{level:process.env.LOG_LEVEL||`info`,formatters:{level:e=>({level:e}),log:C},timestamp:!1,serializers:{err:w,error:w},redact:{paths:v(),censor:i}}}const D=n(E());function O(e){if(!e)return D;let t={};e.name&&(t.name=e.name);let n={};return e.level&&(n.level=e.level),D.child(t,n)}export{t as AUDIT_QUERY_MAX_LIMIT,e as AuditLogClient,o as CYCLE,c as MAX_LOG_REDACT_DEPTH,s as MAX_REDACT_DEPTH,i as REDACTED,r as SENSITIVE_KEYS,a as TRUNCATED,d as UNREADABLE,v as buildRedactPaths,b as createAuditDbWriter,x as createAuditDbWriterMany,S as createAuditLogger,O as createLogger,h as redactDeep,l as toDenySet,E as toolkitLoggerOptions};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/redaction.ts","../src/audit.ts","../src/logger.ts"],"sourcesContent":["/**\n * The toolkit's ONE declaration of what must never reach a log line — and the\n * one walker that enforces it.\n *\n * ## Why this file exists\n *\n * There used to be two redaction surfaces that disagreed. The audit logger\n * walked a ten-name, case-insensitive deny-list to depth 8. The root pino\n * logger — which every `app.logger` call in the toolkit passes through —\n * declared six names, matched them as TOP-LEVEL PATHS only, was missing\n * `authorization` and `cookie` (the two names a credential actually arrives\n * under on an HTTP surface), and had no test. The weaker of the two was the\n * default path.\n *\n * A backstop that disagrees with the primary is a backstop nobody can reason\n * about (D034). So there is now one list, one walker, one set of semantics, and\n * both surfaces are built from them.\n *\n * ## This is the BACKSTOP, not the guarantee\n *\n * The guarantee is that a credential is never in the payload to begin with: what\n * gets logged and bound is a DERIVED whitelist — method, path, entity, status,\n * error code, correlation id, principal id, token id — never a raw `Request`,\n * `Headers`, credential or auth result. A field that is never in the payload\n * cannot be missed by a redactor, cannot be missed by a redactor at the wrong\n * nesting depth, and cannot be reintroduced by a future author adding a\n * convenient `{ req }` to a debug line.\n *\n * This file catches everyone who forgets that.\n */\n\n/**\n * Keys whose values are replaced with {@link REDACTED} wherever they appear.\n *\n * Matched case-INSENSITIVELY on the exact key name — `foo.password` matches,\n * `myPassword` does not. Lower-case here because that is the form the walker\n * compares against.\n */\nexport const SENSITIVE_KEYS: readonly string[] = [\n 'password',\n 'passwordhash',\n 'token',\n 'secret',\n 'apikey',\n 'accesstoken',\n 'refreshtoken',\n 'sessiontoken',\n 'authorization',\n 'cookie',\n] as const\n\nexport const REDACTED = '[REDACTED]'\nexport const TRUNCATED = '[TRUNCATED]'\nexport const CYCLE = '[CYCLE]'\n\n/**\n * Maximum object depth the AUDIT sanitizer walks. Anything past this becomes\n * {@link TRUNCATED} — a DISTINCT sentinel from {@link REDACTED}, so \"we gave up\n * walking\" cannot be misread as \"we wiped a credential\".\n */\nexport const MAX_REDACT_DEPTH = 8\n\n/**\n * Maximum depth the ROOT LOGGER's floor walks — deliberately DEEPER than\n * {@link MAX_REDACT_DEPTH}.\n *\n * An audit entry reaches the logger nested: `changes` sits at depth 1 of the\n * emitted line, and at depth 2 on the DB-write-failure line (`{ err, auditEntry\n * }`) — the one an operator uses to recover a lost audit row. At an equal cap\n * the outer walk would truncate a subtree the audit sanitizer deliberately\n * kept, so the surface that owns the payload would stop being the binding\n * constraint on it. Four levels of head-room covers the deepest nesting any\n * caller in this repo puts a walked payload under.\n */\nexport const MAX_LOG_REDACT_DEPTH = 12\n\n/** Lower-case a key list once, for the walker's `has` check. */\nexport function toDenySet(keys: readonly string[]): ReadonlySet<string> {\n return new Set(keys.map((k) => k.toLowerCase()))\n}\n\nconst DEFAULT_DENY = toDenySet(SENSITIVE_KEYS)\n\n/** Stand-in for a property whose getter threw while being read. */\nexport const UNREADABLE = '[UNREADABLE]'\n\n/**\n * Write `key` onto `out` as a plain data property.\n *\n * `out[key] = value` is wrong for exactly one key: `__proto__`. `Object.keys`\n * returns it as an OWN key on any object built with `Object.create(null)` or\n * `Object.defineProperty` — and this walker explicitly supports null-prototype\n * objects, which is where such a key actually comes from. A plain assignment\n * then invokes `Object.prototype.__proto__`'s setter on `out` instead of\n * defining a property, with two consequences: the key vanishes from the copy,\n * so its value disappears from the log line with no sentinel to show it was\n * there; and `out`'s own prototype is replaced, so what pino serializes is no\n * longer a plain object.\n *\n * The global `Object.prototype` is never at risk here — `out` is a fresh\n * literal — so this is silent data loss and a shape change, not prototype\n * pollution.\n */\nfunction defineOwn(out: Record<string, unknown>, key: string, value: unknown): void {\n Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true })\n}\n\n/**\n * Shallow copy of a plain object that cannot be defeated by a throwing getter\n * — which `{ ...source }` can, because the spread re-invokes every accessor.\n */\nfunction copyPlain(source: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(source)) {\n let value: unknown\n try {\n value = source[key]\n } catch {\n value = UNREADABLE\n }\n defineOwn(out, key, value)\n }\n return out\n}\n\n/** {@link copyPlain}'s array twin — `slice()` would re-invoke a throwing index accessor. */\nfunction copyArray(source: unknown[]): unknown[] {\n const out = new Array<unknown>(source.length)\n for (let i = 0; i < source.length; i++) {\n try {\n out[i] = source[i]\n } catch {\n out[i] = UNREADABLE\n }\n }\n return out\n}\n\n/**\n * Return `value` with every {@link SENSITIVE_KEYS} match replaced, cloning ONLY\n * the branches that changed.\n *\n * ## Copy-on-write, and why it matters here\n *\n * This runs on every log line in the toolkit via pino's `formatters.log`. The\n * overwhelmingly common case is a payload with nothing sensitive in it, and for\n * that case this returns the caller's own object by reference and allocates\n * nothing. Measured against the pinned pino: 2.5 µs/line with no floor at all,\n * 3.6 µs/line with this walker plus the path list — versus 56 µs/line for the\n * wildcard-path floor that would be needed to reach the same depth through\n * pino's own `redact` (its paths are per-level, so covering depth N costs one\n * path per key per level, and each level roughly doubles the cost).\n *\n * The caller's object is never mutated: a changed branch is copied before the\n * write.\n *\n * ## Non-plain objects are returned untouched, and that is load-bearing\n *\n * `formatters.log` runs BEFORE pino's `serializers`, so this walker receives the\n * raw `Error` instance on the 500 path, not the serializer's\n * `{ type, message, stack }` output. `Error.message` and `Error.stack` are\n * NON-ENUMERABLE, so a walker built on `Object.entries` emits `\"err\":{}` and\n * silently destroys every stack trace in the toolkit — with the unit tests and\n * the gates all green. The prototype check below is what prevents that.\n *\n * The consequence is a real hole and it is closed elsewhere rather than here: a\n * credential nested under a non-plain object — `axiosError.config.headers\n * .Authorization` is the shape that actually occurs — is not reachable by this\n * walk. `redactErrorSerializer` in `logger.ts` closes it by walking the\n * SERIALIZED error, which IS a plain object, so depth is covered and the stack\n * survives. Do not \"fix\" it by walking class instances here.\n *\n * ## Cycles are on the CURRENT PATH; shared subtrees are MEMOIZED\n *\n * A node is un-marked on the way back out, so a DAG — the same object\n * referenced twice from different branches, which is ordinary — keeps its\n * value rather than being reported as a cycle. A genuine back-edge still\n * becomes {@link CYCLE}.\n *\n * **Un-marking alone removes the only thing bounding revisits, and that is a\n * self-inflicted DoS rather than a performance note.** Measured on the\n * un-memoized version: a layered DAG of just NINE distinct objects (eight\n * levels each mapping ten keys onto the same child, plus the leaf) took 111\n * million node visits and blocked the event loop for 10.1 seconds —\n * synchronously, inside a log call. The bound was `fanout^depth`, which is not a\n * bound. The memo below restores it: a `(node, depth)` pair is computed at most\n * once, so total work is linear in nodes × depth.\n *\n * **What the memo bounds is THIS WALK, not the log call.** The same clone is\n * returned for every shared reference, and `JSON.stringify` then re-expands the\n * DAG into a tree regardless — so the same 81-object payload still costs\n * seconds and gigabytes at serialization time. That is pre-existing and\n * unchanged (pino expands a DAG with or without this walker); it is stated here\n * so nobody reads the paragraph above as a guarantee it does not make. Bounding\n * what a caller can put INTO a log payload is the consumer's job — see\n * `@murumets-ee/content-api`'s handler, which caps every caller-controlled\n * string and count it logs.\n *\n * One deliberate imprecision, stated because it is invisible otherwise: a\n * memoized subtree carries whatever {@link CYCLE} sentinels its FIRST traversal\n * produced, so on a mutual reference (`X.y = Y; Y.x = X` — which ORM and graph\n * payloads produce routinely, not just pathological ones) a `[CYCLE]` can\n * appear one branch too eagerly. The alternative is the unbounded walk above.\n * The output can never contain an actual cycle: a back-edge always forces a\n * copy up the chain, so there is no serialization hazard either way.\n */\nexport function redactDeep(\n value: unknown,\n denyKeys: ReadonlySet<string> = DEFAULT_DENY,\n onPath: Set<object> = new Set(),\n depth = 0,\n maxDepth: number = MAX_REDACT_DEPTH,\n memo: Map<object, Map<number, unknown>> = new Map(),\n /**\n * Walk objects that are NOT plain — `false` everywhere except inside\n * {@link redactErrorSerializer}, whose input is pino's already-serialized\n * error tree.\n *\n * The default is `false` because skipping non-plain objects is what keeps an\n * `Error`'s non-enumerable `message`/`stack` alive. Inside the error\n * serializer there is no live `Error` left to protect — pino has already\n * flattened each one (including nested error-like props and\n * `AggregateError.errors`) into objects on ITS OWN prototype, whose\n * `message`/`stack` are own-enumerable strings. Without this, a credential on\n * a nested error — `outer.inner.token`, `aggregate.errors[0].apiKey` — is\n * skipped, which is the hole the serializer exists to close.\n *\n * Copy-on-write is what makes it safe rather than merely useful: an object\n * with nothing sensitive in it is returned BY REFERENCE, so a `Date`, `Map`\n * or class instance keeps its identity and its `toJSON`. Only an object that\n * actually contained a credential is flattened, which is the right trade at\n * the point where the alternative is emitting the credential.\n */\n walkNonPlain = false,\n): unknown {\n if (value === null || value === undefined) return value\n if (typeof value !== 'object') return value\n // Checked AFTER the primitive cases, deliberately. The cap exists to bound\n // RECURSION into containers; a scalar has no children, so walking it costs\n // nothing and truncating it only destroys the value. Checking first replaced\n // a perfectly readable leaf at exactly `maxDepth` with `[TRUNCATED]` —\n // including in audit `changes`, where the leaf is the thing an operator came\n // for.\n if (depth >= maxDepth) return TRUNCATED\n\n const node = value as object\n // Checked BEFORE the memo: a back-edge is a property of the current path,\n // not of the node.\n if (onPath.has(node)) return CYCLE\n const cached = memo.get(node)?.get(depth)\n if (cached !== undefined) return cached\n\n const remember = (result: unknown): unknown => {\n let byDepth = memo.get(node)\n if (!byDepth) {\n byDepth = new Map()\n memo.set(node, byDepth)\n }\n byDepth.set(depth, result)\n return result\n }\n\n if (Array.isArray(value)) {\n onPath.add(node)\n let out = value\n for (let i = 0; i < value.length; i++) {\n // Guarded for the same reason the object branch is: an index accessor\n // that throws would escape the log call, and `slice()` would re-invoke\n // it during the copy. Rarer than a throwing property getter, but the\n // failure is identical — the outer catch falls back to emitting the\n // payload UNREDACTED, so one hostile element would disable the floor for\n // the whole line.\n let current: unknown\n let readable = true\n try {\n current = value[i]\n } catch {\n readable = false\n current = UNREADABLE\n }\n const next = redactDeep(current, denyKeys, onPath, depth + 1, maxDepth, memo, walkNonPlain)\n if (!readable || next !== current) {\n if (out === value) out = copyArray(value)\n out[i] = next\n }\n }\n onPath.delete(node)\n return remember(out)\n }\n\n // Date, Map, Set, Buffer, Error, class instances — returned as-is. See the\n // docblock: cloning these is what wipes an Error's stack.\n const proto = Object.getPrototypeOf(value)\n if (!walkNonPlain && proto !== Object.prototype && proto !== null) return value\n\n onPath.add(node)\n const source = value as Record<string, unknown>\n let out = source\n for (const key of Object.keys(source)) {\n // A getter that throws would otherwise escape an unguarded `logger.info`\n // and take down the call site — pino's own serializer degrades instead of\n // throwing, and a redactor must not be less forgiving than what it wraps.\n let current: unknown\n let readable = true\n try {\n current = source[key]\n } catch {\n readable = false\n current = UNREADABLE\n }\n const next = denyKeys.has(key.toLowerCase())\n ? REDACTED\n : redactDeep(current, denyKeys, onPath, depth + 1, maxDepth, memo, walkNonPlain)\n // `!readable` forces the copy even when `next === current`: leaving the\n // key on the original object would leave the throwing GETTER in place, and\n // pino would re-invoke it during serialization.\n if (!readable || next !== current) {\n if (out === source) out = copyPlain(source)\n // `defineOwn`, not `out[key] = next` — see its docblock: an own\n // `__proto__` key would otherwise hit the inherited setter, dropping the\n // key and swapping the clone's prototype.\n defineOwn(out, key, next)\n }\n }\n onPath.delete(node)\n return remember(out)\n}\n\n/**\n * The spellings of a sensitive key that pino's paths must name explicitly.\n *\n * pino compares `redact.paths` VERBATIM — `authorization` does not match\n * `Authorization` — and paths are the only mechanism that reaches child-logger\n * bindings, which never pass through `formatters.log`. So a binding is the one\n * place case matters, and `logger.child({ Authorization })` is exactly how a\n * header-shaped value gets spelled.\n *\n * The camelCase forms are listed rather than derived because they are not\n * derivable: `apikey` → `apiKey`, `passwordhash` → `passwordHash`. An unknown\n * key contributes only its lower/Capitalised/UPPER forms, which is the honest\n * result — the walker is what covers arbitrary casing everywhere else.\n */\n/**\n * A `Map`, not an object literal, and the reason is the same hostile-key\n * problem `defineOwn` exists for — reached from the other side.\n *\n * `buildRedactPaths` is exported and takes an arbitrary key list. On an object\n * literal, `CASE_VARIANTS[key]` resolves INHERITED members: `'constructor'`\n * returns `Object.prototype.constructor` and `'__proto__'` returns\n * `Object.prototype`. Neither is `undefined`, so `?? []` does not fire, and the\n * spread throws `TypeError: … is not iterable` — crashing logger construction\n * at import time, for a caller who did nothing worse than deny-list a key named\n * `constructor`. A `Map` has no prototype chain to fall through.\n */\nconst CASE_VARIANTS: ReadonlyMap<string, readonly string[]> = new Map([\n ['apikey', ['apiKey', 'API_KEY']],\n ['passwordhash', ['passwordHash']],\n ['accesstoken', ['accessToken', 'access_token']],\n ['refreshtoken', ['refreshToken', 'refresh_token']],\n ['sessiontoken', ['sessionToken', 'session_token']],\n])\n\nfunction spellingsOf(key: string): string[] {\n const lower = key.toLowerCase()\n const capitalised = lower.charAt(0).toUpperCase() + lower.slice(1)\n return [\n ...new Set([lower, capitalised, lower.toUpperCase(), ...(CASE_VARIANTS.get(lower) ?? [])]),\n ]\n}\n\n/**\n * pino `redact.paths` covering what {@link redactDeep} structurally cannot:\n * child-logger BINDINGS (which never reach `formatters.log`).\n *\n * Every entry is a LITERAL path. That is not incidental — a literal path costs\n * nothing however many are declared (40 literal paths measured at 2.3 µs/line,\n * i.e. baseline), while each `*` wildcard level multiplies the per-line cost,\n * reaching 22× at the four levels that would be needed to match the walker.\n * Depth is the walker's job; this list buys breadth at the top level, where it\n * is free.\n */\nexport function buildRedactPaths(keys: readonly string[] = SENSITIVE_KEYS): string[] {\n return keys.flatMap(spellingsOf)\n}\n","import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { AuditLogClient } from './audit-client.js'\nimport type { Logger } from './logger.js'\nimport { redactDeep, SENSITIVE_KEYS, toDenySet } from './redaction.js'\n\nexport interface AuditEntry {\n action: string\n entityType?: string\n entityId?: string\n userId?: string\n userName?: string\n changes?: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface AuditConfig {\n /**\n * The audit logger only ever calls `info` and `error` — narrowing the\n * accepted shape avoids forcing tests and adapters to construct or fake\n * the full ~30-method Pino surface.\n */\n logger: Pick<Logger, 'info' | 'error'>\n dbWriter?: (entry: AuditEntry) => Promise<void>\n /**\n * REPLACE the deny-list of object keys whose values are replaced with\n * `[REDACTED]` when found anywhere inside `changes` / `metadata` before\n * the entry is logged or persisted. Matching is case-insensitive on the\n * exact key name (no substring match — `foo.password` matches, `myPassword`\n * does not).\n *\n * **Replace, not merge.** Passing `['ssn']` removes `password`/`token`/etc.\n * from the deny-list; if you want to *add* keys while keeping defaults,\n * spread them yourself: `redactKeys: [...SENSITIVE_KEYS, 'ssn']`\n * (`SENSITIVE_KEYS` is exported from this package).\n *\n * Defaults to {@link SENSITIVE_KEYS} — the SAME declaration the root\n * logger's redaction floor is built from, so the two surfaces cannot drift\n * apart again (D034).\n */\n redactKeys?: readonly string[]\n}\n\nexport interface AuditLogger {\n log: (entry: AuditEntry) => Promise<void>\n}\n\nfunction buildSanitizer(redactKeys?: readonly string[]): (entry: AuditEntry) => AuditEntry {\n const denyKeys = toDenySet(redactKeys ?? SENSITIVE_KEYS)\n return (entry) => {\n if (!entry.changes && !entry.metadata) return entry\n // Per-call path set so a sanitized payload isn't poisoned by a prior\n // call's reference graph.\n const onPath = new Set<object>()\n return {\n ...entry,\n ...(entry.changes\n ? { changes: redactDeep(entry.changes, denyKeys, onPath) as Record<string, unknown> }\n : {}),\n ...(entry.metadata\n ? { metadata: redactDeep(entry.metadata, denyKeys, onPath) as Record<string, unknown> }\n : {}),\n }\n }\n}\n\n/**\n * Create a dbWriter callback wired to AuditLogClient.\n *\n * Pass the returned function as `dbWriter` to `createAuditLogger`:\n * ```typescript\n * const auditLogger = createAuditLogger({\n * logger,\n * dbWriter: createAuditDbWriter(db),\n * })\n * ```\n */\nexport function createAuditDbWriter(db: PostgresJsDatabase): (entry: AuditEntry) => Promise<void> {\n const client = new AuditLogClient(db)\n return (entry) =>\n client.write({\n action: entry.action,\n entityType: entry.entityType ?? null,\n entityId: entry.entityId ?? null,\n userId: entry.userId ?? null,\n userName: entry.userName ?? null,\n changes: entry.changes ?? null,\n metadata: entry.metadata ?? null,\n })\n}\n\n/**\n * Create an audit logger.\n *\n * Audit logs are written to stdout immediately (non-blocking).\n * Optionally, they can also be written to the database if dbWriter is provided.\n *\n * Database writes are fire-and-forget to avoid blocking the main operation.\n * If DB write fails, it's logged to stderr but doesn't throw.\n *\n * **Redaction.** Both stdout and DB writes pass `changes` and `metadata`\n * through a recursive key-based sanitizer (see `redactKeys`). It matters for\n * the DB write in particular: the root logger's floor covers the stdout copy\n * but nothing covers a row on its way into `toolkit_audit_logs`. Defense in\n * depth — callers should still avoid putting secrets into audit payloads in\n * the first place.\n */\nexport function createAuditLogger(config: AuditConfig): AuditLogger {\n const sanitize = buildSanitizer(config.redactKeys)\n return {\n log: async (entry: AuditEntry) => {\n const safe = sanitize(entry)\n\n // Log to stdout immediately (structured log with audit: true marker)\n config.logger.info(\n {\n audit: true,\n action: safe.action,\n entityType: safe.entityType,\n entityId: safe.entityId,\n userId: safe.userId,\n userName: safe.userName,\n changes: safe.changes,\n metadata: safe.metadata,\n },\n `Audit: ${safe.action}`,\n )\n\n // Write to DB if configured (fire and forget)\n if (config.dbWriter) {\n config.dbWriter(safe).catch((err) => {\n config.logger.error({ err, auditEntry: safe }, 'Failed to write audit log to database')\n })\n }\n },\n }\n}\n","import pino, { type Logger as PinoLogger } from 'pino'\nimport { buildRedactPaths, MAX_LOG_REDACT_DEPTH, REDACTED, redactDeep } from './redaction.js'\n\nexport type Logger = PinoLogger\n\n/**\n * Walk a log payload for credentials, and NEVER be the reason a log call\n * throws.\n *\n * pino's own serializer degrades gracefully on a hostile object (it emits\n * `[unable to serialize …]`), so a redactor wrapping it must not be less\n * forgiving. `redactDeep` already contains a throwing getter; this catches\n * anything exotic enough to get past that — at the cost of emitting the\n * unredacted payload, which is the correct trade only because it is the\n * BACKSTOP: the guarantee is that the payload has no credential in it.\n */\nfunction redactLogObject(obj: Record<string, unknown>): Record<string, unknown> {\n try {\n return redactDeep(obj, undefined, undefined, 0, MAX_LOG_REDACT_DEPTH) as Record<string, unknown>\n } catch {\n return obj\n }\n}\n\n/**\n * `pino.stdSerializers.err`, then redacted.\n *\n * This closes the hole `redactDeep` structurally cannot: it skips non-plain\n * objects so an `Error`'s non-enumerable `message`/`stack` survive, which means\n * a credential hanging off an error instance is invisible to it. The most\n * common real shape is an HTTP client's error — `axiosError.config.headers\n * .Authorization`, `err.response.request.headers.cookie` — and `{ err }` is how\n * this repo logs every one of them (`@murumets-ee/notifications`,\n * `@murumets-ee/merit`'s transport, …).\n *\n * The serializer's OUTPUT is a plain object, so walking it covers arbitrary\n * depth with the same case-insensitive deny-list as everything else, and the\n * stack is already a string by then.\n */\nfunction redactErrorSerializer(err: unknown): unknown {\n // NOT typed `Error`. pino's `SerializerFn` is `(value: any) => any` and pino\n // calls it with whatever sits under the key, so a typed parameter here is a\n // claim about the caller that the caller does not honour. `stdSerializers.err`\n // itself returns its input UNCHANGED for anything not error-like\n // (`typeof v.message === 'string'`), and this repo logs\n // `{ error: String(err) }` in several places — spreading that string would\n // emit a character-index map (`{\"0\":\"E\",\"1\":\"r\",…}`) instead of the message.\n const serialized: unknown = pino.stdSerializers.err(err as Error)\n if (typeof serialized !== 'object' || serialized === null || Array.isArray(serialized)) {\n return serialized\n }\n\n // `walkNonPlain` — pino builds the serialized tree on its OWN prototype, at\n // every level, so the default plain-object guard would skip the whole thing\n // and this serializer would do nothing at all. Its input has no live `Error`\n // left to protect; see the option's docblock.\n const redacted = redactDeep(\n { ...(serialized as Record<string, unknown>) },\n undefined,\n undefined,\n 0,\n MAX_LOG_REDACT_DEPTH,\n undefined,\n true,\n )\n\n // Nothing was redacted → hand back pino's OWN object, prototype intact. The\n // spread is a plain copy, which silently drops the non-enumerable `raw`\n // accessor and the raw-error symbol that transports and `wrapErrorSerializer`\n // consumers read. Paying that only when a credential was actually found keeps\n // the contract whole on every ordinary error, which is all of them.\n return isSameShallow(redacted, serialized as Record<string, unknown>) ? serialized : redacted\n}\n\n/** Did the walk change anything? Compared by VALUE at the top level, because the copy is always a new object. */\nfunction isSameShallow(redacted: unknown, original: Record<string, unknown>): boolean {\n if (typeof redacted !== 'object' || redacted === null) return false\n const candidate = redacted as Record<string, unknown>\n const keys = Object.keys(original)\n if (keys.length !== Object.keys(candidate).length) return false\n return keys.every((k) => Object.is(candidate[k], original[k]))\n}\n\nexport interface LoggerConfig {\n name?: string\n level?: string\n // `redact?: string[]` used to sit here and `createLogger` never read it — a\n // field that looks like a per-logger deny-list and silently is not, which is\n // the worst possible shape for a security control. Removed rather than\n // implemented: the floor is deliberately one shared declaration\n // (`SENSITIVE_KEYS`), and a per-child override could only ever narrow it.\n}\n\n/**\n * The toolkit's root-logger configuration, as a value.\n *\n * Exported because the redaction floor below is a security property, and a\n * property is only known to hold if something asserts it. The root logger\n * writes to fd 1 through SonicBoom, which `process.stdout.write` spies cannot\n * observe — so the only way to assert the floor is to build an identical\n * logger over a capture stream. Returning the options the root logger is\n * ACTUALLY constructed from (rather than a copy in the test) is what keeps\n * that assertion honest: there is one expression, used twice.\n */\nexport function toolkitLoggerOptions(): pino.LoggerOptions {\n return {\n level: process.env.LOG_LEVEL || 'info',\n formatters: {\n level: (label) => ({ level: label }),\n /**\n * The redaction floor's DEPTH half — see `redaction.ts` for why the\n * three halves are needed and why this one is copy-on-write.\n *\n * Runs before `serializers`, so it receives a raw `Error` on the 500\n * path; `redactDeep` returns non-plain objects untouched precisely so\n * the stack survives to the operator, and `redactErrorSerializer` is\n * what covers what is nested inside one.\n */\n log: redactLogObject,\n },\n timestamp: false,\n serializers: {\n err: redactErrorSerializer,\n error: redactErrorSerializer,\n },\n /**\n * The floor's BINDINGS half. `formatters.log` never sees child-logger\n * bindings; these literal paths do. Literal (never wildcard) on purpose —\n * a wildcard path costs per level and would put a 22× tax on every log\n * line in the toolkit to buy depth the walker already covers for free.\n */\n redact: {\n paths: buildRedactPaths(),\n censor: REDACTED,\n },\n }\n}\n\n/**\n * Root logger instance\n *\n * IMPORTANT: No transports are used to avoid Next.js bundler issues.\n * Pino transports use worker threads that break webpack/turbopack.\n *\n * For dev pretty-printing, pipe to pino-pretty:\n * pnpm dev | pnpm pino-pretty\n */\nconst rootLogger = pino(toolkitLoggerOptions())\n\n/**\n * Create a logger instance\n * If no config provided, returns the root logger\n * If config provided, returns a child logger with the specified context\n */\nexport function createLogger(config?: LoggerConfig): Logger {\n if (!config) {\n return rootLogger\n }\n\n const bindings: Record<string, unknown> = {}\n if (config.name) {\n bindings.name = config.name\n }\n\n const options: { level?: string } = {}\n if (config.level) {\n options.level = config.level\n }\n\n return rootLogger.child(bindings, options)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/redaction.ts","../src/audit.ts","../src/logger.ts"],"sourcesContent":["/**\n * The toolkit's ONE declaration of what must never reach a log line — and the\n * one walker that enforces it.\n *\n * ## Why this file exists\n *\n * There used to be two redaction surfaces that disagreed. The audit logger\n * walked a ten-name, case-insensitive deny-list to depth 8. The root pino\n * logger — which every `app.logger` call in the toolkit passes through —\n * declared six names, matched them as TOP-LEVEL PATHS only, was missing\n * `authorization` and `cookie` (the two names a credential actually arrives\n * under on an HTTP surface), and had no test. The weaker of the two was the\n * default path.\n *\n * A backstop that disagrees with the primary is a backstop nobody can reason\n * about (D034). So there is now one list, one walker, one set of semantics, and\n * both surfaces are built from them.\n *\n * ## This is the BACKSTOP, not the guarantee\n *\n * The guarantee is that a credential is never in the payload to begin with: what\n * gets logged and bound is a DERIVED whitelist — method, path, entity, status,\n * error code, correlation id, principal id, token id — never a raw `Request`,\n * `Headers`, credential or auth result. A field that is never in the payload\n * cannot be missed by a redactor, cannot be missed by a redactor at the wrong\n * nesting depth, and cannot be reintroduced by a future author adding a\n * convenient `{ req }` to a debug line.\n *\n * This file catches everyone who forgets that.\n */\n\n/**\n * Keys whose values are replaced with {@link REDACTED} wherever they appear.\n *\n * Matched case-INSENSITIVELY on the exact key name — `foo.password` matches,\n * `myPassword` does not. Lower-case here because that is the form the walker\n * compares against.\n */\nexport const SENSITIVE_KEYS: readonly string[] = [\n 'password',\n 'passwordhash',\n 'token',\n 'secret',\n 'apikey',\n 'accesstoken',\n 'refreshtoken',\n 'sessiontoken',\n 'authorization',\n 'cookie',\n] as const\n\nexport const REDACTED = '[REDACTED]'\nexport const TRUNCATED = '[TRUNCATED]'\nexport const CYCLE = '[CYCLE]'\n\n/**\n * Maximum object depth the AUDIT sanitizer walks. Anything past this becomes\n * {@link TRUNCATED} — a DISTINCT sentinel from {@link REDACTED}, so \"we gave up\n * walking\" cannot be misread as \"we wiped a credential\".\n */\nexport const MAX_REDACT_DEPTH = 8\n\n/**\n * Maximum depth the ROOT LOGGER's floor walks — deliberately DEEPER than\n * {@link MAX_REDACT_DEPTH}.\n *\n * An audit entry reaches the logger nested: `changes` sits at depth 1 of the\n * emitted line, and at depth 2 on the DB-write-failure line (`{ err, auditEntry\n * }`) — the one an operator uses to recover a lost audit row. At an equal cap\n * the outer walk would truncate a subtree the audit sanitizer deliberately\n * kept, so the surface that owns the payload would stop being the binding\n * constraint on it. Four levels of head-room covers the deepest nesting any\n * caller in this repo puts a walked payload under.\n */\nexport const MAX_LOG_REDACT_DEPTH = 12\n\n/** Lower-case a key list once, for the walker's `has` check. */\nexport function toDenySet(keys: readonly string[]): ReadonlySet<string> {\n return new Set(keys.map((k) => k.toLowerCase()))\n}\n\nconst DEFAULT_DENY = toDenySet(SENSITIVE_KEYS)\n\n/** Stand-in for a property whose getter threw while being read. */\nexport const UNREADABLE = '[UNREADABLE]'\n\n/**\n * Write `key` onto `out` as a plain data property.\n *\n * `out[key] = value` is wrong for exactly one key: `__proto__`. `Object.keys`\n * returns it as an OWN key on any object built with `Object.create(null)` or\n * `Object.defineProperty` — and this walker explicitly supports null-prototype\n * objects, which is where such a key actually comes from. A plain assignment\n * then invokes `Object.prototype.__proto__`'s setter on `out` instead of\n * defining a property, with two consequences: the key vanishes from the copy,\n * so its value disappears from the log line with no sentinel to show it was\n * there; and `out`'s own prototype is replaced, so what pino serializes is no\n * longer a plain object.\n *\n * The global `Object.prototype` is never at risk here — `out` is a fresh\n * literal — so this is silent data loss and a shape change, not prototype\n * pollution.\n */\nfunction defineOwn(out: Record<string, unknown>, key: string, value: unknown): void {\n Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true })\n}\n\n/**\n * Shallow copy of a plain object that cannot be defeated by a throwing getter\n * — which `{ ...source }` can, because the spread re-invokes every accessor.\n */\nfunction copyPlain(source: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(source)) {\n let value: unknown\n try {\n value = source[key]\n } catch {\n value = UNREADABLE\n }\n defineOwn(out, key, value)\n }\n return out\n}\n\n/** {@link copyPlain}'s array twin — `slice()` would re-invoke a throwing index accessor. */\nfunction copyArray(source: unknown[]): unknown[] {\n const out = new Array<unknown>(source.length)\n for (let i = 0; i < source.length; i++) {\n try {\n out[i] = source[i]\n } catch {\n out[i] = UNREADABLE\n }\n }\n return out\n}\n\n/**\n * Return `value` with every {@link SENSITIVE_KEYS} match replaced, cloning ONLY\n * the branches that changed.\n *\n * ## Copy-on-write, and why it matters here\n *\n * This runs on every log line in the toolkit via pino's `formatters.log`. The\n * overwhelmingly common case is a payload with nothing sensitive in it, and for\n * that case this returns the caller's own object by reference and allocates\n * nothing. Measured against the pinned pino: 2.5 µs/line with no floor at all,\n * 3.6 µs/line with this walker plus the path list — versus 56 µs/line for the\n * wildcard-path floor that would be needed to reach the same depth through\n * pino's own `redact` (its paths are per-level, so covering depth N costs one\n * path per key per level, and each level roughly doubles the cost).\n *\n * The caller's object is never mutated: a changed branch is copied before the\n * write.\n *\n * ## Non-plain objects are returned untouched, and that is load-bearing\n *\n * `formatters.log` runs BEFORE pino's `serializers`, so this walker receives the\n * raw `Error` instance on the 500 path, not the serializer's\n * `{ type, message, stack }` output. `Error.message` and `Error.stack` are\n * NON-ENUMERABLE, so a walker built on `Object.entries` emits `\"err\":{}` and\n * silently destroys every stack trace in the toolkit — with the unit tests and\n * the gates all green. The prototype check below is what prevents that.\n *\n * The consequence is a real hole and it is closed elsewhere rather than here: a\n * credential nested under a non-plain object — `axiosError.config.headers\n * .Authorization` is the shape that actually occurs — is not reachable by this\n * walk. `redactErrorSerializer` in `logger.ts` closes it by walking the\n * SERIALIZED error, which IS a plain object, so depth is covered and the stack\n * survives. Do not \"fix\" it by walking class instances here.\n *\n * ## Cycles are on the CURRENT PATH; shared subtrees are MEMOIZED\n *\n * A node is un-marked on the way back out, so a DAG — the same object\n * referenced twice from different branches, which is ordinary — keeps its\n * value rather than being reported as a cycle. A genuine back-edge still\n * becomes {@link CYCLE}.\n *\n * **Un-marking alone removes the only thing bounding revisits, and that is a\n * self-inflicted DoS rather than a performance note.** Measured on the\n * un-memoized version: a layered DAG of just NINE distinct objects (eight\n * levels each mapping ten keys onto the same child, plus the leaf) took 111\n * million node visits and blocked the event loop for 10.1 seconds —\n * synchronously, inside a log call. The bound was `fanout^depth`, which is not a\n * bound. The memo below restores it: a `(node, depth)` pair is computed at most\n * once, so total work is linear in nodes × depth.\n *\n * **What the memo bounds is THIS WALK, not the log call.** The same clone is\n * returned for every shared reference, and `JSON.stringify` then re-expands the\n * DAG into a tree regardless — so the same 81-object payload still costs\n * seconds and gigabytes at serialization time. That is pre-existing and\n * unchanged (pino expands a DAG with or without this walker); it is stated here\n * so nobody reads the paragraph above as a guarantee it does not make. Bounding\n * what a caller can put INTO a log payload is the consumer's job — see\n * `@murumets-ee/content-api`'s handler, which caps every caller-controlled\n * string and count it logs.\n *\n * One deliberate imprecision, stated because it is invisible otherwise: a\n * memoized subtree carries whatever {@link CYCLE} sentinels its FIRST traversal\n * produced, so on a mutual reference (`X.y = Y; Y.x = X` — which ORM and graph\n * payloads produce routinely, not just pathological ones) a `[CYCLE]` can\n * appear one branch too eagerly. The alternative is the unbounded walk above.\n * The output can never contain an actual cycle: a back-edge always forces a\n * copy up the chain, so there is no serialization hazard either way.\n */\nexport function redactDeep(\n value: unknown,\n denyKeys: ReadonlySet<string> = DEFAULT_DENY,\n onPath: Set<object> = new Set(),\n depth = 0,\n maxDepth: number = MAX_REDACT_DEPTH,\n memo: Map<object, Map<number, unknown>> = new Map(),\n /**\n * Walk objects that are NOT plain — `false` everywhere except inside\n * {@link redactErrorSerializer}, whose input is pino's already-serialized\n * error tree.\n *\n * The default is `false` because skipping non-plain objects is what keeps an\n * `Error`'s non-enumerable `message`/`stack` alive. Inside the error\n * serializer there is no live `Error` left to protect — pino has already\n * flattened each one (including nested error-like props and\n * `AggregateError.errors`) into objects on ITS OWN prototype, whose\n * `message`/`stack` are own-enumerable strings. Without this, a credential on\n * a nested error — `outer.inner.token`, `aggregate.errors[0].apiKey` — is\n * skipped, which is the hole the serializer exists to close.\n *\n * Copy-on-write is what makes it safe rather than merely useful: an object\n * with nothing sensitive in it is returned BY REFERENCE, so a `Date`, `Map`\n * or class instance keeps its identity and its `toJSON`. Only an object that\n * actually contained a credential is flattened, which is the right trade at\n * the point where the alternative is emitting the credential.\n */\n walkNonPlain = false,\n): unknown {\n if (value === null || value === undefined) return value\n if (typeof value !== 'object') return value\n // Checked AFTER the primitive cases, deliberately. The cap exists to bound\n // RECURSION into containers; a scalar has no children, so walking it costs\n // nothing and truncating it only destroys the value. Checking first replaced\n // a perfectly readable leaf at exactly `maxDepth` with `[TRUNCATED]` —\n // including in audit `changes`, where the leaf is the thing an operator came\n // for.\n if (depth >= maxDepth) return TRUNCATED\n\n const node = value as object\n // Checked BEFORE the memo: a back-edge is a property of the current path,\n // not of the node.\n if (onPath.has(node)) return CYCLE\n const cached = memo.get(node)?.get(depth)\n if (cached !== undefined) return cached\n\n const remember = (result: unknown): unknown => {\n let byDepth = memo.get(node)\n if (!byDepth) {\n byDepth = new Map()\n memo.set(node, byDepth)\n }\n byDepth.set(depth, result)\n return result\n }\n\n if (Array.isArray(value)) {\n onPath.add(node)\n let out = value\n for (let i = 0; i < value.length; i++) {\n // Guarded for the same reason the object branch is: an index accessor\n // that throws would escape the log call, and `slice()` would re-invoke\n // it during the copy. Rarer than a throwing property getter, but the\n // failure is identical — the outer catch falls back to emitting the\n // payload UNREDACTED, so one hostile element would disable the floor for\n // the whole line.\n let current: unknown\n let readable = true\n try {\n current = value[i]\n } catch {\n readable = false\n current = UNREADABLE\n }\n const next = redactDeep(current, denyKeys, onPath, depth + 1, maxDepth, memo, walkNonPlain)\n if (!readable || next !== current) {\n if (out === value) out = copyArray(value)\n out[i] = next\n }\n }\n onPath.delete(node)\n return remember(out)\n }\n\n // Date, Map, Set, Buffer, Error, class instances — returned as-is. See the\n // docblock: cloning these is what wipes an Error's stack.\n const proto = Object.getPrototypeOf(value)\n if (!walkNonPlain && proto !== Object.prototype && proto !== null) return value\n\n onPath.add(node)\n const source = value as Record<string, unknown>\n let out = source\n for (const key of Object.keys(source)) {\n // A getter that throws would otherwise escape an unguarded `logger.info`\n // and take down the call site — pino's own serializer degrades instead of\n // throwing, and a redactor must not be less forgiving than what it wraps.\n let current: unknown\n let readable = true\n try {\n current = source[key]\n } catch {\n readable = false\n current = UNREADABLE\n }\n const next = denyKeys.has(key.toLowerCase())\n ? REDACTED\n : redactDeep(current, denyKeys, onPath, depth + 1, maxDepth, memo, walkNonPlain)\n // `!readable` forces the copy even when `next === current`: leaving the\n // key on the original object would leave the throwing GETTER in place, and\n // pino would re-invoke it during serialization.\n if (!readable || next !== current) {\n if (out === source) out = copyPlain(source)\n // `defineOwn`, not `out[key] = next` — see its docblock: an own\n // `__proto__` key would otherwise hit the inherited setter, dropping the\n // key and swapping the clone's prototype.\n defineOwn(out, key, next)\n }\n }\n onPath.delete(node)\n return remember(out)\n}\n\n/**\n * The spellings of a sensitive key that pino's paths must name explicitly.\n *\n * pino compares `redact.paths` VERBATIM — `authorization` does not match\n * `Authorization` — and paths are the only mechanism that reaches child-logger\n * bindings, which never pass through `formatters.log`. So a binding is the one\n * place case matters, and `logger.child({ Authorization })` is exactly how a\n * header-shaped value gets spelled.\n *\n * The camelCase forms are listed rather than derived because they are not\n * derivable: `apikey` → `apiKey`, `passwordhash` → `passwordHash`. An unknown\n * key contributes only its lower/Capitalised/UPPER forms, which is the honest\n * result — the walker is what covers arbitrary casing everywhere else.\n */\n/**\n * A `Map`, not an object literal, and the reason is the same hostile-key\n * problem `defineOwn` exists for — reached from the other side.\n *\n * `buildRedactPaths` is exported and takes an arbitrary key list. On an object\n * literal, `CASE_VARIANTS[key]` resolves INHERITED members: `'constructor'`\n * returns `Object.prototype.constructor` and `'__proto__'` returns\n * `Object.prototype`. Neither is `undefined`, so `?? []` does not fire, and the\n * spread throws `TypeError: … is not iterable` — crashing logger construction\n * at import time, for a caller who did nothing worse than deny-list a key named\n * `constructor`. A `Map` has no prototype chain to fall through.\n */\nconst CASE_VARIANTS: ReadonlyMap<string, readonly string[]> = new Map([\n ['apikey', ['apiKey', 'API_KEY']],\n ['passwordhash', ['passwordHash']],\n ['accesstoken', ['accessToken', 'access_token']],\n ['refreshtoken', ['refreshToken', 'refresh_token']],\n ['sessiontoken', ['sessionToken', 'session_token']],\n])\n\nfunction spellingsOf(key: string): string[] {\n const lower = key.toLowerCase()\n const capitalised = lower.charAt(0).toUpperCase() + lower.slice(1)\n return [\n ...new Set([lower, capitalised, lower.toUpperCase(), ...(CASE_VARIANTS.get(lower) ?? [])]),\n ]\n}\n\n/**\n * pino `redact.paths` covering what {@link redactDeep} structurally cannot:\n * child-logger BINDINGS (which never reach `formatters.log`).\n *\n * Every entry is a LITERAL path. That is not incidental — a literal path costs\n * nothing however many are declared (40 literal paths measured at 2.3 µs/line,\n * i.e. baseline), while each `*` wildcard level multiplies the per-line cost,\n * reaching 22× at the four levels that would be needed to match the walker.\n * Depth is the walker's job; this list buys breadth at the top level, where it\n * is free.\n */\nexport function buildRedactPaths(keys: readonly string[] = SENSITIVE_KEYS): string[] {\n return keys.flatMap(spellingsOf)\n}\n","import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { AuditLogClient } from './audit-client.js'\nimport type { Logger } from './logger.js'\nimport { redactDeep, SENSITIVE_KEYS, toDenySet } from './redaction.js'\n\nexport interface AuditEntry {\n action: string\n entityType?: string\n entityId?: string\n userId?: string\n userName?: string\n changes?: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface AuditConfig {\n /**\n * The audit logger only ever calls `info` and `error` — narrowing the\n * accepted shape avoids forcing tests and adapters to construct or fake\n * the full ~30-method Pino surface.\n */\n logger: Pick<Logger, 'info' | 'error'>\n dbWriter?: (entry: AuditEntry) => Promise<void>\n /**\n * Batched counterpart to {@link dbWriter} (F124) — ONE multi-row INSERT\n * for N entries. Optional: a config that omits it but supplies\n * `dbWriter` still persists every entry via {@link AuditLogger.logMany},\n * degraded to N single-row writes awaited in sequence — a batch is\n * never silently dropped just because the batched writer isn't wired.\n * A config that omits BOTH logs to stdout only, the same as an omitted\n * `dbWriter` leaves {@link AuditLogger.log}.\n */\n dbWriterMany?: (entries: AuditEntry[]) => Promise<void>\n /**\n * REPLACE the deny-list of object keys whose values are replaced with\n * `[REDACTED]` when found anywhere inside `changes` / `metadata` before\n * the entry is logged or persisted. Matching is case-insensitive on the\n * exact key name (no substring match — `foo.password` matches, `myPassword`\n * does not).\n *\n * **Replace, not merge.** Passing `['ssn']` removes `password`/`token`/etc.\n * from the deny-list; if you want to *add* keys while keeping defaults,\n * spread them yourself: `redactKeys: [...SENSITIVE_KEYS, 'ssn']`\n * (`SENSITIVE_KEYS` is exported from this package).\n *\n * Defaults to {@link SENSITIVE_KEYS} — the SAME declaration the root\n * logger's redaction floor is built from, so the two surfaces cannot drift\n * apart again (D034).\n */\n redactKeys?: readonly string[]\n}\n\nexport interface AuditLogger {\n log: (entry: AuditEntry) => Promise<void>\n /**\n * Batched write (F124) — ONE round trip for N entries instead of N.\n *\n * Optional so an existing hand-rolled `AuditLogger` (a test double, an\n * app that has not re-generated its route wiring) stays valid without\n * change — `createAuditLogger`'s own return value always implements it.\n *\n * Unlike {@link log}, which fires the DB write and returns without\n * waiting for it (`dbWriter(...).catch(...)`, never awaited), `logMany`\n * AWAITS its batched write internally before resolving — that is what\n * gives a caller something to await (F124's whole point). It still\n * NEVER REJECTS: a `dbWriterMany` failure is caught and logged, exactly\n * like a `dbWriter` failure is today, so a batched write can never turn\n * an already-committed state change into a 500 the operator retries.\n */\n logMany?: (entries: AuditEntry[]) => Promise<void>\n}\n\nfunction buildSanitizer(redactKeys?: readonly string[]): (entry: AuditEntry) => AuditEntry {\n const denyKeys = toDenySet(redactKeys ?? SENSITIVE_KEYS)\n return (entry) => {\n if (!entry.changes && !entry.metadata) return entry\n // Per-call path set so a sanitized payload isn't poisoned by a prior\n // call's reference graph.\n const onPath = new Set<object>()\n return {\n ...entry,\n ...(entry.changes\n ? { changes: redactDeep(entry.changes, denyKeys, onPath) as Record<string, unknown> }\n : {}),\n ...(entry.metadata\n ? { metadata: redactDeep(entry.metadata, denyKeys, onPath) as Record<string, unknown> }\n : {}),\n }\n }\n}\n\n/**\n * Create a dbWriter callback wired to AuditLogClient.\n *\n * Pass the returned function as `dbWriter` to `createAuditLogger`:\n * ```typescript\n * const auditLogger = createAuditLogger({\n * logger,\n * dbWriter: createAuditDbWriter(db),\n * })\n * ```\n */\nexport function createAuditDbWriter(db: PostgresJsDatabase): (entry: AuditEntry) => Promise<void> {\n const client = new AuditLogClient(db)\n return (entry) =>\n client.write({\n action: entry.action,\n entityType: entry.entityType ?? null,\n entityId: entry.entityId ?? null,\n userId: entry.userId ?? null,\n userName: entry.userName ?? null,\n changes: entry.changes ?? null,\n metadata: entry.metadata ?? null,\n })\n}\n\n/**\n * Create a batched dbWriter callback wired to `AuditLogClient.writeMany` (F124).\n *\n * Pass the returned function as `dbWriterMany` to `createAuditLogger`,\n * alongside `dbWriter: createAuditDbWriter(db)` — the two are independent\n * capabilities on the same client, not alternatives.\n */\nexport function createAuditDbWriterMany(\n db: PostgresJsDatabase,\n): (entries: AuditEntry[]) => Promise<void> {\n const client = new AuditLogClient(db)\n return (entries) =>\n client.writeMany(\n entries.map((entry) => ({\n action: entry.action,\n entityType: entry.entityType ?? null,\n entityId: entry.entityId ?? null,\n userId: entry.userId ?? null,\n userName: entry.userName ?? null,\n changes: entry.changes ?? null,\n metadata: entry.metadata ?? null,\n })),\n )\n}\n\n/**\n * Create an audit logger.\n *\n * Audit logs are written to stdout immediately (non-blocking).\n * Optionally, they can also be written to the database if dbWriter is provided.\n *\n * Database writes are fire-and-forget to avoid blocking the main operation.\n * If DB write fails, it's logged to stderr but doesn't throw.\n *\n * **Redaction.** Both stdout and DB writes pass `changes` and `metadata`\n * through a recursive key-based sanitizer (see `redactKeys`). It matters for\n * the DB write in particular: the root logger's floor covers the stdout copy\n * but nothing covers a row on its way into `toolkit_audit_logs`. Defense in\n * depth — callers should still avoid putting secrets into audit payloads in\n * the first place.\n */\nexport function createAuditLogger(config: AuditConfig): AuditLogger {\n const sanitize = buildSanitizer(config.redactKeys)\n return {\n log: async (entry: AuditEntry) => {\n const safe = sanitize(entry)\n\n // Log to stdout immediately (structured log with audit: true marker)\n config.logger.info(\n {\n audit: true,\n action: safe.action,\n entityType: safe.entityType,\n entityId: safe.entityId,\n userId: safe.userId,\n userName: safe.userName,\n changes: safe.changes,\n metadata: safe.metadata,\n },\n `Audit: ${safe.action}`,\n )\n\n // Write to DB if configured (fire and forget)\n if (config.dbWriter) {\n config.dbWriter(safe).catch((err) => {\n config.logger.error({ err, auditEntry: safe }, 'Failed to write audit log to database')\n })\n }\n },\n logMany: async (entries: AuditEntry[]) => {\n if (entries.length === 0) return\n const safeEntries = entries.map(sanitize)\n\n for (const safe of safeEntries) {\n config.logger.info(\n {\n audit: true,\n action: safe.action,\n entityType: safe.entityType,\n entityId: safe.entityId,\n userId: safe.userId,\n userName: safe.userName,\n changes: safe.changes,\n metadata: safe.metadata,\n },\n `Audit: ${safe.action}`,\n )\n }\n\n // Unlike `log`, AWAITED — this is what gives a caller something to\n // wait on (F124). Still never throws: a batch failure is caught and\n // logged, same non-blocking contract as `log`'s dbWriter.\n if (config.dbWriterMany) {\n try {\n await config.dbWriterMany(safeEntries)\n } catch (err) {\n config.logger.error(\n { err, auditEntryCount: safeEntries.length },\n 'Failed to batch-write audit logs to database',\n )\n }\n } else if (config.dbWriter) {\n // No batched writer configured — a config built with `dbWriter`\n // alone (e.g. an app that never rewired its `AuditLogger` for\n // batching) still gets a `logMany` on this returned object, and\n // `buildAuditLogManyFn` has no way to know the batched half is\n // unwired. Degrade to N single-row writes rather than silently\n // dropping every entry — slower, but nothing is lost.\n for (const safe of safeEntries) {\n try {\n await config.dbWriter(safe)\n } catch (err) {\n config.logger.error({ err, auditEntry: safe }, 'Failed to write audit log to database')\n }\n }\n }\n },\n }\n}\n","import pino, { type Logger as PinoLogger } from 'pino'\nimport { buildRedactPaths, MAX_LOG_REDACT_DEPTH, REDACTED, redactDeep } from './redaction.js'\n\nexport type Logger = PinoLogger\n\n/**\n * Walk a log payload for credentials, and NEVER be the reason a log call\n * throws.\n *\n * pino's own serializer degrades gracefully on a hostile object (it emits\n * `[unable to serialize …]`), so a redactor wrapping it must not be less\n * forgiving. `redactDeep` already contains a throwing getter; this catches\n * anything exotic enough to get past that — at the cost of emitting the\n * unredacted payload, which is the correct trade only because it is the\n * BACKSTOP: the guarantee is that the payload has no credential in it.\n */\nfunction redactLogObject(obj: Record<string, unknown>): Record<string, unknown> {\n try {\n return redactDeep(obj, undefined, undefined, 0, MAX_LOG_REDACT_DEPTH) as Record<string, unknown>\n } catch {\n return obj\n }\n}\n\n/**\n * `pino.stdSerializers.err`, then redacted.\n *\n * This closes the hole `redactDeep` structurally cannot: it skips non-plain\n * objects so an `Error`'s non-enumerable `message`/`stack` survive, which means\n * a credential hanging off an error instance is invisible to it. The most\n * common real shape is an HTTP client's error — `axiosError.config.headers\n * .Authorization`, `err.response.request.headers.cookie` — and `{ err }` is how\n * this repo logs every one of them (`@murumets-ee/notifications`,\n * `@murumets-ee/merit`'s transport, …).\n *\n * The serializer's OUTPUT is a plain object, so walking it covers arbitrary\n * depth with the same case-insensitive deny-list as everything else, and the\n * stack is already a string by then.\n */\nfunction redactErrorSerializer(err: unknown): unknown {\n // NOT typed `Error`. pino's `SerializerFn` is `(value: any) => any` and pino\n // calls it with whatever sits under the key, so a typed parameter here is a\n // claim about the caller that the caller does not honour. `stdSerializers.err`\n // itself returns its input UNCHANGED for anything not error-like\n // (`typeof v.message === 'string'`), and this repo logs\n // `{ error: String(err) }` in several places — spreading that string would\n // emit a character-index map (`{\"0\":\"E\",\"1\":\"r\",…}`) instead of the message.\n const serialized: unknown = pino.stdSerializers.err(err as Error)\n if (typeof serialized !== 'object' || serialized === null || Array.isArray(serialized)) {\n return serialized\n }\n\n // `walkNonPlain` — pino builds the serialized tree on its OWN prototype, at\n // every level, so the default plain-object guard would skip the whole thing\n // and this serializer would do nothing at all. Its input has no live `Error`\n // left to protect; see the option's docblock.\n const redacted = redactDeep(\n { ...(serialized as Record<string, unknown>) },\n undefined,\n undefined,\n 0,\n MAX_LOG_REDACT_DEPTH,\n undefined,\n true,\n )\n\n // Nothing was redacted → hand back pino's OWN object, prototype intact. The\n // spread is a plain copy, which silently drops the non-enumerable `raw`\n // accessor and the raw-error symbol that transports and `wrapErrorSerializer`\n // consumers read. Paying that only when a credential was actually found keeps\n // the contract whole on every ordinary error, which is all of them.\n return isSameShallow(redacted, serialized as Record<string, unknown>) ? serialized : redacted\n}\n\n/** Did the walk change anything? Compared by VALUE at the top level, because the copy is always a new object. */\nfunction isSameShallow(redacted: unknown, original: Record<string, unknown>): boolean {\n if (typeof redacted !== 'object' || redacted === null) return false\n const candidate = redacted as Record<string, unknown>\n const keys = Object.keys(original)\n if (keys.length !== Object.keys(candidate).length) return false\n return keys.every((k) => Object.is(candidate[k], original[k]))\n}\n\nexport interface LoggerConfig {\n name?: string\n level?: string\n // `redact?: string[]` used to sit here and `createLogger` never read it — a\n // field that looks like a per-logger deny-list and silently is not, which is\n // the worst possible shape for a security control. Removed rather than\n // implemented: the floor is deliberately one shared declaration\n // (`SENSITIVE_KEYS`), and a per-child override could only ever narrow it.\n}\n\n/**\n * The toolkit's root-logger configuration, as a value.\n *\n * Exported because the redaction floor below is a security property, and a\n * property is only known to hold if something asserts it. The root logger\n * writes to fd 1 through SonicBoom, which `process.stdout.write` spies cannot\n * observe — so the only way to assert the floor is to build an identical\n * logger over a capture stream. Returning the options the root logger is\n * ACTUALLY constructed from (rather than a copy in the test) is what keeps\n * that assertion honest: there is one expression, used twice.\n */\nexport function toolkitLoggerOptions(): pino.LoggerOptions {\n return {\n level: process.env.LOG_LEVEL || 'info',\n formatters: {\n level: (label) => ({ level: label }),\n /**\n * The redaction floor's DEPTH half — see `redaction.ts` for why the\n * three halves are needed and why this one is copy-on-write.\n *\n * Runs before `serializers`, so it receives a raw `Error` on the 500\n * path; `redactDeep` returns non-plain objects untouched precisely so\n * the stack survives to the operator, and `redactErrorSerializer` is\n * what covers what is nested inside one.\n */\n log: redactLogObject,\n },\n timestamp: false,\n serializers: {\n err: redactErrorSerializer,\n error: redactErrorSerializer,\n },\n /**\n * The floor's BINDINGS half. `formatters.log` never sees child-logger\n * bindings; these literal paths do. Literal (never wildcard) on purpose —\n * a wildcard path costs per level and would put a 22× tax on every log\n * line in the toolkit to buy depth the walker already covers for free.\n */\n redact: {\n paths: buildRedactPaths(),\n censor: REDACTED,\n },\n }\n}\n\n/**\n * Root logger instance\n *\n * IMPORTANT: No transports are used to avoid Next.js bundler issues.\n * Pino transports use worker threads that break webpack/turbopack.\n *\n * For dev pretty-printing, pipe to pino-pretty:\n * pnpm dev | pnpm pino-pretty\n */\nconst rootLogger = pino(toolkitLoggerOptions())\n\n/**\n * Create a logger instance\n * If no config provided, returns the root logger\n * If config provided, returns a child logger with the specified context\n */\nexport function createLogger(config?: LoggerConfig): Logger {\n if (!config) {\n return rootLogger\n }\n\n const bindings: Record<string, unknown> = {}\n if (config.name) {\n bindings.name = config.name\n }\n\n const options: { level?: string } = {}\n if (config.level) {\n options.level = config.level\n }\n\n return rootLogger.child(bindings, options)\n}\n"],"mappings":"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"}
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e}from"./audit-table-BImbXt_s.mjs";import{combineAdminRoutes as t,defineAdminRoute as n}from"@murumets-ee/admin-route";import{z as r}from"zod";const i=/^[a-zA-Z0-9_-]{1,255}$/,a=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,o=r.object({action:r.string().max(100).optional(),entityType:r.string().max(100).optional(),entityId:r.string().regex(i,`Invalid ID format`).optional(),userId:r.string().regex(i,`Invalid user ID format`).optional(),dateFrom:r.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),dateTo:r.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),search:r.string().max(200).optional(),limit:r.coerce.number().min(1).max(100).default(50),offset:r.coerce.number().min(0).default(0),sortField:r.enum([`createdAt`,`action`,`entityType`]).default(`createdAt`),sortDirection:r.enum([`asc`,`desc`]).default(`desc`)});function s(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function c(e,t){return s({error:e},t)}function l(e){return t([n({prefix:`logs`,path:``,method:`GET`,matchAnyPath:!0,permission:`audit-logs:view`,defaultRoles:[`admin`],description:`Read the immutable audit log`,handler:async(t,{segments:n,app:r})=>{let i=e?e():new(await(import(`./audit-client-
|
|
1
|
+
import{n as e}from"./audit-table-BImbXt_s.mjs";import{combineAdminRoutes as t,defineAdminRoute as n}from"@murumets-ee/admin-route";import{z as r}from"zod";const i=/^[a-zA-Z0-9_-]{1,255}$/,a=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,o=r.object({action:r.string().max(100).optional(),entityType:r.string().max(100).optional(),entityId:r.string().regex(i,`Invalid ID format`).optional(),userId:r.string().regex(i,`Invalid user ID format`).optional(),dateFrom:r.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),dateTo:r.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),search:r.string().max(200).optional(),limit:r.coerce.number().min(1).max(100).default(50),offset:r.coerce.number().min(0).default(0),sortField:r.enum([`createdAt`,`action`,`entityType`]).default(`createdAt`),sortDirection:r.enum([`asc`,`desc`]).default(`desc`)});function s(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function c(e,t){return s({error:e},t)}function l(e){return t([n({prefix:`logs`,path:``,method:`GET`,matchAnyPath:!0,permission:`audit-logs:view`,defaultRoles:[`admin`],description:`Read the immutable audit log`,handler:async(t,{segments:n,app:r})=>{let i=e?e():new(await(import(`./audit-client-Cx8nIF9Q.mjs`).then(e=>e.r))).AuditLogClient(r.db.readWrite),l=n[0];if(n.length===1&&l===`filters`){let[e,t]=await Promise.all([i.getDistinctEntityTypes(),i.getDistinctActions()]);return s({entityTypes:e,actions:t})}if(n.length===1&&l!==void 0){if(!a.test(l))return c(`Audit log entry not found`,404);let e=await i.findById(l);return e?s(e):c(`Audit log entry not found`,404)}if(n.length>1)return c(`Not found`,404);let u=new URL(t.url),d=Object.fromEntries(u.searchParams),f=o.safeParse(d);if(!f.success)return c(`Invalid query params: ${f.error.issues.map(e=>e.message).join(`, `)}`,400);let{dateFrom:p,dateTo:m,...h}=f.data;return s(await i.query({...h,...p&&{dateFrom:new Date(p)},...m&&{dateTo:new Date(m)}}))}})])}function u(e){return e}function d(){return u({name:`@murumets-ee/logging`,server:{tables:{toolkitAuditLogs:e},routes:l()},shared:{pluginResources:[{name:`audit-logs`,actions:[`view`]}]}})}export{d as logging};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/logging",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
"drizzle-orm": "^0.45.2",
|
|
25
25
|
"pino": "^9.5.0",
|
|
26
26
|
"zod": "^3.24.1",
|
|
27
|
-
"@murumets-ee/admin-route": "0.
|
|
28
|
-
"@murumets-ee/db": "0.
|
|
27
|
+
"@murumets-ee/admin-route": "0.58.0",
|
|
28
|
+
"@murumets-ee/db": "0.58.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
+
"postgres": "^3.4.9",
|
|
31
32
|
"tsdown": "^0.22.2",
|
|
32
33
|
"typescript": "^5.7.2",
|
|
33
34
|
"vitest": "^2.1.8"
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
"build": "tsdown",
|
|
40
41
|
"dev": "tsdown --watch",
|
|
41
42
|
"test": "vitest run",
|
|
42
|
-
"test:watch": "vitest"
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
-
|
|
3
|
-
//#region src/audit-client.d.ts
|
|
4
|
-
interface AuditLogQueryOptions {
|
|
5
|
-
action?: string | undefined;
|
|
6
|
-
entityType?: string | undefined;
|
|
7
|
-
entityId?: string | undefined;
|
|
8
|
-
userId?: string | undefined;
|
|
9
|
-
dateFrom?: Date | undefined;
|
|
10
|
-
dateTo?: Date | undefined;
|
|
11
|
-
/** Free-text search across action, entityType, userName */
|
|
12
|
-
search?: string | undefined;
|
|
13
|
-
/** Default 50, max 100 */
|
|
14
|
-
limit?: number | undefined;
|
|
15
|
-
offset?: number | undefined;
|
|
16
|
-
sortField?: 'createdAt' | 'action' | 'entityType' | undefined;
|
|
17
|
-
sortDirection?: 'asc' | 'desc' | undefined;
|
|
18
|
-
}
|
|
19
|
-
interface AuditLogEntry {
|
|
20
|
-
id: string;
|
|
21
|
-
action: string;
|
|
22
|
-
entityType: string | null;
|
|
23
|
-
entityId: string | null;
|
|
24
|
-
userId: string | null;
|
|
25
|
-
userName: string | null;
|
|
26
|
-
changes: Record<string, unknown> | null;
|
|
27
|
-
metadata: Record<string, unknown> | null;
|
|
28
|
-
createdAt: Date;
|
|
29
|
-
}
|
|
30
|
-
interface AuditLogListResult {
|
|
31
|
-
items: AuditLogEntry[];
|
|
32
|
-
total: number;
|
|
33
|
-
}
|
|
34
|
-
declare class AuditLogClient {
|
|
35
|
-
private client;
|
|
36
|
-
constructor(db: PostgresJsDatabase);
|
|
37
|
-
/** Write a single audit log entry */
|
|
38
|
-
write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void>;
|
|
39
|
-
/** Query audit logs with filters and pagination */
|
|
40
|
-
query(options?: AuditLogQueryOptions): Promise<AuditLogListResult>;
|
|
41
|
-
/** Get a single audit log entry by ID */
|
|
42
|
-
findById(id: string): Promise<AuditLogEntry | null>;
|
|
43
|
-
/** Get distinct entity types present in the audit log */
|
|
44
|
-
getDistinctEntityTypes(): Promise<string[]>;
|
|
45
|
-
/** Get distinct actions present in the audit log */
|
|
46
|
-
getDistinctActions(): Promise<string[]>;
|
|
47
|
-
private buildWhere;
|
|
48
|
-
private buildOrderBy;
|
|
49
|
-
}
|
|
50
|
-
//#endregion
|
|
51
|
-
export { AuditLogQueryOptions as i, AuditLogEntry as n, AuditLogListResult as r, AuditLogClient as t };
|
|
52
|
-
//# sourceMappingURL=audit-client-4XgZ_M3r.d.mts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"audit-client-4XgZ_M3r.d.mts","names":[],"sources":["../src/audit-client.ts"],"mappings":";;;UAQiB,oBAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA,GAAW,IAAA;EACX,MAAA,GAAS,IAAI;EAJb;EAMA,MAAA;EAJA;EAMA,KAAA;EACA,MAAA;EACA,SAAA;EACA,aAAA;AAAA;AAAA,UAGe,aAAA;EACf,EAAA;EACA,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,EAAS,MAAA;EACT,QAAA,EAAU,MAAA;EACV,SAAA,EAAW,IAAA;AAAA;AAAA,UAGI,kBAAA;EACf,KAAA,EAAO,aAAa;EACpB,KAAA;AAAA;AAAA,cAqBW,cAAA;EAAA,QACH,MAAA;cAEI,EAAA,EAAI,kBAAA;EAjChB;EAsCM,KAAA,CAAM,KAAA,EAAO,IAAA,CAAK,aAAA,wBAAqC,OAAA;EApC7D;EAiDM,KAAA,CAAM,OAAA,GAAS,oBAAA,GAA4B,OAAA,CAAQ,kBAAA;EAhDzD;EAmEM,QAAA,CAAS,EAAA,WAAa,OAAA,CAAQ,aAAA;EAlEpC;EAuEM,sBAAA,CAAA,GAA0B,OAAA;EAvEjB;EA8ET,kBAAA,CAAA,GAAsB,OAAA;EAAA,QAQpB,UAAA;EAAA,QAmCA,YAAA;AAAA"}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./audit-table-BImbXt_s.mjs";var t=Object.defineProperty,n=((e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r})({AuditLogClient:()=>r}),r=class{client;constructor(t){this.client=e.makeClient(t)}async write(e){await this.client.insert({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})}async query(e={}){let t=Math.min(Math.max(e.limit??50,1),100),n=Math.max(e.offset??0,0),r=this.buildWhere(e),i=this.buildOrderBy(e.sortField,e.sortDirection),[a,o]=await Promise.all([this.client.findMany({...r!==void 0&&{where:r},orderBy:i,limit:t,offset:n}),this.client.count(r)]);return{items:a,total:o}}async findById(e){return this.client.findOne({id:e})}async getDistinctEntityTypes(){return this.client.distinct(`entityType`,{orderBy:`asc`})}async getDistinctActions(){return this.client.distinct(`action`,{orderBy:`asc`})}buildWhere(e){let t=[];if(e.action&&t.push({action:e.action}),e.entityType&&t.push({entityType:e.entityType}),e.entityId&&t.push({entityId:e.entityId}),e.userId&&t.push({userId:e.userId}),e.dateFrom&&t.push({createdAt:{gte:e.dateFrom}}),e.dateTo&&t.push({createdAt:{lte:e.dateTo}}),e.search&&t.push({$or:[{action:{ilike:e.search}},{entityType:{ilike:e.search}},{userName:{ilike:e.search}}]}),t.length!==0)return t.length===1?t[0]:{$and:t}}buildOrderBy(e,t){let n=t===`asc`?`asc`:`desc`;switch(e){case`action`:return[{column:`action`,dir:n}];case`entityType`:return[{column:`entityType`,dir:n}];default:return[{column:`createdAt`,dir:n}]}}};export{n,r as t};
|
|
2
|
-
//# sourceMappingURL=audit-client-CkD795ec.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"audit-client-CkD795ec.mjs","names":[],"sources":["../src/audit-client.ts"],"sourcesContent":["import type { TableClient, WhereClause } from '@murumets-ee/db'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { auditLogTable } from './audit-table.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface AuditLogQueryOptions {\n action?: string | undefined\n entityType?: string | undefined\n entityId?: string | undefined\n userId?: string | undefined\n dateFrom?: Date | undefined\n dateTo?: Date | undefined\n /** Free-text search across action, entityType, userName */\n search?: string | undefined\n /** Default 50, max 100 */\n limit?: number | undefined\n offset?: number | undefined\n sortField?: 'createdAt' | 'action' | 'entityType' | undefined\n sortDirection?: 'asc' | 'desc' | undefined\n}\n\nexport interface AuditLogEntry {\n id: string\n action: string\n entityType: string | null\n entityId: string | null\n userId: string | null\n userName: string | null\n changes: Record<string, unknown> | null\n metadata: Record<string, unknown> | null\n createdAt: Date\n}\n\nexport interface AuditLogListResult {\n items: AuditLogEntry[]\n total: number\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MAX_LIMIT = 100\nconst DEFAULT_LIMIT = 50\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\ntype AuditCols = typeof auditLogTable.schema.columns\ntype AuditWhere = WhereClause<AuditCols>\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\nexport class AuditLogClient {\n private client: TableClient<AuditCols>\n\n constructor(db: PostgresJsDatabase) {\n this.client = auditLogTable.makeClient(db)\n }\n\n /** Write a single audit log entry */\n async write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void> {\n await this.client.insert({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })\n }\n\n /** Query audit logs with filters and pagination */\n async query(options: AuditLogQueryOptions = {}): Promise<AuditLogListResult> {\n const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)\n const offset = Math.max(options.offset ?? 0, 0)\n\n const where = this.buildWhere(options)\n const orderBy = this.buildOrderBy(options.sortField, options.sortDirection)\n\n const [items, total] = await Promise.all([\n this.client.findMany({ ...(where !== undefined && { where }), orderBy, limit, offset }),\n this.client.count(where),\n ])\n\n return {\n items: items as AuditLogEntry[],\n total,\n }\n }\n\n /** Get a single audit log entry by ID */\n async findById(id: string): Promise<AuditLogEntry | null> {\n return this.client.findOne({ id }) as Promise<AuditLogEntry | null>\n }\n\n /** Get distinct entity types present in the audit log */\n async getDistinctEntityTypes(): Promise<string[]> {\n // entityType is nullable, but distinct() excludes nulls by default —\n // the cast is safe because includeNull is not set.\n return this.client.distinct('entityType', { orderBy: 'asc' }) as Promise<string[]>\n }\n\n /** Get distinct actions present in the audit log */\n async getDistinctActions(): Promise<string[]> {\n return this.client.distinct('action', { orderBy: 'asc' })\n }\n\n // -------------------------------------------------------------------------\n // Private helpers\n // -------------------------------------------------------------------------\n\n private buildWhere(options: AuditLogQueryOptions): AuditWhere | undefined {\n const parts: AuditWhere[] = []\n\n if (options.action) parts.push({ action: options.action })\n if (options.entityType) parts.push({ entityType: options.entityType })\n if (options.entityId) parts.push({ entityId: options.entityId })\n if (options.userId) parts.push({ userId: options.userId })\n\n // Date range: push each bound as its own typed clause. The\n // ColumnOperators union allows exactly one operator per object —\n // combining gte+lte requires two clauses AND-ed at the top level\n // (which buildWhere does below).\n if (options.dateFrom) {\n parts.push({ createdAt: { gte: options.dateFrom } })\n }\n if (options.dateTo) {\n parts.push({ createdAt: { lte: options.dateTo } })\n }\n\n // Free-text search across action, entityType, userName\n if (options.search) {\n parts.push({\n $or: [\n { action: { ilike: options.search } },\n { entityType: { ilike: options.search } },\n { userName: { ilike: options.search } },\n ],\n })\n }\n\n if (parts.length === 0) return undefined\n if (parts.length === 1) return parts[0]\n return { $and: parts }\n }\n\n private buildOrderBy(\n sortField?: string,\n sortDirection?: string,\n ): { column: keyof AuditCols & string; dir: 'asc' | 'desc' }[] {\n const dir: 'asc' | 'desc' = sortDirection === 'asc' ? 'asc' : 'desc'\n switch (sortField) {\n case 'action':\n return [{ column: 'action', dir }]\n case 'entityType':\n return [{ column: 'entityType', dir }]\n default:\n return [{ column: 'createdAt', dir }]\n }\n }\n}\n"],"mappings":"6NA2Da,EAAb,KAA4B,CAC1B,OAEA,YAAY,EAAwB,CAClC,KAAK,OAAS,EAAc,WAAW,CAAE,CAC3C,CAGA,MAAM,MAAM,EAA+D,CACzE,MAAM,KAAK,OAAO,OAAO,CACvB,OAAQ,EAAM,OACd,WAAY,EAAM,WAClB,SAAU,EAAM,SAChB,OAAQ,EAAM,OACd,SAAU,EAAM,SAChB,QAAS,EAAM,QACf,SAAU,EAAM,QAClB,CAAC,CACH,CAGA,MAAM,MAAM,EAAgC,CAAC,EAAgC,CAC3E,IAAM,EAAQ,KAAK,IAAI,KAAK,IAAI,EAAQ,OAAS,GAAe,CAAC,EAAG,GAAS,EACvE,EAAS,KAAK,IAAI,EAAQ,QAAU,EAAG,CAAC,EAExC,EAAQ,KAAK,WAAW,CAAO,EAC/B,EAAU,KAAK,aAAa,EAAQ,UAAW,EAAQ,aAAa,EAEpE,CAAC,EAAO,GAAS,MAAM,QAAQ,IAAI,CACvC,KAAK,OAAO,SAAS,CAAE,GAAI,IAAU,IAAA,IAAa,CAAE,OAAM,EAAI,UAAS,QAAO,QAAO,CAAC,EACtF,KAAK,OAAO,MAAM,CAAK,CACzB,CAAC,EAED,MAAO,CACE,QACP,OACF,CACF,CAGA,MAAM,SAAS,EAA2C,CACxD,OAAO,KAAK,OAAO,QAAQ,CAAE,IAAG,CAAC,CACnC,CAGA,MAAM,wBAA4C,CAGhD,OAAO,KAAK,OAAO,SAAS,aAAc,CAAE,QAAS,KAAM,CAAC,CAC9D,CAGA,MAAM,oBAAwC,CAC5C,OAAO,KAAK,OAAO,SAAS,SAAU,CAAE,QAAS,KAAM,CAAC,CAC1D,CAMA,WAAmB,EAAuD,CACxE,IAAM,EAAsB,CAAC,EAE7B,GAAI,EAAQ,QAAQ,EAAM,KAAK,CAAE,OAAQ,EAAQ,MAAO,CAAC,EACrD,EAAQ,YAAY,EAAM,KAAK,CAAE,WAAY,EAAQ,UAAW,CAAC,EACjE,EAAQ,UAAU,EAAM,KAAK,CAAE,SAAU,EAAQ,QAAS,CAAC,EAC3D,EAAQ,QAAQ,EAAM,KAAK,CAAE,OAAQ,EAAQ,MAAO,CAAC,EAMrD,EAAQ,UACV,EAAM,KAAK,CAAE,UAAW,CAAE,IAAK,EAAQ,QAAS,CAAE,CAAC,EAEjD,EAAQ,QACV,EAAM,KAAK,CAAE,UAAW,CAAE,IAAK,EAAQ,MAAO,CAAE,CAAC,EAI/C,EAAQ,QACV,EAAM,KAAK,CACT,IAAK,CACH,CAAE,OAAQ,CAAE,MAAO,EAAQ,MAAO,CAAE,EACpC,CAAE,WAAY,CAAE,MAAO,EAAQ,MAAO,CAAE,EACxC,CAAE,SAAU,CAAE,MAAO,EAAQ,MAAO,CAAE,CACxC,CACF,CAAC,EAGC,EAAM,SAAW,EAErB,OADI,EAAM,SAAW,EAAU,EAAM,GAC9B,CAAE,KAAM,CAAM,CACvB,CAEA,aACE,EACA,EAC6D,CAC7D,IAAM,EAAsB,IAAkB,MAAQ,MAAQ,OAC9D,OAAQ,EAAR,CACE,IAAK,SACH,MAAO,CAAC,CAAE,OAAQ,SAAU,KAAI,CAAC,EACnC,IAAK,aACH,MAAO,CAAC,CAAE,OAAQ,aAAc,KAAI,CAAC,EACvC,QACE,MAAO,CAAC,CAAE,OAAQ,YAAa,KAAI,CAAC,CACxC,CACF,CACF"}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{column as e,defineTable as t}from"@murumets-ee/db";const n=t({name:`toolkit_audit_logs`,columns:{id:e.uuid({primaryKey:!0,defaultRandom:!0}),action:e.varchar({length:100,notNull:!0}),entityType:e.varchar({length:100,pgName:`entity_type`}),entityId:e.varchar({length:255,pgName:`entity_id`}),userId:e.varchar({length:255,pgName:`user_id`}),userName:e.varchar({length:255,pgName:`user_name`}),changes:e.jsonb(),metadata:e.jsonb(),createdAt:e.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`})},indexes:[{on:[`entityType`,`createdAt`],name:`audit_entity_type_created_at_idx`},{on:[`userId`,`createdAt`],name:`audit_user_id_created_at_idx`},{on:[`action`,`createdAt`],name:`audit_action_created_at_idx`},{on:[`createdAt`],name:`audit_created_at_idx`},{on:[`entityId`],name:`audit_entity_id_idx`}]});n.table;var r=class{client;constructor(e){this.client=n.makeClient(e)}async write(e){await this.client.insert({action:e.action,entityType:e.entityType,entityId:e.entityId,userId:e.userId,userName:e.userName,changes:e.changes,metadata:e.metadata})}async query(e={}){let t=Math.min(Math.max(e.limit??50,1),100),n=Math.max(e.offset??0,0),r=this.buildWhere(e),i=this.buildOrderBy(e.sortField,e.sortDirection),[a,o]=await Promise.all([this.client.findMany({...r!==void 0&&{where:r},orderBy:i,limit:t,offset:n}),this.client.count(r)]);return{items:a,total:o}}async findById(e){return this.client.findOne({id:e})}async getDistinctEntityTypes(){return this.client.distinct(`entityType`,{orderBy:`asc`})}async getDistinctActions(){return this.client.distinct(`action`,{orderBy:`asc`})}buildWhere(e){let t=[];if(e.action&&t.push({action:e.action}),e.entityType&&t.push({entityType:e.entityType}),e.entityId&&t.push({entityId:e.entityId}),e.userId&&t.push({userId:e.userId}),e.dateFrom&&t.push({createdAt:{gte:e.dateFrom}}),e.dateTo&&t.push({createdAt:{lte:e.dateTo}}),e.search&&t.push({$or:[{action:{ilike:e.search}},{entityType:{ilike:e.search}},{userName:{ilike:e.search}}]}),t.length!==0)return t.length===1?t[0]:{$and:t}}buildOrderBy(e,t){let n=t===`asc`?`asc`:`desc`;switch(e){case`action`:return[{column:`action`,dir:n}];case`entityType`:return[{column:`entityType`,dir:n}];default:return[{column:`createdAt`,dir:n}]}}};export{r as AuditLogClient};
|
|
2
|
-
//# sourceMappingURL=audit-client-T2P2K970.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"audit-client-T2P2K970.mjs","names":[],"sources":["../src/audit-table.ts","../src/audit-client.ts"],"sourcesContent":["/**\n * `toolkit_audit_logs` table definition via `defineTable`.\n *\n * Replaces the hand-written `pgTable` in `schema.ts`. Uses `pgName` to\n * preserve the existing snake_case Postgres column names so no migration\n * is required.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const auditLogTable = defineTable({\n name: 'toolkit_audit_logs',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n action: column.varchar({ length: 100, notNull: true }),\n entityType: column.varchar({ length: 100, pgName: 'entity_type' }),\n entityId: column.varchar({ length: 255, pgName: 'entity_id' }),\n userId: column.varchar({ length: 255, pgName: 'user_id' }),\n userName: column.varchar({ length: 255, pgName: 'user_name' }),\n changes: column.jsonb<Record<string, unknown>>(),\n metadata: column.jsonb<Record<string, unknown>>(),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n },\n indexes: [\n { on: ['entityType', 'createdAt'], name: 'audit_entity_type_created_at_idx' },\n { on: ['userId', 'createdAt'], name: 'audit_user_id_created_at_idx' },\n { on: ['action', 'createdAt'], name: 'audit_action_created_at_idx' },\n { on: ['createdAt'], name: 'audit_created_at_idx' },\n { on: ['entityId'], name: 'audit_entity_id_idx' },\n ],\n})\n\n/** Backward-compatible re-export — consumers importing `toolkitAuditLogs` see the same PgTable. */\nexport const toolkitAuditLogs = auditLogTable.table\n","import type { TableClient, WhereClause } from '@murumets-ee/db'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { auditLogTable } from './audit-table.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface AuditLogQueryOptions {\n action?: string | undefined\n entityType?: string | undefined\n entityId?: string | undefined\n userId?: string | undefined\n dateFrom?: Date | undefined\n dateTo?: Date | undefined\n /** Free-text search across action, entityType, userName */\n search?: string | undefined\n /** Default 50, max 100 */\n limit?: number | undefined\n offset?: number | undefined\n sortField?: 'createdAt' | 'action' | 'entityType' | undefined\n sortDirection?: 'asc' | 'desc' | undefined\n}\n\nexport interface AuditLogEntry {\n id: string\n action: string\n entityType: string | null\n entityId: string | null\n userId: string | null\n userName: string | null\n changes: Record<string, unknown> | null\n metadata: Record<string, unknown> | null\n createdAt: Date\n}\n\nexport interface AuditLogListResult {\n items: AuditLogEntry[]\n total: number\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst MAX_LIMIT = 100\nconst DEFAULT_LIMIT = 50\n\n// ---------------------------------------------------------------------------\n// Internal types\n// ---------------------------------------------------------------------------\n\ntype AuditCols = typeof auditLogTable.schema.columns\ntype AuditWhere = WhereClause<AuditCols>\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\nexport class AuditLogClient {\n private client: TableClient<AuditCols>\n\n constructor(db: PostgresJsDatabase) {\n this.client = auditLogTable.makeClient(db)\n }\n\n /** Write a single audit log entry */\n async write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void> {\n await this.client.insert({\n action: entry.action,\n entityType: entry.entityType,\n entityId: entry.entityId,\n userId: entry.userId,\n userName: entry.userName,\n changes: entry.changes,\n metadata: entry.metadata,\n })\n }\n\n /** Query audit logs with filters and pagination */\n async query(options: AuditLogQueryOptions = {}): Promise<AuditLogListResult> {\n const limit = Math.min(Math.max(options.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)\n const offset = Math.max(options.offset ?? 0, 0)\n\n const where = this.buildWhere(options)\n const orderBy = this.buildOrderBy(options.sortField, options.sortDirection)\n\n const [items, total] = await Promise.all([\n this.client.findMany({ ...(where !== undefined && { where }), orderBy, limit, offset }),\n this.client.count(where),\n ])\n\n return {\n items: items as AuditLogEntry[],\n total,\n }\n }\n\n /** Get a single audit log entry by ID */\n async findById(id: string): Promise<AuditLogEntry | null> {\n return this.client.findOne({ id }) as Promise<AuditLogEntry | null>\n }\n\n /** Get distinct entity types present in the audit log */\n async getDistinctEntityTypes(): Promise<string[]> {\n // entityType is nullable, but distinct() excludes nulls by default —\n // the cast is safe because includeNull is not set.\n return this.client.distinct('entityType', { orderBy: 'asc' }) as Promise<string[]>\n }\n\n /** Get distinct actions present in the audit log */\n async getDistinctActions(): Promise<string[]> {\n return this.client.distinct('action', { orderBy: 'asc' })\n }\n\n // -------------------------------------------------------------------------\n // Private helpers\n // -------------------------------------------------------------------------\n\n private buildWhere(options: AuditLogQueryOptions): AuditWhere | undefined {\n const parts: AuditWhere[] = []\n\n if (options.action) parts.push({ action: options.action })\n if (options.entityType) parts.push({ entityType: options.entityType })\n if (options.entityId) parts.push({ entityId: options.entityId })\n if (options.userId) parts.push({ userId: options.userId })\n\n // Date range: push each bound as its own typed clause. The\n // ColumnOperators union allows exactly one operator per object —\n // combining gte+lte requires two clauses AND-ed at the top level\n // (which buildWhere does below).\n if (options.dateFrom) {\n parts.push({ createdAt: { gte: options.dateFrom } })\n }\n if (options.dateTo) {\n parts.push({ createdAt: { lte: options.dateTo } })\n }\n\n // Free-text search across action, entityType, userName\n if (options.search) {\n parts.push({\n $or: [\n { action: { ilike: options.search } },\n { entityType: { ilike: options.search } },\n { userName: { ilike: options.search } },\n ],\n })\n }\n\n if (parts.length === 0) return undefined\n if (parts.length === 1) return parts[0]\n return { $and: parts }\n }\n\n private buildOrderBy(\n sortField?: string,\n sortDirection?: string,\n ): { column: keyof AuditCols & string; dir: 'asc' | 'desc' }[] {\n const dir: 'asc' | 'desc' = sortDirection === 'asc' ? 'asc' : 'desc'\n switch (sortField) {\n case 'action':\n return [{ column: 'action', dir }]\n case 'entityType':\n return [{ column: 'entityType', dir }]\n default:\n return [{ column: 'createdAt', dir }]\n }\n }\n}\n"],"mappings":"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"}
|