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
9import { runInProjectDatabase } from '../db/data-plane.js';
10import { ValidationError } from '@briven/shared';
11
12const MAX_LIMIT = 200;
13const DEFAULT_LIMIT = 50;
14
15export type LogLevel = 'error' | 'warn' | 'info';
16
17export interface WriteAppLogInput {
18 level: LogLevel;
19 action: string;
20 message: string;
21 metadata?: Record<string, unknown>;
22}
23
24export interface AppLogEntry {
25 id: string;
26 level: LogLevel;
27 action: string;
28 message: string;
29 metadata: Record<string, unknown>;
30 createdAt: string;
31}
32
33export 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
55export interface ListAppLogsOpts {
56 limit?: number;
57 level?: LogLevel;
58 action?: string;
59 cursor?: string | null;
60}
61
62export interface AppLogListResult {
63 items: AppLogEntry[];
64 nextCursor: string | null;
65}
66
67interface RawAppLogRow {
68 id: string;
69 level: string;
70 action: string;
71 message: string;
72 metadata: unknown;
73 created_at: Date;
74}
75
76export 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 */
143export 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 */
160export 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
174function isPlainObject(v: unknown): v is Record<string, unknown> {
175 return v !== null && typeof v === 'object' && !Array.isArray(v);
176}
177
178function formatCursor(args: { createdAt: string; id: string }): string {
179 return `${args.createdAt}__${args.id}`;
180}
181
182function 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}