@mars-stack/cli 0.2.0 → 0.2.2
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/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# Skill: Add Audit Log
|
|
2
|
+
|
|
3
|
+
Add user action tracking with a Prisma `AuditLog` model, server-side logging helper, admin view, and retention policy.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add audit logging, activity tracking, compliance logging, user action history, or admin activity monitoring.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Read `src/lib/mars.ts` to check available auth wrappers.
|
|
12
|
+
- Read `prisma/schema/` to see existing models.
|
|
13
|
+
|
|
14
|
+
## Step 1: Prisma Schema
|
|
15
|
+
|
|
16
|
+
```prisma
|
|
17
|
+
// prisma/schema/audit-log.prisma
|
|
18
|
+
model AuditLog {
|
|
19
|
+
id String @id @default(cuid())
|
|
20
|
+
userId String?
|
|
21
|
+
action String
|
|
22
|
+
resourceType String
|
|
23
|
+
resourceId String?
|
|
24
|
+
metadata Json?
|
|
25
|
+
ipAddress String?
|
|
26
|
+
userAgent String?
|
|
27
|
+
timestamp DateTime @default(now())
|
|
28
|
+
|
|
29
|
+
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
30
|
+
|
|
31
|
+
@@index([userId])
|
|
32
|
+
@@index([action])
|
|
33
|
+
@@index([resourceType])
|
|
34
|
+
@@index([resourceType, resourceId])
|
|
35
|
+
@@index([timestamp])
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Update the User model in `prisma/schema/auth.prisma` to add the relation:
|
|
40
|
+
|
|
41
|
+
```prisma
|
|
42
|
+
model User {
|
|
43
|
+
// ... existing fields
|
|
44
|
+
auditLogs AuditLog[]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run `yarn db:push` to sync.
|
|
49
|
+
|
|
50
|
+
## Step 2: Audit Log Types
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// src/features/audit-log/types.ts
|
|
54
|
+
export interface AuditEventInput {
|
|
55
|
+
userId?: string;
|
|
56
|
+
action: AuditAction;
|
|
57
|
+
resourceType: string;
|
|
58
|
+
resourceId?: string;
|
|
59
|
+
metadata?: Record<string, unknown>;
|
|
60
|
+
ipAddress?: string;
|
|
61
|
+
userAgent?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AuditAction =
|
|
65
|
+
| 'create'
|
|
66
|
+
| 'read'
|
|
67
|
+
| 'update'
|
|
68
|
+
| 'delete'
|
|
69
|
+
| 'login'
|
|
70
|
+
| 'logout'
|
|
71
|
+
| 'login_failed'
|
|
72
|
+
| 'password_change'
|
|
73
|
+
| 'password_reset'
|
|
74
|
+
| 'email_verify'
|
|
75
|
+
| 'role_change'
|
|
76
|
+
| 'export'
|
|
77
|
+
| 'invite'
|
|
78
|
+
| 'settings_change';
|
|
79
|
+
|
|
80
|
+
export interface AuditLogQuery {
|
|
81
|
+
userId?: string;
|
|
82
|
+
action?: AuditAction;
|
|
83
|
+
resourceType?: string;
|
|
84
|
+
resourceId?: string;
|
|
85
|
+
startDate?: Date;
|
|
86
|
+
endDate?: Date;
|
|
87
|
+
limit?: number;
|
|
88
|
+
cursor?: string;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Step 3: Server-Side Audit Logger
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// src/features/audit-log/server/index.ts
|
|
96
|
+
import 'server-only';
|
|
97
|
+
|
|
98
|
+
import { prisma } from '@/lib/prisma';
|
|
99
|
+
import { apiLogger } from '@/lib/mars';
|
|
100
|
+
import type { AuditEventInput, AuditLogQuery } from '../types';
|
|
101
|
+
|
|
102
|
+
export async function logAuditEvent(event: AuditEventInput): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await prisma.auditLog.create({
|
|
105
|
+
data: {
|
|
106
|
+
userId: event.userId,
|
|
107
|
+
action: event.action,
|
|
108
|
+
resourceType: event.resourceType,
|
|
109
|
+
resourceId: event.resourceId,
|
|
110
|
+
metadata: event.metadata ?? undefined,
|
|
111
|
+
ipAddress: event.ipAddress,
|
|
112
|
+
userAgent: event.userAgent,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
apiLogger.error({ error, event: event.action }, 'Failed to write audit log');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function logAuditEventFromRequest(
|
|
121
|
+
request: Request,
|
|
122
|
+
event: Omit<AuditEventInput, 'ipAddress' | 'userAgent'>,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
return logAuditEvent({
|
|
125
|
+
...event,
|
|
126
|
+
ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
127
|
+
?? request.headers.get('x-real-ip')
|
|
128
|
+
?? undefined,
|
|
129
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function queryAuditLogs(query: AuditLogQuery) {
|
|
134
|
+
const where: Record<string, unknown> = {};
|
|
135
|
+
|
|
136
|
+
if (query.userId) where.userId = query.userId;
|
|
137
|
+
if (query.action) where.action = query.action;
|
|
138
|
+
if (query.resourceType) where.resourceType = query.resourceType;
|
|
139
|
+
if (query.resourceId) where.resourceId = query.resourceId;
|
|
140
|
+
|
|
141
|
+
if (query.startDate || query.endDate) {
|
|
142
|
+
where.timestamp = {};
|
|
143
|
+
if (query.startDate) (where.timestamp as Record<string, unknown>).gte = query.startDate;
|
|
144
|
+
if (query.endDate) (where.timestamp as Record<string, unknown>).lte = query.endDate;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const take = Math.min(query.limit ?? 50, 100);
|
|
148
|
+
|
|
149
|
+
return prisma.auditLog.findMany({
|
|
150
|
+
where,
|
|
151
|
+
orderBy: { timestamp: 'desc' },
|
|
152
|
+
take,
|
|
153
|
+
...(query.cursor ? { skip: 1, cursor: { id: query.cursor } } : {}),
|
|
154
|
+
include: {
|
|
155
|
+
user: { select: { id: true, email: true, name: true } },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function cleanupOldAuditLogs(retentionDays: number = 90): Promise<number> {
|
|
161
|
+
const cutoff = new Date();
|
|
162
|
+
cutoff.setDate(cutoff.getDate() - retentionDays);
|
|
163
|
+
|
|
164
|
+
const result = await prisma.auditLog.deleteMany({
|
|
165
|
+
where: { timestamp: { lt: cutoff } },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
apiLogger.info({ deleted: result.count, retentionDays }, 'Audit log cleanup completed');
|
|
169
|
+
return result.count;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Step 4: Usage in API Routes
|
|
174
|
+
|
|
175
|
+
Integrate audit logging into existing route handlers:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// Example: in an existing update route
|
|
179
|
+
import { logAuditEventFromRequest } from '@/features/audit-log/server';
|
|
180
|
+
|
|
181
|
+
export const PUT = withAuth(async (request, { params }) => {
|
|
182
|
+
try {
|
|
183
|
+
const { id } = await params;
|
|
184
|
+
const body = updateSchema.parse(await request.json());
|
|
185
|
+
const updated = await updateResource(request.session.userId, id, body);
|
|
186
|
+
|
|
187
|
+
await logAuditEventFromRequest(request, {
|
|
188
|
+
userId: request.session.userId,
|
|
189
|
+
action: 'update',
|
|
190
|
+
resourceType: 'widget',
|
|
191
|
+
resourceId: id,
|
|
192
|
+
metadata: { fields: Object.keys(body) },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return NextResponse.json(updated);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return handleApiError(error, { endpoint: '/api/protected/widgets/[id]' });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Auth event logging
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// In login route: src/app/api/auth/login/route.ts
|
|
206
|
+
await logAuditEventFromRequest(request, {
|
|
207
|
+
userId: user.id,
|
|
208
|
+
action: 'login',
|
|
209
|
+
resourceType: 'session',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// On failed login
|
|
213
|
+
await logAuditEventFromRequest(request, {
|
|
214
|
+
action: 'login_failed',
|
|
215
|
+
resourceType: 'auth',
|
|
216
|
+
metadata: { email: body.email },
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Step 5: Admin Audit Log API
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
// src/app/api/protected/admin/audit-logs/route.ts
|
|
224
|
+
import { handleApiError, withRole } from '@/lib/mars';
|
|
225
|
+
import { queryAuditLogs } from '@/features/audit-log/server';
|
|
226
|
+
import { NextResponse } from 'next/server';
|
|
227
|
+
import { z } from 'zod';
|
|
228
|
+
|
|
229
|
+
const querySchema = z.object({
|
|
230
|
+
userId: z.string().optional(),
|
|
231
|
+
action: z.string().optional(),
|
|
232
|
+
resourceType: z.string().optional(),
|
|
233
|
+
resourceId: z.string().optional(),
|
|
234
|
+
startDate: z.coerce.date().optional(),
|
|
235
|
+
endDate: z.coerce.date().optional(),
|
|
236
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
237
|
+
cursor: z.string().optional(),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const GET = withRole(['admin'], async (request) => {
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(request.url);
|
|
243
|
+
const query = querySchema.parse(Object.fromEntries(url.searchParams));
|
|
244
|
+
const logs = await queryAuditLogs(query);
|
|
245
|
+
return NextResponse.json(logs);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return handleApiError(error, { endpoint: '/api/protected/admin/audit-logs' });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Step 6: Cleanup API (Admin)
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// src/app/api/protected/admin/audit-logs/cleanup/route.ts
|
|
256
|
+
import { handleApiError, withRole } from '@/lib/mars';
|
|
257
|
+
import { cleanupOldAuditLogs } from '@/features/audit-log/server';
|
|
258
|
+
import { NextResponse } from 'next/server';
|
|
259
|
+
import { z } from 'zod';
|
|
260
|
+
|
|
261
|
+
const cleanupSchema = z.object({
|
|
262
|
+
retentionDays: z.number().int().min(1).max(365).default(90),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
export const POST = withRole(['admin'], async (request) => {
|
|
266
|
+
try {
|
|
267
|
+
const body = cleanupSchema.parse(await request.json());
|
|
268
|
+
const deleted = await cleanupOldAuditLogs(body.retentionDays);
|
|
269
|
+
return NextResponse.json({ deleted, retentionDays: body.retentionDays });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return handleApiError(error, { endpoint: '/api/protected/admin/audit-logs/cleanup' });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Step 7: Retention Policy
|
|
277
|
+
|
|
278
|
+
Set up automated cleanup using a cron job or scheduled function:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// src/app/api/cron/audit-cleanup/route.ts
|
|
282
|
+
import { NextResponse } from 'next/server';
|
|
283
|
+
import { cleanupOldAuditLogs } from '@/features/audit-log/server';
|
|
284
|
+
|
|
285
|
+
export async function GET(request: Request) {
|
|
286
|
+
const authHeader = request.headers.get('authorization');
|
|
287
|
+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
288
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const deleted = await cleanupOldAuditLogs(90);
|
|
292
|
+
return NextResponse.json({ deleted });
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Add to `vercel.json` for Vercel Cron:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"crons": [
|
|
301
|
+
{
|
|
302
|
+
"path": "/api/cron/audit-cleanup",
|
|
303
|
+
"schedule": "0 3 * * 0"
|
|
304
|
+
}
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Testing
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
313
|
+
import { logAuditEvent, queryAuditLogs } from './index';
|
|
314
|
+
|
|
315
|
+
vi.mock('@/lib/prisma', () => ({
|
|
316
|
+
prisma: {
|
|
317
|
+
auditLog: {
|
|
318
|
+
create: vi.fn(),
|
|
319
|
+
findMany: vi.fn(),
|
|
320
|
+
deleteMany: vi.fn(),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
vi.mock('@/lib/mars', () => ({
|
|
326
|
+
apiLogger: { error: vi.fn(), info: vi.fn() },
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
describe('logAuditEvent', () => {
|
|
330
|
+
it('creates an audit log entry', async () => {
|
|
331
|
+
const { prisma } = await import('@/lib/prisma');
|
|
332
|
+
await logAuditEvent({
|
|
333
|
+
userId: 'user-1',
|
|
334
|
+
action: 'create',
|
|
335
|
+
resourceType: 'widget',
|
|
336
|
+
resourceId: 'widget-1',
|
|
337
|
+
});
|
|
338
|
+
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
data: expect.objectContaining({
|
|
341
|
+
userId: 'user-1',
|
|
342
|
+
action: 'create',
|
|
343
|
+
resourceType: 'widget',
|
|
344
|
+
}),
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('does not throw if database write fails', async () => {
|
|
350
|
+
const { prisma } = await import('@/lib/prisma');
|
|
351
|
+
(prisma.auditLog.create as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('DB error'));
|
|
352
|
+
await expect(logAuditEvent({
|
|
353
|
+
action: 'login',
|
|
354
|
+
resourceType: 'session',
|
|
355
|
+
})).resolves.toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Checklist
|
|
361
|
+
|
|
362
|
+
- [ ] `AuditLog` model in Prisma schema with proper indexes
|
|
363
|
+
- [ ] User relation added (with `onDelete: SetNull`)
|
|
364
|
+
- [ ] `logAuditEvent()` and `logAuditEventFromRequest()` helpers created
|
|
365
|
+
- [ ] Server module imports `'server-only'`
|
|
366
|
+
- [ ] Audit logging integrated into key routes (auth, CRUD, admin actions)
|
|
367
|
+
- [ ] Admin query API with filtering and cursor pagination
|
|
368
|
+
- [ ] Cleanup endpoint and retention policy (default 90 days)
|
|
369
|
+
- [ ] Cron job for automated cleanup
|
|
370
|
+
- [ ] Audit logger never throws (fire-and-forget with error logging)
|
|
371
|
+
- [ ] No PII logged in metadata (no passwords, tokens, or full request bodies)
|
|
372
|
+
- [ ] `db:push` run after schema changes
|
|
373
|
+
- [ ] Tests written for audit log service
|