auth-app-logs.ts192 lines · main
| 1 | /** |
| 2 | * Application logs for briven auth tenants (Phase 6.3). |
| 3 | * |
| 4 | * Structured operational logs (errors, warnings, info) that supplement |
| 5 | * the audit log with diagnostic detail. Retention is tenant-configurable |
| 6 | * via `authConfig.retention.appLogDays` (7/30/90). |
| 7 | */ |
| 8 | |
| 9 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 10 | import { ValidationError } from '@briven/shared'; |
| 11 | |
| 12 | const MAX_LIMIT = 200; |
| 13 | const DEFAULT_LIMIT = 50; |
| 14 | |
| 15 | export type LogLevel = 'error' | 'warn' | 'info'; |
| 16 | |
| 17 | export interface WriteAppLogInput { |
| 18 | level: LogLevel; |
| 19 | action: string; |
| 20 | message: string; |
| 21 | metadata?: Record<string, unknown>; |
| 22 | } |
| 23 | |
| 24 | export interface AppLogEntry { |
| 25 | id: string; |
| 26 | level: LogLevel; |
| 27 | action: string; |
| 28 | message: string; |
| 29 | metadata: Record<string, unknown>; |
| 30 | createdAt: string; |
| 31 | } |
| 32 | |
| 33 | export async function writeAppLog( |
| 34 | projectId: string, |
| 35 | input: WriteAppLogInput, |
| 36 | ): Promise<void> { |
| 37 | if (!['error', 'warn', 'info'].includes(input.level)) { |
| 38 | throw new ValidationError('log level must be error, warn, or info', { level: input.level }); |
| 39 | } |
| 40 | await runInProjectDatabase(projectId, async (tx) => { |
| 41 | await tx.unsafe( |
| 42 | `INSERT INTO "_briven_auth_app_logs" (id, level, action, message, metadata, created_at) |
| 43 | VALUES ($1, $2, $3, $4, $5, now())`, |
| 44 | [ |
| 45 | `log_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, |
| 46 | input.level, |
| 47 | input.action, |
| 48 | input.message, |
| 49 | JSON.stringify(input.metadata ?? {}), |
| 50 | ] as never[], |
| 51 | ); |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | export interface ListAppLogsOpts { |
| 56 | limit?: number; |
| 57 | level?: LogLevel; |
| 58 | action?: string; |
| 59 | cursor?: string | null; |
| 60 | } |
| 61 | |
| 62 | export interface AppLogListResult { |
| 63 | items: AppLogEntry[]; |
| 64 | nextCursor: string | null; |
| 65 | } |
| 66 | |
| 67 | interface RawAppLogRow { |
| 68 | id: string; |
| 69 | level: string; |
| 70 | action: string; |
| 71 | message: string; |
| 72 | metadata: unknown; |
| 73 | created_at: Date; |
| 74 | } |
| 75 | |
| 76 | export async function listAppLogs( |
| 77 | projectId: string, |
| 78 | opts: ListAppLogsOpts = {}, |
| 79 | ): Promise<AppLogListResult> { |
| 80 | const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); |
| 81 | |
| 82 | const where: string[] = ['1=1']; |
| 83 | const params: unknown[] = []; |
| 84 | |
| 85 | if (opts.cursor) { |
| 86 | const parsed = parseCursor(opts.cursor); |
| 87 | params.push(parsed.createdAt, parsed.id); |
| 88 | where.push( |
| 89 | `(created_at < $${params.length - 1}::timestamptz OR (created_at = $${params.length - 1}::timestamptz AND id < $${params.length}))`, |
| 90 | ); |
| 91 | } |
| 92 | if (opts.level) { |
| 93 | params.push(opts.level); |
| 94 | where.push(`level = $${params.length}`); |
| 95 | } |
| 96 | if (opts.action) { |
| 97 | params.push(opts.action); |
| 98 | where.push(`action = $${params.length}`); |
| 99 | } |
| 100 | |
| 101 | params.push(limit + 1); |
| 102 | const limitPlaceholder = `$${params.length}`; |
| 103 | |
| 104 | const rows = await runInProjectDatabase<RawAppLogRow[]>(projectId, async (tx) => { |
| 105 | return (await tx.unsafe( |
| 106 | ` |
| 107 | SELECT id, level, action, message, metadata, created_at |
| 108 | FROM "_briven_auth_app_logs" |
| 109 | WHERE ${where.join(' AND ')} |
| 110 | ORDER BY created_at DESC, id DESC |
| 111 | LIMIT ${limitPlaceholder} |
| 112 | `, |
| 113 | params as never[], |
| 114 | )) as RawAppLogRow[]; |
| 115 | }); |
| 116 | |
| 117 | const hasMore = rows.length > limit; |
| 118 | const page = hasMore ? rows.slice(0, limit) : rows; |
| 119 | |
| 120 | const items = page.map((r) => ({ |
| 121 | id: r.id, |
| 122 | level: r.level as LogLevel, |
| 123 | action: r.action, |
| 124 | message: r.message, |
| 125 | metadata: isPlainObject(r.metadata) ? r.metadata : {}, |
| 126 | createdAt: r.created_at.toISOString(), |
| 127 | })); |
| 128 | |
| 129 | const nextCursor = hasMore |
| 130 | ? formatCursor({ |
| 131 | createdAt: page[page.length - 1]!.created_at.toISOString(), |
| 132 | id: page[page.length - 1]!.id, |
| 133 | }) |
| 134 | : null; |
| 135 | |
| 136 | return { items, nextCursor }; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Purge app logs older than the retention threshold. |
| 141 | * Called by the janitor worker. |
| 142 | */ |
| 143 | export async function purgeOldAppLogs( |
| 144 | projectId: string, |
| 145 | retentionDays: number, |
| 146 | ): Promise<{ deleted: number }> { |
| 147 | const result = await runInProjectDatabase<{ count: number }[]>(projectId, async (tx) => { |
| 148 | return (await tx.unsafe( |
| 149 | `DELETE FROM "_briven_auth_app_logs" |
| 150 | WHERE created_at < now() - interval '${retentionDays} days' |
| 151 | RETURNING 1`, |
| 152 | )) as { count: number }[]; |
| 153 | }); |
| 154 | return { deleted: result.length }; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Purge audit logs older than the retention threshold. |
| 159 | */ |
| 160 | export async function purgeOldAuditLogs( |
| 161 | projectId: string, |
| 162 | retentionDays: number, |
| 163 | ): Promise<{ deleted: number }> { |
| 164 | const result = await runInProjectDatabase<{ count: number }[]>(projectId, async (tx) => { |
| 165 | return (await tx.unsafe( |
| 166 | `DELETE FROM "_briven_auth_audit_log" |
| 167 | WHERE occurred_at < now() - interval '${retentionDays} days' |
| 168 | RETURNING 1`, |
| 169 | )) as { count: number }[]; |
| 170 | }); |
| 171 | return { deleted: result.length }; |
| 172 | } |
| 173 | |
| 174 | function isPlainObject(v: unknown): v is Record<string, unknown> { |
| 175 | return v !== null && typeof v === 'object' && !Array.isArray(v); |
| 176 | } |
| 177 | |
| 178 | function formatCursor(args: { createdAt: string; id: string }): string { |
| 179 | return `${args.createdAt}__${args.id}`; |
| 180 | } |
| 181 | |
| 182 | function parseCursor(raw: string): { createdAt: string; id: string } { |
| 183 | const sepAt = raw.indexOf('__'); |
| 184 | if (sepAt <= 0) throw new ValidationError('invalid cursor', { cursor: raw }); |
| 185 | const createdAt = raw.slice(0, sepAt); |
| 186 | const id = raw.slice(sepAt + 2); |
| 187 | if (!id) throw new ValidationError('invalid cursor', { cursor: raw }); |
| 188 | if (Number.isNaN(Date.parse(createdAt))) { |
| 189 | throw new ValidationError('invalid cursor', { cursor: raw }); |
| 190 | } |
| 191 | return { createdAt, id }; |
| 192 | } |