@murumets-ee/logging 0.35.1 → 0.37.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 +59 -13
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/admin.mjs.map +1 -1
- package/dist/audit-client-4XgZ_M3r.d.mts +52 -0
- package/dist/audit-client-4XgZ_M3r.d.mts.map +1 -0
- package/dist/audit-client-CkD795ec.mjs +2 -0
- package/dist/audit-client-CkD795ec.mjs.map +1 -0
- package/dist/audit-client-T2P2K970.mjs +2 -0
- package/dist/audit-client-T2P2K970.mjs.map +1 -0
- package/dist/index.d.mts +1 -48
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/plugin.d.mts +27 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/package.json +3 -2
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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-playground`, `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
|
package/dist/admin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/audit-client.ts","../src/admin/routes.ts"],"mappings":"
|
|
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{
|
|
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
|
package/dist/admin.mjs.map
CHANGED
|
@@ -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-playground`, `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,3 +1,4 @@
|
|
|
1
|
+
import { i as AuditLogQueryOptions, n as AuditLogEntry, r as AuditLogListResult, t as AuditLogClient } from "./audit-client-4XgZ_M3r.mjs";
|
|
1
2
|
import { Logger as Logger$1 } from "pino";
|
|
2
3
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
4
|
|
|
@@ -79,53 +80,5 @@ declare function createAuditDbWriter(db: PostgresJsDatabase): (entry: AuditEntry
|
|
|
79
80
|
*/
|
|
80
81
|
declare function createAuditLogger(config: AuditConfig): AuditLogger;
|
|
81
82
|
//#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
|
-
}
|
|
129
|
-
//#endregion
|
|
130
83
|
export { type AuditConfig, type AuditEntry, AuditLogClient, type AuditLogEntry, type AuditLogListResult, type AuditLogQueryOptions, type AuditLogger, type Logger, type LoggerConfig, createAuditDbWriter, createAuditLogger, createLogger };
|
|
131
84
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/logger.ts","../src/audit.ts"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/logger.ts","../src/audit.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;;;;;;EAMf,MAAA,EAAQ,IAAA,CAAK,MAAA;EACb,QAAA,IAAY,KAAA,EAAO,UAAA,KAAe,OAAA;EDmBpB;;;;;;;;AAA2C;;;;ECNzD,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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./audit-
|
|
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`];function r(e,t,n,i=0){if(i>=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=>r(e,t,n,i+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]=r(s,t,n,i+1);return o}function i(e){let t=new Set((e??n).map(e=>e.toLowerCase()));return e=>{if(!e.changes&&!e.metadata)return e;let n=new WeakSet;return{...e,...e.changes?{changes:r(e.changes,t,n)}:{},...e.metadata?{metadata:r(e.metadata,t,n)}:{}}}}function a(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 o(e){let t=i(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 s=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 c(e){if(!e)return s;let t={};e.name&&(t.name=e.name);let n={};return e.level&&(n.level=e.level),s.child(t,n)}export{e as AuditLogClient,a as createAuditDbWriter,o as createAuditLogger,c as createLogger};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -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/audit.ts","../src/logger.ts"],"sourcesContent":["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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"}
|
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 [{
|
package/dist/plugin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";
|
|
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{
|
|
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
|
package/dist/plugin.mjs.map
CHANGED
|
@@ -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-playground`, `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.
|
|
3
|
+
"version": "0.37.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/
|
|
27
|
+
"@murumets-ee/admin-route": "0.37.0",
|
|
28
|
+
"@murumets-ee/db": "0.37.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"tsdown": "^0.22.2",
|