@murumets-ee/logging 0.36.0 → 0.38.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 CHANGED
@@ -1,3 +1,4 @@
1
+ import { AdminRoute } from "@murumets-ee/admin-route";
1
2
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
3
 
3
4
  //#region src/audit-client.d.ts
@@ -49,24 +50,69 @@ declare class AuditLogClient {
49
50
  }
50
51
  //#endregion
51
52
  //#region src/admin/routes.d.ts
52
- interface AdminRoute {
53
- prefix: string;
54
- resource?: string;
55
- actions?: readonly string[];
56
- handlers: Partial<Record<string, (req: Request, ctx: {
57
- segments: string[];
58
- user: {
59
- id: string;
60
- role?: string;
61
- };
62
- checkPermission: (resource: string, action: string) => boolean;
63
- }) => Promise<Response>>>;
53
+ /**
54
+ * The slice of the running app this route's handler actually needs: a DB
55
+ * handle to construct `AuditLogClient` with.
56
+ *
57
+ * This is dependency injection, not a stand-in — the same shape CLAUDE.md
58
+ * blesses for the entity package's resolvers. Naming exactly the surface
59
+ * the handler reads (rather than the whole `ToolkitApp`) is the narrower,
60
+ * better claim: it documents the handler's real coupling and it is what
61
+ * `TApp` is bound to for every type in this file.
62
+ *
63
+ * `ToolkitApp` satisfies it structurally (`db.readWrite: PostgresJsDatabase`),
64
+ * so `AdminRoute<LoggingApp>[]` is assignable to core's
65
+ * `Plugin.server.routes: AdminRoute<ToolkitApp>[]` — `ctx` is a
66
+ * contravariant position, so a NARROWER `ctx.app` here accepts the WIDER
67
+ * app the dispatcher actually passes.
68
+ */
69
+ interface LoggingApp {
70
+ db: {
71
+ readWrite: PostgresJsDatabase;
72
+ };
64
73
  }
65
74
  /** Subset of `AuditLogClient` actually called by the route handlers. Using
66
75
  * `Pick` (not a hand-written `*Like` interface) means new methods on the
67
76
  * real client don't silently degrade these to `unknown`. */
68
77
  type AuditLogClientForRoutes = Pick<AuditLogClient, 'query' | 'findById' | 'getDistinctEntityTypes' | 'getDistinctActions'>;
69
- declare function logRoutes(getClient: () => AuditLogClientForRoutes): AdminRoute;
78
+ /**
79
+ * Build the audit-log admin route(s).
80
+ *
81
+ * Declared through the real `defineAdminRoute` / `combineAdminRoutes`
82
+ * factory pair (plan/admin-api-hardening F020 + F006) now that those
83
+ * primitives live in the dependency-free `@murumets-ee/admin-route` leaf
84
+ * this package CAN depend on. That retires the last hand-written
85
+ * `AdminRoute` object literal in the codebase and, with it, the
86
+ * hand-rolled permission check that used to guard this route: the
87
+ * factory's `guardedHandler` performs the identical
88
+ * `checkPermission('audit-logs', 'view')` test BEFORE the handler runs,
89
+ * and additionally emits the `permission.denied` audit entry + the
90
+ * uniform `{ error, code: 'forbidden' }` body that the hand-rolled gate
91
+ * only partially reproduced. Registering through the factory also lands
92
+ * `audit-logs:view` in the process-local permission catalog, so
93
+ * role-default seeding and the Permission Matrix UI pick it up.
94
+ *
95
+ * `matchAnyPath: true` is required: this prefix's sub-path space is a
96
+ * runtime value (`/logs/<uuid>`) alongside the static `/logs/filters`,
97
+ * which `combineAdminRoutes`' `segments[0]`-keyed dispatch cannot
98
+ * enumerate. The handler keeps doing its own sub-path dispatch exactly as
99
+ * before.
100
+ *
101
+ * `getClient` is an OPTIONAL back-compat escape hatch. `logging()`'s own
102
+ * `Plugin.server.routes` declaration (see `../plugin.js`) calls this with
103
+ * ZERO arguments: a closure captured at plugin-CONSTRUCTION time can't
104
+ * work for a static declarative field — there is no live `app` yet at
105
+ * that point — so the zero-arg path reads `ctx.app` per request instead,
106
+ * the SAME instance the dispatcher resolved via `getApp()`. Existing
107
+ * explicit wiring (`apps/admin`, `apps/perf-harness`, the
108
+ * `lumi admin-api:init` scaffold template) still calls
109
+ * `logRoutes(() => new AuditLogClient(getApp().db.readWrite))` — per
110
+ * plan/admin-api-hardening's scope rule those call sites stay correct
111
+ * (removing the now-redundant explicit entry is a later task), so the
112
+ * override stays supported rather than becoming a breaking signature
113
+ * change.
114
+ */
115
+ declare function logRoutes(getClient?: () => AuditLogClientForRoutes): AdminRoute<LoggingApp>[];
70
116
  //#endregion
71
117
  export { logRoutes };
72
118
  //# sourceMappingURL=admin.d.mts.map
@@ -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;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;;;UC1HA,UAAA;EACR,MAAA;EACA,QAAA;EACA,OAAA;EACA,QAAA,EAAU,OAAA,CACR,MAAA,UAGI,GAAA,EAAK,OAAA,EACL,GAAA;IACE,QAAA;IACA,IAAA;MAAQ,EAAA;MAAY,IAAA;IAAA;IACpB,eAAA,GAAkB,QAAA,UAAkB,MAAA;EAAA,MAEnC,OAAA,CAAQ,QAAA;AAAA;;;;KAmDd,uBAAA,GAA0B,IAAI,CACjC,cAAA;AAAA,iBAIc,SAAA,CAAU,SAAA,QAAiB,uBAAA,GAA0B,UAAU"}
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;EAOA,MAAA;EALA;EAOA,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;EAlChB;EAuCM,KAAA,CAAM,KAAA,EAAO,IAAA,CAAK,aAAA,wBAAqC,OAAA;EArC7D;EAkDM,KAAA,CAAM,OAAA,GAAS,oBAAA,GAA4B,OAAA,CAAQ,kBAAA;EAjDhD;EAoEH,QAAA,CAAS,EAAA,WAAa,OAAA,CAAQ,aAAA;EAnE1B;EAwEJ,sBAAA,CAAA,GAA0B,OAAA;EAvErB;EA8EL,kBAAA,CAAA,GAAsB,OAAA;EAAA,QAQpB,UAAA;EAAA,QAmCA,YAAA;AAAA;;;;;;;;;;;;;;;;;;;UC5FO,UAAA;EACf,EAAA;IACE,SAAA,EAAW,kBAAkB;EAAA;AAAA;;;;KAkD5B,uBAAA,GAA0B,IAAI,CACjC,cAAA;;AD7EK;AAqBP;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCiGgB,SAAA,CAAU,SAAA,SAAkB,uBAAA,GAA0B,UAAA,CAAW,UAAA"}
package/dist/admin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{z as e}from"zod";const t=/^[a-zA-Z0-9_-]{1,255}$/,n=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,r=e.object({action:e.string().max(100).optional(),entityType:e.string().max(100).optional(),entityId:e.string().regex(t,`Invalid ID format`).optional(),userId:e.string().regex(t,`Invalid user ID format`).optional(),dateFrom:e.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),dateTo:e.string().datetime({offset:!0,message:`Invalid ISO date`}).optional(),search:e.string().max(200).optional(),limit:e.coerce.number().min(1).max(100).default(50),offset:e.coerce.number().min(0).default(0),sortField:e.enum([`createdAt`,`action`,`entityType`]).default(`createdAt`),sortDirection:e.enum([`asc`,`desc`]).default(`desc`)});function i(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function a(e,t){return i({error:e},t)}function o(e){return{prefix:`logs`,resource:`audit-logs`,actions:[`view`],handlers:{GET:async(t,{segments:o,checkPermission:s})=>{if(!s(`audit-logs`,`view`))return a(`Forbidden`,403);let c=e(),l=o[0];if(o.length===1&&l===`filters`){let[e,t]=await Promise.all([c.getDistinctEntityTypes(),c.getDistinctActions()]);return i({entityTypes:e,actions:t})}if(o.length===1&&l!==void 0){if(!n.test(l))return a(`Audit log entry not found`,404);let e=await c.findById(l);return e?i(e):a(`Audit log entry not found`,404)}if(o.length>1)return a(`Not found`,404);let u=new URL(t.url),d=Object.fromEntries(u.searchParams),f=r.safeParse(d);if(!f.success)return a(`Invalid query params: ${f.error.issues.map(e=>e.message).join(`, `)}`,400);let{dateFrom:p,dateTo:m,...h}=f.data;return i(await c.query({...h,...p&&{dateFrom:new Date(p)},...m&&{dateTo:new Date(m)}}))}}}}export{o as logRoutes};
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-T2P2K970.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
@@ -1 +1 @@
1
- {"version":3,"file":"admin.mjs","names":[],"sources":["../src/admin/routes.ts"],"sourcesContent":["/**\n * Audit log admin routes for the centralized admin API handler.\n *\n * Read-only: only GET handlers. Audit logs are immutable.\n * Admin-only: explicit role check before any data access.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { logRoutes } from '@murumets-ee/logging/admin'\n * import { AuditLogClient } from '@murumets-ee/logging'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [...],\n * routes: [logRoutes(() => new AuditLogClient(db))],\n * })\n * ```\n */\n\nimport { z } from 'zod'\n// Type-only import — keeps the runtime AuditLogClient (and its postgres-js\n// type closure) out of the /admin bundle while still tying the handler's\n// expected client surface to the real class. Drift surfaces as a TS error\n// the next time AuditLogClient gains a method.\nimport type { AuditLogClient } from '../audit-client.js'\n\n// ---------------------------------------------------------------------------\n// Local route type — avoids circular build dep: logging → core → logging.\n// Structurally compatible with AdminRoute from @murumets-ee/core.\n// ---------------------------------------------------------------------------\n\ninterface AdminRoute {\n prefix: string\n resource?: string\n actions?: readonly string[]\n handlers: Partial<\n Record<\n string,\n (\n req: Request,\n ctx: {\n segments: string[]\n user: { id: string; role?: string }\n checkPermission: (resource: string, action: string) => boolean\n },\n ) => Promise<Response>\n >\n >\n}\n\n// ---------------------------------------------------------------------------\n// Query param validation\n// ---------------------------------------------------------------------------\n\nconst ID_REGEX = /^[a-zA-Z0-9_-]{1,255}$/\n// Strict RFC 4122 UUID — `audit_logs.id` is `column.uuid({...})`, so a\n// non-UUID path segment otherwise reaches postgres and triggers a 500\n// from `invalid input syntax for type uuid`. Validate up front and 404.\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nconst auditLogQuerySchema = z.object({\n action: z.string().max(100).optional(),\n entityType: z.string().max(100).optional(),\n entityId: z.string().regex(ID_REGEX, 'Invalid ID format').optional(),\n userId: z.string().regex(ID_REGEX, 'Invalid user ID format').optional(),\n dateFrom: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n dateTo: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n search: z.string().max(200).optional(),\n limit: z.coerce.number().min(1).max(100).default(50),\n offset: z.coerce.number().min(0).default(0),\n sortField: z.enum(['createdAt', 'action', 'entityType']).default('createdAt'),\n sortDirection: z.enum(['asc', 'desc']).default('desc'),\n})\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/** Subset of `AuditLogClient` actually called by the route handlers. Using\n * `Pick` (not a hand-written `*Like` interface) means new methods on the\n * real client don't silently degrade these to `unknown`. */\ntype AuditLogClientForRoutes = Pick<\n AuditLogClient,\n 'query' | 'findById' | 'getDistinctEntityTypes' | 'getDistinctActions'\n>\n\nexport function logRoutes(getClient: () => AuditLogClientForRoutes): AdminRoute {\n return {\n prefix: 'logs',\n resource: 'audit-logs',\n actions: ['view'],\n handlers: {\n GET: async (req, { segments, checkPermission }) => {\n // Defense in depth — the framework already gates on\n // `resource: 'audit-logs'` before invoking the handler; this re-check\n // means an accidental drop of `resource` (or a future framework\n // refactor) cannot silently make audit logs world-readable.\n if (!checkPermission('audit-logs', 'view')) {\n return errorJson('Forbidden', 403)\n }\n\n const client = getClient()\n\n const firstSegment = segments[0]\n\n // GET /logs/filters — distinct values for filter dropdowns\n if (segments.length === 1 && firstSegment === 'filters') {\n const [entityTypes, actions] = await Promise.all([\n client.getDistinctEntityTypes(),\n client.getDistinctActions(),\n ])\n return json({ entityTypes, actions })\n }\n\n // GET /logs/:id — single entry detail\n if (segments.length === 1 && firstSegment !== undefined) {\n if (!UUID_REGEX.test(firstSegment)) {\n return errorJson('Audit log entry not found', 404)\n }\n const entry = await client.findById(firstSegment)\n if (!entry) return errorJson('Audit log entry not found', 404)\n return json(entry)\n }\n\n // GET /logs — list with filters + pagination. Reject anything\n // deeper than a single id/keyword segment so unknown paths don't\n // silently fall through and return the full list.\n if (segments.length > 1) {\n return errorJson('Not found', 404)\n }\n\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const parsed = auditLogQuerySchema.safeParse(params)\n if (!parsed.success) {\n return errorJson(\n `Invalid query params: ${parsed.error.issues.map((i) => i.message).join(', ')}`,\n 400,\n )\n }\n\n const { dateFrom, dateTo, ...rest } = parsed.data\n const result = await client.query({\n ...rest,\n ...(dateFrom && { dateFrom: new Date(dateFrom) }),\n ...(dateTo && { dateTo: new Date(dateTo) }),\n })\n\n return json(result)\n },\n // No POST, PATCH, DELETE — audit logs are append-only\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"admin.mjs","names":[],"sources":["../src/admin/routes.ts"],"sourcesContent":["/**\n * Audit log admin routes for the centralized admin API handler.\n *\n * Read-only: only GET handlers. Audit logs are immutable.\n * Admin-only: explicit role check before any data access.\n *\n * `logging()` declares this route on its own `Plugin.server.routes` (see\n * `../plugin.js`) — an app needs no manual wiring for it. The example\n * below (explicit `routes: [...]`) is the legacy back-compat path, kept\n * working for callers that haven't dropped their now-redundant explicit\n * entry yet.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { logRoutes } from '@murumets-ee/logging/admin'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [...],\n * routes: [...logRoutes()],\n * })\n * ```\n */\n\nimport { type AdminRoute, combineAdminRoutes, defineAdminRoute } from '@murumets-ee/admin-route'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\n// Type-only import — keeps the runtime AuditLogClient (and its\n// `@murumets-ee/db`/postgres-js closure) out of the /admin bundle AND out of\n// `dist/plugin.mjs` (this module is now value-imported from `../plugin.js`\n// for the declarative `server.routes` slot, and `dist/plugin.mjs` is what\n// jiti/tsx loads from `lumi.config.ts` on EVERY `lumi` CLI invocation — see\n// `logRoutes`' JSDoc below). The zero-arg fallback branch reaches the real\n// class via a dynamic `import()` at request time instead of a static import.\nimport type { AuditLogClient } from '../audit-client.js'\n\n// ---------------------------------------------------------------------------\n// `TApp` binding — the route TYPES come from `@murumets-ee/admin-route`, a\n// dependency-free leaf (F020/F024). `logging` still cannot import\n// `@murumets-ee/core` (core's app.ts imports `createLogger` from here, so\n// the reverse edge closes a real cycle), and the leaf deliberately cannot\n// name `ToolkitApp` for the same reason — hence its `TApp` generic. `core`\n// binds `TApp = ToolkitApp`; this package binds `TApp = LoggingApp` below.\n// ---------------------------------------------------------------------------\n\n/**\n * The slice of the running app this route's handler actually needs: a DB\n * handle to construct `AuditLogClient` with.\n *\n * This is dependency injection, not a stand-in — the same shape CLAUDE.md\n * blesses for the entity package's resolvers. Naming exactly the surface\n * the handler reads (rather than the whole `ToolkitApp`) is the narrower,\n * better claim: it documents the handler's real coupling and it is what\n * `TApp` is bound to for every type in this file.\n *\n * `ToolkitApp` satisfies it structurally (`db.readWrite: PostgresJsDatabase`),\n * so `AdminRoute<LoggingApp>[]` is assignable to core's\n * `Plugin.server.routes: AdminRoute<ToolkitApp>[]` — `ctx` is a\n * contravariant position, so a NARROWER `ctx.app` here accepts the WIDER\n * app the dispatcher actually passes.\n */\nexport interface LoggingApp {\n db: {\n readWrite: PostgresJsDatabase\n }\n}\n\n// ---------------------------------------------------------------------------\n// Query param validation\n// ---------------------------------------------------------------------------\n\nconst ID_REGEX = /^[a-zA-Z0-9_-]{1,255}$/\n// Strict RFC 4122 UUID — `audit_logs.id` is `column.uuid({...})`, so a\n// non-UUID path segment otherwise reaches postgres and triggers a 500\n// from `invalid input syntax for type uuid`. Validate up front and 404.\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nconst auditLogQuerySchema = z.object({\n action: z.string().max(100).optional(),\n entityType: z.string().max(100).optional(),\n entityId: z.string().regex(ID_REGEX, 'Invalid ID format').optional(),\n userId: z.string().regex(ID_REGEX, 'Invalid user ID format').optional(),\n dateFrom: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n dateTo: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n search: z.string().max(200).optional(),\n limit: z.coerce.number().min(1).max(100).default(50),\n offset: z.coerce.number().min(0).default(0),\n sortField: z.enum(['createdAt', 'action', 'entityType']).default('createdAt'),\n sortDirection: z.enum(['asc', 'desc']).default('desc'),\n})\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/** Subset of `AuditLogClient` actually called by the route handlers. Using\n * `Pick` (not a hand-written `*Like` interface) means new methods on the\n * real client don't silently degrade these to `unknown`. */\ntype AuditLogClientForRoutes = Pick<\n AuditLogClient,\n 'query' | 'findById' | 'getDistinctEntityTypes' | 'getDistinctActions'\n>\n\n/**\n * Build the audit-log admin route(s).\n *\n * Declared through the real `defineAdminRoute` / `combineAdminRoutes`\n * factory pair (plan/admin-api-hardening F020 + F006) now that those\n * primitives live in the dependency-free `@murumets-ee/admin-route` leaf\n * this package CAN depend on. That retires the last hand-written\n * `AdminRoute` object literal in the codebase and, with it, the\n * hand-rolled permission check that used to guard this route: the\n * factory's `guardedHandler` performs the identical\n * `checkPermission('audit-logs', 'view')` test BEFORE the handler runs,\n * and additionally emits the `permission.denied` audit entry + the\n * uniform `{ error, code: 'forbidden' }` body that the hand-rolled gate\n * only partially reproduced. Registering through the factory also lands\n * `audit-logs:view` in the process-local permission catalog, so\n * role-default seeding and the Permission Matrix UI pick it up.\n *\n * `matchAnyPath: true` is required: this prefix's sub-path space is a\n * runtime value (`/logs/<uuid>`) alongside the static `/logs/filters`,\n * which `combineAdminRoutes`' `segments[0]`-keyed dispatch cannot\n * enumerate. The handler keeps doing its own sub-path dispatch exactly as\n * before.\n *\n * `getClient` is an OPTIONAL back-compat escape hatch. `logging()`'s own\n * `Plugin.server.routes` declaration (see `../plugin.js`) calls this with\n * ZERO arguments: a closure captured at plugin-CONSTRUCTION time can't\n * work for a static declarative field — there is no live `app` yet at\n * that point — so the zero-arg path reads `ctx.app` per request instead,\n * the SAME instance the dispatcher resolved via `getApp()`. Existing\n * explicit wiring (`apps/admin`, `apps/perf-harness`, the\n * `lumi admin-api:init` scaffold template) still calls\n * `logRoutes(() => new AuditLogClient(getApp().db.readWrite))` — per\n * plan/admin-api-hardening's scope rule those call sites stay correct\n * (removing the now-redundant explicit entry is a later task), so the\n * override stays supported rather than becoming a breaking signature\n * change.\n */\nexport function logRoutes(getClient?: () => AuditLogClientForRoutes): AdminRoute<LoggingApp>[] {\n return combineAdminRoutes([\n defineAdminRoute<LoggingApp, ''>({\n prefix: 'logs',\n path: '',\n method: 'GET',\n // Catch-all: `/logs`, `/logs/filters` and `/logs/<uuid>` all land on\n // this one guarded handler, which dispatches internally (below).\n matchAnyPath: true,\n permission: 'audit-logs:view',\n defaultRoles: ['admin'],\n description: 'Read the immutable audit log',\n handler: async (req, { segments, app }) => {\n // No permission check here — `defineAdminRoute`'s `guardedHandler`\n // has already enforced `audit-logs:view` (and audited the denial)\n // by the time this runs. A second inline check would be a copy\n // that can drift; that duplication is exactly what this plan\n // exists to remove.\n const client = getClient\n ? getClient()\n : new (await import('../audit-client.js')).AuditLogClient(app.db.readWrite)\n\n const firstSegment = segments[0]\n\n // GET /logs/filters — distinct values for filter dropdowns\n if (segments.length === 1 && firstSegment === 'filters') {\n const [entityTypes, actions] = await Promise.all([\n client.getDistinctEntityTypes(),\n client.getDistinctActions(),\n ])\n return json({ entityTypes, actions })\n }\n\n // GET /logs/:id — single entry detail\n if (segments.length === 1 && firstSegment !== undefined) {\n if (!UUID_REGEX.test(firstSegment)) {\n return errorJson('Audit log entry not found', 404)\n }\n const entry = await client.findById(firstSegment)\n if (!entry) return errorJson('Audit log entry not found', 404)\n return json(entry)\n }\n\n // GET /logs — list with filters + pagination. Reject anything\n // deeper than a single id/keyword segment so unknown paths don't\n // silently fall through and return the full list.\n if (segments.length > 1) {\n return errorJson('Not found', 404)\n }\n\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const parsed = auditLogQuerySchema.safeParse(params)\n if (!parsed.success) {\n return errorJson(\n `Invalid query params: ${parsed.error.issues.map((i) => i.message).join(', ')}`,\n 400,\n )\n }\n\n const { dateFrom, dateTo, ...rest } = parsed.data\n const result = await client.query({\n ...rest,\n ...(dateFrom && { dateFrom: new Date(dateFrom) }),\n ...(dateTo && { dateTo: new Date(dateTo) }),\n })\n\n return json(result)\n },\n // No POST, PATCH, DELETE entries — audit logs are append-only.\n }),\n ])\n}\n"],"mappings":"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"}
@@ -0,0 +1,52 @@
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
@@ -0,0 +1 @@
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"}
@@ -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})({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
@@ -0,0 +1 @@
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"}
@@ -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 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
@@ -0,0 +1 @@
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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"}
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- import { Logger as Logger$1 } from "pino";
1
+ import { i as AuditLogQueryOptions, n as AuditLogEntry, r as AuditLogListResult, t as AuditLogClient } from "./audit-client-4XgZ_M3r.mjs";
2
+ import pino, { Logger as Logger$1 } from "pino";
2
3
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
4
 
4
5
  //#region src/logger.d.ts
@@ -6,8 +7,19 @@ type Logger = Logger$1;
6
7
  interface LoggerConfig {
7
8
  name?: string;
8
9
  level?: string;
9
- redact?: string[];
10
10
  }
11
+ /**
12
+ * The toolkit's root-logger configuration, as a value.
13
+ *
14
+ * Exported because the redaction floor below is a security property, and a
15
+ * property is only known to hold if something asserts it. The root logger
16
+ * writes to fd 1 through SonicBoom, which `process.stdout.write` spies cannot
17
+ * observe — so the only way to assert the floor is to build an identical
18
+ * logger over a capture stream. Returning the options the root logger is
19
+ * ACTUALLY constructed from (rather than a copy in the test) is what keeps
20
+ * that assertion honest: there is one expression, used twice.
21
+ */
22
+ declare function toolkitLoggerOptions(): pino.LoggerOptions;
11
23
  /**
12
24
  * Create a logger instance
13
25
  * If no config provided, returns the root logger
@@ -42,8 +54,12 @@ interface AuditConfig {
42
54
  *
43
55
  * **Replace, not merge.** Passing `['ssn']` removes `password`/`token`/etc.
44
56
  * from the deny-list; if you want to *add* keys while keeping defaults,
45
- * spread them yourself: `redactKeys: [...DEFAULT_REDACT_KEYS, 'ssn']`
46
- * (import `DEFAULT_REDACT_KEYS` if needed; or just inline the list).
57
+ * spread them yourself: `redactKeys: [...SENSITIVE_KEYS, 'ssn']`
58
+ * (`SENSITIVE_KEYS` is exported from this package).
59
+ *
60
+ * Defaults to {@link SENSITIVE_KEYS} — the SAME declaration the root
61
+ * logger's redaction floor is built from, so the two surfaces cannot drift
62
+ * apart again (D034).
47
63
  */
48
64
  redactKeys?: readonly string[];
49
65
  }
@@ -72,60 +88,182 @@ declare function createAuditDbWriter(db: PostgresJsDatabase): (entry: AuditEntry
72
88
  * If DB write fails, it's logged to stderr but doesn't throw.
73
89
  *
74
90
  * **Redaction.** Both stdout and DB writes pass `changes` and `metadata`
75
- * through a recursive key-based sanitizer (see `redactKeys`). Pino's own
76
- * top-level `redact` config does not match nested keys, so callers must rely
77
- * on this sanitizer for nested credentials. Defense-in-depth — callers should
78
- * still avoid sticking secrets into audit payloads in the first place.
91
+ * through a recursive key-based sanitizer (see `redactKeys`). It matters for
92
+ * the DB write in particular: the root logger's floor covers the stdout copy
93
+ * but nothing covers a row on its way into `toolkit_audit_logs`. Defense in
94
+ * depth — callers should still avoid putting secrets into audit payloads in
95
+ * the first place.
79
96
  */
80
97
  declare function createAuditLogger(config: AuditConfig): AuditLogger;
81
98
  //#endregion
82
- //#region src/audit-client.d.ts
83
- interface AuditLogQueryOptions {
84
- action?: string | undefined;
85
- entityType?: string | undefined;
86
- entityId?: string | undefined;
87
- userId?: string | undefined;
88
- dateFrom?: Date | undefined;
89
- dateTo?: Date | undefined;
90
- /** Free-text search across action, entityType, userName */
91
- search?: string | undefined;
92
- /** Default 50, max 100 */
93
- limit?: number | undefined;
94
- offset?: number | undefined;
95
- sortField?: 'createdAt' | 'action' | 'entityType' | undefined;
96
- sortDirection?: 'asc' | 'desc' | undefined;
97
- }
98
- interface AuditLogEntry {
99
- id: string;
100
- action: string;
101
- entityType: string | null;
102
- entityId: string | null;
103
- userId: string | null;
104
- userName: string | null;
105
- changes: Record<string, unknown> | null;
106
- metadata: Record<string, unknown> | null;
107
- createdAt: Date;
108
- }
109
- interface AuditLogListResult {
110
- items: AuditLogEntry[];
111
- total: number;
112
- }
113
- declare class AuditLogClient {
114
- private client;
115
- constructor(db: PostgresJsDatabase);
116
- /** Write a single audit log entry */
117
- write(entry: Omit<AuditLogEntry, 'id' | 'createdAt'>): Promise<void>;
118
- /** Query audit logs with filters and pagination */
119
- query(options?: AuditLogQueryOptions): Promise<AuditLogListResult>;
120
- /** Get a single audit log entry by ID */
121
- findById(id: string): Promise<AuditLogEntry | null>;
122
- /** Get distinct entity types present in the audit log */
123
- getDistinctEntityTypes(): Promise<string[]>;
124
- /** Get distinct actions present in the audit log */
125
- getDistinctActions(): Promise<string[]>;
126
- private buildWhere;
127
- private buildOrderBy;
128
- }
99
+ //#region src/redaction.d.ts
100
+ /**
101
+ * The toolkit's ONE declaration of what must never reach a log line — and the
102
+ * one walker that enforces it.
103
+ *
104
+ * ## Why this file exists
105
+ *
106
+ * There used to be two redaction surfaces that disagreed. The audit logger
107
+ * walked a ten-name, case-insensitive deny-list to depth 8. The root pino
108
+ * logger — which every `app.logger` call in the toolkit passes through —
109
+ * declared six names, matched them as TOP-LEVEL PATHS only, was missing
110
+ * `authorization` and `cookie` (the two names a credential actually arrives
111
+ * under on an HTTP surface), and had no test. The weaker of the two was the
112
+ * default path.
113
+ *
114
+ * A backstop that disagrees with the primary is a backstop nobody can reason
115
+ * about (D034). So there is now one list, one walker, one set of semantics, and
116
+ * both surfaces are built from them.
117
+ *
118
+ * ## This is the BACKSTOP, not the guarantee
119
+ *
120
+ * The guarantee is that a credential is never in the payload to begin with: what
121
+ * gets logged and bound is a DERIVED whitelist — method, path, entity, status,
122
+ * error code, correlation id, principal id, token id — never a raw `Request`,
123
+ * `Headers`, credential or auth result. A field that is never in the payload
124
+ * cannot be missed by a redactor, cannot be missed by a redactor at the wrong
125
+ * nesting depth, and cannot be reintroduced by a future author adding a
126
+ * convenient `{ req }` to a debug line.
127
+ *
128
+ * This file catches everyone who forgets that.
129
+ */
130
+ /**
131
+ * Keys whose values are replaced with {@link REDACTED} wherever they appear.
132
+ *
133
+ * Matched case-INSENSITIVELY on the exact key name — `foo.password` matches,
134
+ * `myPassword` does not. Lower-case here because that is the form the walker
135
+ * compares against.
136
+ */
137
+ declare const SENSITIVE_KEYS: readonly string[];
138
+ declare const REDACTED = "[REDACTED]";
139
+ declare const TRUNCATED = "[TRUNCATED]";
140
+ declare const CYCLE = "[CYCLE]";
141
+ /**
142
+ * Maximum object depth the AUDIT sanitizer walks. Anything past this becomes
143
+ * {@link TRUNCATED} — a DISTINCT sentinel from {@link REDACTED}, so "we gave up
144
+ * walking" cannot be misread as "we wiped a credential".
145
+ */
146
+ declare const MAX_REDACT_DEPTH = 8;
147
+ /**
148
+ * Maximum depth the ROOT LOGGER's floor walks — deliberately DEEPER than
149
+ * {@link MAX_REDACT_DEPTH}.
150
+ *
151
+ * An audit entry reaches the logger nested: `changes` sits at depth 1 of the
152
+ * emitted line, and at depth 2 on the DB-write-failure line (`{ err, auditEntry
153
+ * }`) — the one an operator uses to recover a lost audit row. At an equal cap
154
+ * the outer walk would truncate a subtree the audit sanitizer deliberately
155
+ * kept, so the surface that owns the payload would stop being the binding
156
+ * constraint on it. Four levels of head-room covers the deepest nesting any
157
+ * caller in this repo puts a walked payload under.
158
+ */
159
+ declare const MAX_LOG_REDACT_DEPTH = 12;
160
+ /** Lower-case a key list once, for the walker's `has` check. */
161
+ declare function toDenySet(keys: readonly string[]): ReadonlySet<string>;
162
+ /** Stand-in for a property whose getter threw while being read. */
163
+ declare const UNREADABLE = "[UNREADABLE]";
164
+ /**
165
+ * Return `value` with every {@link SENSITIVE_KEYS} match replaced, cloning ONLY
166
+ * the branches that changed.
167
+ *
168
+ * ## Copy-on-write, and why it matters here
169
+ *
170
+ * This runs on every log line in the toolkit via pino's `formatters.log`. The
171
+ * overwhelmingly common case is a payload with nothing sensitive in it, and for
172
+ * that case this returns the caller's own object by reference and allocates
173
+ * nothing. Measured against the pinned pino: 2.5 µs/line with no floor at all,
174
+ * 3.6 µs/line with this walker plus the path list — versus 56 µs/line for the
175
+ * wildcard-path floor that would be needed to reach the same depth through
176
+ * pino's own `redact` (its paths are per-level, so covering depth N costs one
177
+ * path per key per level, and each level roughly doubles the cost).
178
+ *
179
+ * The caller's object is never mutated: a changed branch is copied before the
180
+ * write.
181
+ *
182
+ * ## Non-plain objects are returned untouched, and that is load-bearing
183
+ *
184
+ * `formatters.log` runs BEFORE pino's `serializers`, so this walker receives the
185
+ * raw `Error` instance on the 500 path, not the serializer's
186
+ * `{ type, message, stack }` output. `Error.message` and `Error.stack` are
187
+ * NON-ENUMERABLE, so a walker built on `Object.entries` emits `"err":{}` and
188
+ * silently destroys every stack trace in the toolkit — with the unit tests and
189
+ * the gates all green. The prototype check below is what prevents that.
190
+ *
191
+ * The consequence is a real hole and it is closed elsewhere rather than here: a
192
+ * credential nested under a non-plain object — `axiosError.config.headers
193
+ * .Authorization` is the shape that actually occurs — is not reachable by this
194
+ * walk. `redactErrorSerializer` in `logger.ts` closes it by walking the
195
+ * SERIALIZED error, which IS a plain object, so depth is covered and the stack
196
+ * survives. Do not "fix" it by walking class instances here.
197
+ *
198
+ * ## Cycles are on the CURRENT PATH; shared subtrees are MEMOIZED
199
+ *
200
+ * A node is un-marked on the way back out, so a DAG — the same object
201
+ * referenced twice from different branches, which is ordinary — keeps its
202
+ * value rather than being reported as a cycle. A genuine back-edge still
203
+ * becomes {@link CYCLE}.
204
+ *
205
+ * **Un-marking alone removes the only thing bounding revisits, and that is a
206
+ * self-inflicted DoS rather than a performance note.** Measured on the
207
+ * un-memoized version: a layered DAG of just NINE distinct objects (eight
208
+ * levels each mapping ten keys onto the same child, plus the leaf) took 111
209
+ * million node visits and blocked the event loop for 10.1 seconds —
210
+ * synchronously, inside a log call. The bound was `fanout^depth`, which is not a
211
+ * bound. The memo below restores it: a `(node, depth)` pair is computed at most
212
+ * once, so total work is linear in nodes × depth.
213
+ *
214
+ * **What the memo bounds is THIS WALK, not the log call.** The same clone is
215
+ * returned for every shared reference, and `JSON.stringify` then re-expands the
216
+ * DAG into a tree regardless — so the same 81-object payload still costs
217
+ * seconds and gigabytes at serialization time. That is pre-existing and
218
+ * unchanged (pino expands a DAG with or without this walker); it is stated here
219
+ * so nobody reads the paragraph above as a guarantee it does not make. Bounding
220
+ * what a caller can put INTO a log payload is the consumer's job — see
221
+ * `@murumets-ee/content-api`'s handler, which caps every caller-controlled
222
+ * string and count it logs.
223
+ *
224
+ * One deliberate imprecision, stated because it is invisible otherwise: a
225
+ * memoized subtree carries whatever {@link CYCLE} sentinels its FIRST traversal
226
+ * produced, so on a mutual reference (`X.y = Y; Y.x = X` — which ORM and graph
227
+ * payloads produce routinely, not just pathological ones) a `[CYCLE]` can
228
+ * appear one branch too eagerly. The alternative is the unbounded walk above.
229
+ * The output can never contain an actual cycle: a back-edge always forces a
230
+ * copy up the chain, so there is no serialization hazard either way.
231
+ */
232
+ declare function redactDeep(value: unknown, denyKeys?: ReadonlySet<string>, onPath?: Set<object>, depth?: number, maxDepth?: number, memo?: Map<object, Map<number, unknown>>,
233
+ /**
234
+ * Walk objects that are NOT plain — `false` everywhere except inside
235
+ * {@link redactErrorSerializer}, whose input is pino's already-serialized
236
+ * error tree.
237
+ *
238
+ * The default is `false` because skipping non-plain objects is what keeps an
239
+ * `Error`'s non-enumerable `message`/`stack` alive. Inside the error
240
+ * serializer there is no live `Error` left to protect — pino has already
241
+ * flattened each one (including nested error-like props and
242
+ * `AggregateError.errors`) into objects on ITS OWN prototype, whose
243
+ * `message`/`stack` are own-enumerable strings. Without this, a credential on
244
+ * a nested error — `outer.inner.token`, `aggregate.errors[0].apiKey` — is
245
+ * skipped, which is the hole the serializer exists to close.
246
+ *
247
+ * Copy-on-write is what makes it safe rather than merely useful: an object
248
+ * with nothing sensitive in it is returned BY REFERENCE, so a `Date`, `Map`
249
+ * or class instance keeps its identity and its `toJSON`. Only an object that
250
+ * actually contained a credential is flattened, which is the right trade at
251
+ * the point where the alternative is emitting the credential.
252
+ */
253
+
254
+ walkNonPlain?: boolean): unknown;
255
+ /**
256
+ * pino `redact.paths` covering what {@link redactDeep} structurally cannot:
257
+ * child-logger BINDINGS (which never reach `formatters.log`).
258
+ *
259
+ * Every entry is a LITERAL path. That is not incidental — a literal path costs
260
+ * nothing however many are declared (40 literal paths measured at 2.3 µs/line,
261
+ * i.e. baseline), while each `*` wildcard level multiplies the per-line cost,
262
+ * reaching 22× at the four levels that would be needed to match the walker.
263
+ * Depth is the walker's job; this list buys breadth at the top level, where it
264
+ * is free.
265
+ */
266
+ declare function buildRedactPaths(keys?: readonly string[]): string[];
129
267
  //#endregion
130
- export { type AuditConfig, type AuditEntry, AuditLogClient, type AuditLogEntry, type AuditLogListResult, type AuditLogQueryOptions, type AuditLogger, type Logger, type LoggerConfig, createAuditDbWriter, createAuditLogger, createLogger };
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 };
131
269
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/logger.ts","../src/audit.ts","../src/audit-client.ts"],"mappings":";;;;KAEY,MAAA,GAAS,QAAU;AAAA,UAEd,YAAA;EACf,IAAA;EACA,KAAA;EACA,MAAA;AAAA;;AAL6B;AAE/B;;;iBAoCgB,YAAA,CAAa,MAAA,GAAS,YAAA,GAAe,MAAM;;;UCpC1C,UAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA;EACA,OAAA,GAAU,MAAA;EACV,QAAA,GAAW,MAAM;AAAA;AAAA,UAGF,WAAA;EDVY;;;;;ECgB3B,MAAA,EAAQ,IAAA,CAAK,MAAA;EACb,QAAA,IAAY,KAAA,EAAO,UAAA,KAAe,OAAA;EDmBR;;;;;;;AAA+B;;;;ACpC3D;EA8BE,UAAA;AAAA;AAAA,UAGe,WAAA;EACf,GAAA,GAAM,KAAA,EAAO,UAAA,KAAe,OAAO;AAAA;;;;;;;;;AA3BlB;AAGnB;;iBAkIgB,mBAAA,CAAoB,EAAA,EAAI,kBAAA,IAAsB,KAAA,EAAO,UAAA,KAAe,OAAA;;;;;;;;;;;;;;;;iBA6BpE,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,WAAW;;;UCrKlD,oBAAA;EACf,MAAA;EACA,UAAA;EACA,QAAA;EACA,MAAA;EACA,QAAA,GAAW,IAAA;EACX,MAAA,GAAS,IAAI;EFZgB;EEc7B,MAAA;EFZ2B;EEc3B,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;EDzDhB;EC8DM,KAAA,CAAM,KAAA,EAAO,IAAA,CAAK,aAAA,wBAAqC,OAAA;ED5D7D;ECyEM,KAAA,CAAM,OAAA,GAAS,oBAAA,GAA4B,OAAA,CAAQ,kBAAA;EDvEzD;EC0FM,QAAA,CAAS,EAAA,WAAa,OAAA,CAAQ,aAAA;EDzF1B;EC8FJ,sBAAA,CAAA,GAA0B,OAAA;ED7FrB;ECoGL,kBAAA,CAAA,GAAsB,OAAA;EAAA,QAQpB,UAAA;EAAA,QAmCA,YAAA;AAAA"}
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;;;;;;;;AAA2D;;;;ACrJ3D;;;EAkCE,UAAA;AAAA;AAAA,UAGe,WAAA;EACf,GAAA,GAAM,KAAA,EAAO,UAAA,KAAe,OAAO;AAAA;;;;;;;AA/BlB;AAGnB;;;;iBA6DgB,mBAAA,CAAoB,EAAA,EAAI,kBAAA,IAAsB,KAAA,EAAO,UAAA,KAAe,OAAA;;;;;;;;;;;;;;AArCxE;AAGZ;;iBAgEgB,iBAAA,CAAkB,MAAA,EAAQ,WAAA,GAAc,WAAW;;;;;;;;ADvGnE;;;;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;;AD7CD;AAGZ;;;;;;;;;AACqC;AAiCrC;;;;;;;;;;;;;;AAA2F;AA8B3F;;;;;;;;AAAmE;;;;ACpEnE;;;;AAWU;AAEV;;;;AAAqB;AACrB;;;;AAAsB;AACtB;;;;AAAkB;AAOlB;;;;AAA6B;AAc7B;;;;iBAoIgB,UAAA,CACd,KAAA,WACA,QAAA,GAAU,WAAA,UACV,MAAA,GAAQ,GAAA,UACR,KAAA,WACA,QAAA,WACA,IAAA,GAAM,GAAA,SAAY,GAAA;AAvIpB;;;;AAA+D;AAO/D;;;;AAAuB;AA0HvB;;;;;;;;;;;AA2BE,YAAA;;;;;;;;;;;AAAoB;iBAoJN,gBAAA,CAAiB,IAAwC"}
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./audit-table-BImbXt_s.mjs";import t from"pino";var n=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}]}}};const r=[`password`,`passwordhash`,`token`,`secret`,`apikey`,`accesstoken`,`refreshtoken`,`sessiontoken`,`authorization`,`cookie`];function i(e,t,n,r=0){if(r>=8)return`[TRUNCATED]`;if(typeof e!=`object`||!e)return e;if(n.has(e))return`[CYCLE]`;if(n.add(e),Array.isArray(e))return e.map(e=>i(e,t,n,r+1));let a=Object.getPrototypeOf(e);if(a!==Object.prototype&&a!==null)return e;let o={};for(let[a,s]of Object.entries(e))t.has(a.toLowerCase())?o[a]=`[REDACTED]`:o[a]=i(s,t,n,r+1);return o}function a(e){let t=new Set((e??r).map(e=>e.toLowerCase()));return e=>{if(!e.changes&&!e.metadata)return e;let n=new WeakSet;return{...e,...e.changes?{changes:i(e.changes,t,n)}:{},...e.metadata?{metadata:i(e.metadata,t,n)}:{}}}}function o(e){let t=new n(e);return e=>t.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 s(e){let t=a(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`)})}}}const c=t({level:process.env.LOG_LEVEL||`info`,formatters:{level:e=>({level:e})},timestamp:!1,serializers:{err:t.stdSerializers.err,error:t.stdSerializers.err},redact:{paths:[`password`,`token`,`secret`,`apiKey`,`accessToken`,`refreshToken`],censor:`[REDACTED]`}});function l(e){if(!e)return c;let t={};e.name&&(t.name=e.name);let n={};return e.level&&(n.level=e.level),c.child(t,n)}export{n as AuditLogClient,o as createAuditDbWriter,s as createAuditLogger,l as createLogger};
1
+ import{t as e}from"./audit-client-CkD795ec.mjs";import t from"pino";const n=[`password`,`passwordhash`,`token`,`secret`,`apikey`,`accesstoken`,`refreshtoken`,`sessiontoken`,`authorization`,`cookie`],r=`[REDACTED]`,i=`[TRUNCATED]`,a=`[CYCLE]`,o=8,s=12;function c(e){return new Set(e.map(e=>e.toLowerCase()))}const l=c(n),u=`[UNREADABLE]`;function d(e,t,n){Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0})}function f(e){let t={};for(let n of Object.keys(e)){let r;try{r=e[n]}catch{r=u}d(t,n,r)}return t}function p(e){let t=Array(e.length);for(let n=0;n<e.length;n++)try{t[n]=e[n]}catch{t[n]=u}return t}function m(e,t=l,n=new Set,o=0,s=8,c=new Map,h=!1){if(typeof e!=`object`||!e)return e;if(o>=s)return i;let g=e;if(n.has(g))return a;let _=c.get(g)?.get(o);if(_!==void 0)return _;let v=e=>{let t=c.get(g);return t||(t=new Map,c.set(g,t)),t.set(o,e),e};if(Array.isArray(e)){n.add(g);let r=e;for(let i=0;i<e.length;i++){let a,l=!0;try{a=e[i]}catch{l=!1,a=u}let d=m(a,t,n,o+1,s,c,h);(!l||d!==a)&&(r===e&&(r=p(e)),r[i]=d)}return n.delete(g),v(r)}let y=Object.getPrototypeOf(e);if(!h&&y!==Object.prototype&&y!==null)return e;n.add(g);let b=e,x=b;for(let e of Object.keys(b)){let i,a=!0;try{i=b[e]}catch{a=!1,i=u}let l=t.has(e.toLowerCase())?r:m(i,t,n,o+1,s,c,h);(!a||l!==i)&&(x===b&&(x=f(b)),d(x,e,l))}return n.delete(g),v(x)}const h=new Map([[`apikey`,[`apiKey`,`API_KEY`]],[`passwordhash`,[`passwordHash`]],[`accesstoken`,[`accessToken`,`access_token`]],[`refreshtoken`,[`refreshToken`,`refresh_token`]],[`sessiontoken`,[`sessionToken`,`session_token`]]]);function g(e){let t=e.toLowerCase(),n=t.charAt(0).toUpperCase()+t.slice(1);return[...new Set([t,n,t.toUpperCase(),...h.get(t)??[]])]}function _(e=n){return e.flatMap(g)}function v(e){let t=c(e??n);return e=>{if(!e.changes&&!e.metadata)return e;let n=new Set;return{...e,...e.changes?{changes:m(e.changes,t,n)}:{},...e.metadata?{metadata:m(e.metadata,t,n)}:{}}}}function y(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 b(e){let t=v(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`)})}}}function x(e){try{return m(e,void 0,void 0,0,12)}catch{return e}}function S(e){let n=t.stdSerializers.err(e);if(typeof n!=`object`||!n||Array.isArray(n))return n;let r=m({...n},void 0,void 0,0,12,void 0,!0);return C(r,n)?n:r}function C(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 w(){return{level:process.env.LOG_LEVEL||`info`,formatters:{level:e=>({level:e}),log:x},timestamp:!1,serializers:{err:S,error:S},redact:{paths:_(),censor:r}}}const T=t(w());function E(e){if(!e)return T;let t={};e.name&&(t.name=e.name);let n={};return e.level&&(n.level=e.level),T.child(t,n)}export{e as AuditLogClient,a as CYCLE,s as MAX_LOG_REDACT_DEPTH,o as MAX_REDACT_DEPTH,r as REDACTED,n as SENSITIVE_KEYS,i as TRUNCATED,u as UNREADABLE,_ as buildRedactPaths,y as createAuditDbWriter,b as createAuditLogger,E as createLogger,m as redactDeep,c as toDenySet,w as toolkitLoggerOptions};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/audit-client.ts","../src/audit.ts","../src/logger.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","import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { AuditLogClient } from './audit-client.js'\nimport type { Logger } from './logger.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: [...DEFAULT_REDACT_KEYS, 'ssn']`\n * (import `DEFAULT_REDACT_KEYS` if needed; or just inline the list).\n */\n redactKeys?: readonly string[]\n}\n\nexport interface AuditLogger {\n log: (entry: AuditEntry) => Promise<void>\n}\n\nconst DEFAULT_REDACT_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\nconst REDACTED = '[REDACTED]'\nconst TRUNCATED = '[TRUNCATED]'\nconst CYCLE = '[CYCLE]'\n\n/** Maximum object depth walked by the redactor — defends against deeply\n * recursive caller payloads. Anything past this is replaced with TRUNCATED\n * so admins reading the log can tell \"we gave up walking\" apart from\n * \"we wiped a credential\". */\nconst MAX_REDACT_DEPTH = 8\n\n/**\n * Walk `value` and return a deep clone where any property whose key matches\n * `denyKeys` (case-insensitive exact match) is replaced with `[REDACTED]`.\n *\n * - Objects and arrays are cloned; primitives are returned as-is.\n * - Cycles are detected via a per-walk visited set and replaced with\n * `[CYCLE]` (callers should not be feeding cycles into JSONB, but the\n * redactor must not run forever or fan out exponentially if they do).\n * - Depth cap (`MAX_REDACT_DEPTH`) replaces over-deep subtrees with\n * `[TRUNCATED]`. Distinct sentinel from `[REDACTED]` so the cap is not\n * mistaken for a credential wipe.\n * - Non-plain objects (Date, Map, Set, Buffer, class instances) are returned\n * as-is — audit payloads are JSONB-serializable plain objects by contract;\n * anything else surfaces in the dbWriter, not here.\n * - `Object.create(null)` (null-prototype) IS walked, since it's a valid\n * plain-object shape commonly produced by `Object.fromEntries(...)` and\n * parsed JSON in some libraries.\n *\n * Symbol-keyed properties are NOT walked — `Object.entries` skips them, but\n * they also wouldn't survive JSONB serialization to the DB. Stdout could\n * theoretically leak one if a caller hands us a hybrid object; that's a\n * caller bug we don't try to defend against.\n */\nfunction redactDeep(\n value: unknown,\n denyKeys: ReadonlySet<string>,\n visited: WeakSet<object>,\n depth = 0,\n): unknown {\n if (depth >= MAX_REDACT_DEPTH) return TRUNCATED\n if (value === null || value === undefined) return value\n if (typeof value !== 'object') return value\n if (visited.has(value as object)) return CYCLE\n visited.add(value as object)\n if (Array.isArray(value)) {\n return value.map((v) => redactDeep(v, denyKeys, visited, depth + 1))\n }\n const proto = Object.getPrototypeOf(value)\n if (proto !== Object.prototype && proto !== null) return value\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (denyKeys.has(k.toLowerCase())) {\n out[k] = REDACTED\n } else {\n out[k] = redactDeep(v, denyKeys, visited, depth + 1)\n }\n }\n return out\n}\n\nfunction buildSanitizer(redactKeys?: readonly string[]): (entry: AuditEntry) => AuditEntry {\n const denyKeys = new Set((redactKeys ?? DEFAULT_REDACT_KEYS).map((k) => k.toLowerCase()))\n return (entry) => {\n if (!entry.changes && !entry.metadata) return entry\n // Per-call visited set so a sanitized payload isn't poisoned by a prior\n // call's reference graph.\n const visited = new WeakSet<object>()\n return {\n ...entry,\n ...(entry.changes\n ? { changes: redactDeep(entry.changes, denyKeys, visited) as Record<string, unknown> }\n : {}),\n ...(entry.metadata\n ? { metadata: redactDeep(entry.metadata, denyKeys, visited) 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`). Pino's own\n * top-level `redact` config does not match nested keys, so callers must rely\n * on this sanitizer for nested credentials. Defense-in-depth — callers should\n * still avoid sticking secrets into audit payloads in 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'\n\nexport type Logger = PinoLogger\n\nexport interface LoggerConfig {\n name?: string\n level?: string\n redact?: string[]\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({\n level: process.env.LOG_LEVEL || 'info',\n formatters: {\n level: (label) => ({ level: label }),\n },\n timestamp: false,\n serializers: {\n err: pino.stdSerializers.err,\n error: pino.stdSerializers.err,\n },\n redact: {\n paths: ['password', 'token', 'secret', 'apiKey', 'accessToken', 'refreshToken'],\n censor: '[REDACTED]',\n },\n})\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 * 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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"}
package/dist/plugin.d.mts CHANGED
@@ -1,3 +1,29 @@
1
+ import { AdminRoute } from "@murumets-ee/admin-route";
2
+ import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+
4
+ //#region src/admin/routes.d.ts
5
+ /**
6
+ * The slice of the running app this route's handler actually needs: a DB
7
+ * handle to construct `AuditLogClient` with.
8
+ *
9
+ * This is dependency injection, not a stand-in — the same shape CLAUDE.md
10
+ * blesses for the entity package's resolvers. Naming exactly the surface
11
+ * the handler reads (rather than the whole `ToolkitApp`) is the narrower,
12
+ * better claim: it documents the handler's real coupling and it is what
13
+ * `TApp` is bound to for every type in this file.
14
+ *
15
+ * `ToolkitApp` satisfies it structurally (`db.readWrite: PostgresJsDatabase`),
16
+ * so `AdminRoute<LoggingApp>[]` is assignable to core's
17
+ * `Plugin.server.routes: AdminRoute<ToolkitApp>[]` — `ctx` is a
18
+ * contravariant position, so a NARROWER `ctx.app` here accepts the WIDER
19
+ * app the dispatcher actually passes.
20
+ */
21
+ interface LoggingApp {
22
+ db: {
23
+ readWrite: PostgresJsDatabase;
24
+ };
25
+ }
26
+ //#endregion
1
27
  //#region src/plugin.d.ts
2
28
  declare function logging(): {
3
29
  readonly name: "@murumets-ee/logging";
@@ -28,6 +54,7 @@ declare function logging(): {
28
54
  dialect: "pg";
29
55
  }>;
30
56
  };
57
+ readonly routes: AdminRoute<LoggingApp>[];
31
58
  };
32
59
  readonly shared: {
33
60
  readonly pluginResources: readonly [{
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";iBAkDgB,OAAA,CAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/admin/routes.ts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;UA8DiB,UAAA;EACf,EAAA;IACE,SAAA,EAAW,kBAAkB;EAAA;AAAA;;;iBCDjB,OAAA,CAAA;EAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAAO,UAAA,CAAA,UAAA;EAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{n as e}from"./audit-table-BImbXt_s.mjs";function t(e){return e}function n(){return t({name:`@murumets-ee/logging`,server:{tables:{toolkitAuditLogs:e}},shared:{pluginResources:[{name:`audit-logs`,actions:[`view`]}]}})}export{n as logging};
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-CkD795ec.mjs`).then(e=>e.n))).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
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["import type { Table } from 'drizzle-orm'\nimport { toolkitAuditLogs } from './audit-table.js'\n\n// Structural Plugin shape — kept in sync with @murumets-ee/core. Not imported\n// from core because core depends on logging (circular dep would fail).\n//\n// `Table` (the dialect-agnostic base from `drizzle-orm`) is used instead of\n// `PgTable<any>` because `PgTable<any>` is invariant on its config (the\n// `<any>` kills covariant variance through `_.config`), and narrow\n// `PgTableWithColumns<{...}>` literals captured by `<const P>` inference\n// in `definePlugin` would fail to assign to it. `Table` is drizzle's own\n// canonical accept-type — see drizzle-zod's `createSelectSchema(entity:\n// Table)` and `getTableColumns<T extends Table>`. `readonly` arrays match\n// the widened `Plugin.shared.pluginResources?` so the literal-tuple\n// preservation reaches the `ResolvedPermissions` reader. Issue #357 PR 2.\ninterface LoggingPlugin {\n name: string\n server: {\n tables: Record<string, Table>\n }\n shared: {\n pluginResources: readonly { name: string; actions: readonly string[] }[]\n }\n}\n\n/**\n * Logging plugin — registers the `toolkit_audit_logs` table so it shows up\n * in generated migrations AND contributes the `audit-logs` permission\n * resource so admins can grant audit-log read access via the Permissions\n * UI without hand-declaring the resource in their app's pluginResources\n * array. The audit logger itself is wired up separately via\n * `createAuditLogger()`.\n *\n * @example\n * ```typescript\n * import { logging } from '@murumets-ee/logging/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [logging()],\n * })\n * ```\n */\n// Local `<const T>` identity helper — same pattern as `definePlugin` in\n// `@murumets-ee/core`, inlined here because logging can't import from\n// core (circular dep). Captures the literal types of `name` and\n// `pluginResources` for `ResolvedPermissions<C>` (#357 PR 2).\nfunction defineLoggingPlugin<const T extends LoggingPlugin>(plugin: T): T {\n return plugin\n}\n\nexport function logging() {\n return defineLoggingPlugin({\n name: '@murumets-ee/logging',\n server: {\n tables: { toolkitAuditLogs },\n },\n shared: {\n pluginResources: [{ name: 'audit-logs', actions: ['view'] }],\n },\n })\n}\n"],"mappings":"+CA8CA,SAAS,EAAmD,EAAc,CACxE,OAAO,CACT,CAEA,SAAgB,GAAU,CACxB,OAAO,EAAoB,CACzB,KAAM,uBACN,OAAQ,CACN,OAAQ,CAAE,kBAAiB,CAC7B,EACA,OAAQ,CACN,gBAAiB,CAAC,CAAE,KAAM,aAAc,QAAS,CAAC,MAAM,CAAE,CAAC,CAC7D,CACF,CAAC,CACH"}
1
+ {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/admin/routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Audit log admin routes for the centralized admin API handler.\n *\n * Read-only: only GET handlers. Audit logs are immutable.\n * Admin-only: explicit role check before any data access.\n *\n * `logging()` declares this route on its own `Plugin.server.routes` (see\n * `../plugin.js`) — an app needs no manual wiring for it. The example\n * below (explicit `routes: [...]`) is the legacy back-compat path, kept\n * working for callers that haven't dropped their now-redundant explicit\n * entry yet.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { logRoutes } from '@murumets-ee/logging/admin'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [...],\n * routes: [...logRoutes()],\n * })\n * ```\n */\n\nimport { type AdminRoute, combineAdminRoutes, defineAdminRoute } from '@murumets-ee/admin-route'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\n// Type-only import — keeps the runtime AuditLogClient (and its\n// `@murumets-ee/db`/postgres-js closure) out of the /admin bundle AND out of\n// `dist/plugin.mjs` (this module is now value-imported from `../plugin.js`\n// for the declarative `server.routes` slot, and `dist/plugin.mjs` is what\n// jiti/tsx loads from `lumi.config.ts` on EVERY `lumi` CLI invocation — see\n// `logRoutes`' JSDoc below). The zero-arg fallback branch reaches the real\n// class via a dynamic `import()` at request time instead of a static import.\nimport type { AuditLogClient } from '../audit-client.js'\n\n// ---------------------------------------------------------------------------\n// `TApp` binding — the route TYPES come from `@murumets-ee/admin-route`, a\n// dependency-free leaf (F020/F024). `logging` still cannot import\n// `@murumets-ee/core` (core's app.ts imports `createLogger` from here, so\n// the reverse edge closes a real cycle), and the leaf deliberately cannot\n// name `ToolkitApp` for the same reason — hence its `TApp` generic. `core`\n// binds `TApp = ToolkitApp`; this package binds `TApp = LoggingApp` below.\n// ---------------------------------------------------------------------------\n\n/**\n * The slice of the running app this route's handler actually needs: a DB\n * handle to construct `AuditLogClient` with.\n *\n * This is dependency injection, not a stand-in — the same shape CLAUDE.md\n * blesses for the entity package's resolvers. Naming exactly the surface\n * the handler reads (rather than the whole `ToolkitApp`) is the narrower,\n * better claim: it documents the handler's real coupling and it is what\n * `TApp` is bound to for every type in this file.\n *\n * `ToolkitApp` satisfies it structurally (`db.readWrite: PostgresJsDatabase`),\n * so `AdminRoute<LoggingApp>[]` is assignable to core's\n * `Plugin.server.routes: AdminRoute<ToolkitApp>[]` — `ctx` is a\n * contravariant position, so a NARROWER `ctx.app` here accepts the WIDER\n * app the dispatcher actually passes.\n */\nexport interface LoggingApp {\n db: {\n readWrite: PostgresJsDatabase\n }\n}\n\n// ---------------------------------------------------------------------------\n// Query param validation\n// ---------------------------------------------------------------------------\n\nconst ID_REGEX = /^[a-zA-Z0-9_-]{1,255}$/\n// Strict RFC 4122 UUID — `audit_logs.id` is `column.uuid({...})`, so a\n// non-UUID path segment otherwise reaches postgres and triggers a 500\n// from `invalid input syntax for type uuid`. Validate up front and 404.\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nconst auditLogQuerySchema = z.object({\n action: z.string().max(100).optional(),\n entityType: z.string().max(100).optional(),\n entityId: z.string().regex(ID_REGEX, 'Invalid ID format').optional(),\n userId: z.string().regex(ID_REGEX, 'Invalid user ID format').optional(),\n dateFrom: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n dateTo: z.string().datetime({ offset: true, message: 'Invalid ISO date' }).optional(),\n search: z.string().max(200).optional(),\n limit: z.coerce.number().min(1).max(100).default(50),\n offset: z.coerce.number().min(0).default(0),\n sortField: z.enum(['createdAt', 'action', 'entityType']).default('createdAt'),\n sortDirection: z.enum(['asc', 'desc']).default('desc'),\n})\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/** Subset of `AuditLogClient` actually called by the route handlers. Using\n * `Pick` (not a hand-written `*Like` interface) means new methods on the\n * real client don't silently degrade these to `unknown`. */\ntype AuditLogClientForRoutes = Pick<\n AuditLogClient,\n 'query' | 'findById' | 'getDistinctEntityTypes' | 'getDistinctActions'\n>\n\n/**\n * Build the audit-log admin route(s).\n *\n * Declared through the real `defineAdminRoute` / `combineAdminRoutes`\n * factory pair (plan/admin-api-hardening F020 + F006) now that those\n * primitives live in the dependency-free `@murumets-ee/admin-route` leaf\n * this package CAN depend on. That retires the last hand-written\n * `AdminRoute` object literal in the codebase and, with it, the\n * hand-rolled permission check that used to guard this route: the\n * factory's `guardedHandler` performs the identical\n * `checkPermission('audit-logs', 'view')` test BEFORE the handler runs,\n * and additionally emits the `permission.denied` audit entry + the\n * uniform `{ error, code: 'forbidden' }` body that the hand-rolled gate\n * only partially reproduced. Registering through the factory also lands\n * `audit-logs:view` in the process-local permission catalog, so\n * role-default seeding and the Permission Matrix UI pick it up.\n *\n * `matchAnyPath: true` is required: this prefix's sub-path space is a\n * runtime value (`/logs/<uuid>`) alongside the static `/logs/filters`,\n * which `combineAdminRoutes`' `segments[0]`-keyed dispatch cannot\n * enumerate. The handler keeps doing its own sub-path dispatch exactly as\n * before.\n *\n * `getClient` is an OPTIONAL back-compat escape hatch. `logging()`'s own\n * `Plugin.server.routes` declaration (see `../plugin.js`) calls this with\n * ZERO arguments: a closure captured at plugin-CONSTRUCTION time can't\n * work for a static declarative field — there is no live `app` yet at\n * that point — so the zero-arg path reads `ctx.app` per request instead,\n * the SAME instance the dispatcher resolved via `getApp()`. Existing\n * explicit wiring (`apps/admin`, `apps/perf-harness`, the\n * `lumi admin-api:init` scaffold template) still calls\n * `logRoutes(() => new AuditLogClient(getApp().db.readWrite))` — per\n * plan/admin-api-hardening's scope rule those call sites stay correct\n * (removing the now-redundant explicit entry is a later task), so the\n * override stays supported rather than becoming a breaking signature\n * change.\n */\nexport function logRoutes(getClient?: () => AuditLogClientForRoutes): AdminRoute<LoggingApp>[] {\n return combineAdminRoutes([\n defineAdminRoute<LoggingApp, ''>({\n prefix: 'logs',\n path: '',\n method: 'GET',\n // Catch-all: `/logs`, `/logs/filters` and `/logs/<uuid>` all land on\n // this one guarded handler, which dispatches internally (below).\n matchAnyPath: true,\n permission: 'audit-logs:view',\n defaultRoles: ['admin'],\n description: 'Read the immutable audit log',\n handler: async (req, { segments, app }) => {\n // No permission check here — `defineAdminRoute`'s `guardedHandler`\n // has already enforced `audit-logs:view` (and audited the denial)\n // by the time this runs. A second inline check would be a copy\n // that can drift; that duplication is exactly what this plan\n // exists to remove.\n const client = getClient\n ? getClient()\n : new (await import('../audit-client.js')).AuditLogClient(app.db.readWrite)\n\n const firstSegment = segments[0]\n\n // GET /logs/filters — distinct values for filter dropdowns\n if (segments.length === 1 && firstSegment === 'filters') {\n const [entityTypes, actions] = await Promise.all([\n client.getDistinctEntityTypes(),\n client.getDistinctActions(),\n ])\n return json({ entityTypes, actions })\n }\n\n // GET /logs/:id — single entry detail\n if (segments.length === 1 && firstSegment !== undefined) {\n if (!UUID_REGEX.test(firstSegment)) {\n return errorJson('Audit log entry not found', 404)\n }\n const entry = await client.findById(firstSegment)\n if (!entry) return errorJson('Audit log entry not found', 404)\n return json(entry)\n }\n\n // GET /logs — list with filters + pagination. Reject anything\n // deeper than a single id/keyword segment so unknown paths don't\n // silently fall through and return the full list.\n if (segments.length > 1) {\n return errorJson('Not found', 404)\n }\n\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const parsed = auditLogQuerySchema.safeParse(params)\n if (!parsed.success) {\n return errorJson(\n `Invalid query params: ${parsed.error.issues.map((i) => i.message).join(', ')}`,\n 400,\n )\n }\n\n const { dateFrom, dateTo, ...rest } = parsed.data\n const result = await client.query({\n ...rest,\n ...(dateFrom && { dateFrom: new Date(dateFrom) }),\n ...(dateTo && { dateTo: new Date(dateTo) }),\n })\n\n return json(result)\n },\n // No POST, PATCH, DELETE entries — audit logs are append-only.\n }),\n ])\n}\n","import type { AdminRoute } from '@murumets-ee/admin-route'\nimport type { Table } from 'drizzle-orm'\nimport { type LoggingApp, logRoutes } from './admin/routes.js'\nimport { toolkitAuditLogs } from './audit-table.js'\n\n// Structural Plugin shape — kept in sync with @murumets-ee/core. Not imported\n// from core because core depends on logging (circular dep would fail).\n//\n// `Table` (the dialect-agnostic base from `drizzle-orm`) is used instead of\n// `PgTable<any>` because `PgTable<any>` is invariant on its config (the\n// `<any>` kills covariant variance through `_.config`), and narrow\n// `PgTableWithColumns<{...}>` literals captured by `<const P>` inference\n// in `definePlugin` would fail to assign to it. `Table` is drizzle's own\n// canonical accept-type — see drizzle-zod's `createSelectSchema(entity:\n// Table)` and `getTableColumns<T extends Table>`. `readonly` arrays match\n// the widened `Plugin.shared.pluginResources?` so the literal-tuple\n// preservation reaches the `ResolvedPermissions` reader. Issue #357 PR 2.\n//\n// `server.routes` uses the REAL `AdminRoute` from `@murumets-ee/admin-route`\n// (the dependency-free leaf core re-exports it from — F020), with `TApp`\n// bound to `LoggingApp` because this file can't name `ToolkitApp` without\n// importing `@murumets-ee/core`. `AdminRoute<LoggingApp>[]` is assignable\n// to core's `Plugin.server.routes` (`AdminRoute<ToolkitApp>[]`): `ctx` is a\n// contravariant position under `strictFunctionTypes`, and `ToolkitApp`\n// satisfies `LoggingApp`'s `{ db: { readWrite } }`.\ninterface LoggingPlugin {\n name: string\n server: {\n tables: Record<string, Table>\n routes: AdminRoute<LoggingApp>[]\n }\n shared: {\n pluginResources: readonly { name: string; actions: readonly string[] }[]\n }\n}\n\n/**\n * Logging plugin — registers the `toolkit_audit_logs` table so it shows up\n * in generated migrations, contributes the `audit-logs` permission\n * resource so admins can grant audit-log read access via the Permissions\n * UI without hand-declaring the resource in their app's pluginResources\n * array, AND declares the `/api/admin/logs` route on `server.routes` so\n * an app needs no manual wiring for it either (plan/admin-api-hardening\n * F006). The audit logger itself is wired up separately via\n * `createAuditLogger()`.\n *\n * @example\n * ```typescript\n * import { logging } from '@murumets-ee/logging/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [logging()],\n * })\n * ```\n */\n// Local `<const T>` identity helper — same pattern as `definePlugin` in\n// `@murumets-ee/core`, inlined here because logging can't import from\n// core (circular dep). Captures the literal types of `name` and\n// `pluginResources` for `ResolvedPermissions<C>` (#357 PR 2).\nfunction defineLoggingPlugin<const T extends LoggingPlugin>(plugin: T): T {\n return plugin\n}\n\nexport function logging() {\n return defineLoggingPlugin({\n name: '@murumets-ee/logging',\n server: {\n tables: { toolkitAuditLogs },\n // Zero-arg call — a closure captured here (at plugin-construction\n // time) can't work: there is no live `app` yet. The route reads\n // `ctx.app` per request instead (see `logRoutes`' JSDoc in\n // `./admin/routes.js`). `logRoutes()` returns the `combineAdminRoutes`\n // array directly, so it is spread-assigned, not wrapped.\n routes: logRoutes(),\n },\n shared: {\n pluginResources: [{ name: 'audit-logs', actions: ['view'] }],\n },\n })\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/logging",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,7 +24,8 @@
24
24
  "drizzle-orm": "^0.45.2",
25
25
  "pino": "^9.5.0",
26
26
  "zod": "^3.24.1",
27
- "@murumets-ee/db": "0.36.0"
27
+ "@murumets-ee/admin-route": "0.38.0",
28
+ "@murumets-ee/db": "0.38.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "tsdown": "^0.22.2",