@mars-stack/core 0.4.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.
Files changed (192) hide show
  1. package/README.md +32 -0
  2. package/cursor/manifest.json +304 -0
  3. package/cursor/rules/mars-composition-patterns.mdc +186 -0
  4. package/cursor/rules/mars-data-access.mdc +26 -0
  5. package/cursor/rules/mars-project-structure.mdc +34 -0
  6. package/cursor/rules/mars-security.mdc +25 -0
  7. package/cursor/rules/mars-testing.mdc +24 -0
  8. package/cursor/rules/mars-ui-conventions.mdc +29 -0
  9. package/cursor/skills/mars-add-api-route/SKILL.md +120 -0
  10. package/cursor/skills/mars-add-audit-log/SKILL.md +373 -0
  11. package/cursor/skills/mars-add-blog/SKILL.md +447 -0
  12. package/cursor/skills/mars-add-command-palette/SKILL.md +438 -0
  13. package/cursor/skills/mars-add-component/SKILL.md +158 -0
  14. package/cursor/skills/mars-add-crud-routes/SKILL.md +221 -0
  15. package/cursor/skills/mars-add-e2e-test/SKILL.md +227 -0
  16. package/cursor/skills/mars-add-error-boundary/SKILL.md +472 -0
  17. package/cursor/skills/mars-add-feature/SKILL.md +174 -0
  18. package/cursor/skills/mars-add-middleware/SKILL.md +135 -0
  19. package/cursor/skills/mars-add-page/SKILL.md +153 -0
  20. package/cursor/skills/mars-add-prisma-model/SKILL.md +148 -0
  21. package/cursor/skills/mars-add-protected-resource/SKILL.md +192 -0
  22. package/cursor/skills/mars-add-role/SKILL.md +156 -0
  23. package/cursor/skills/mars-add-server-action/SKILL.md +167 -0
  24. package/cursor/skills/mars-add-webhook/SKILL.md +192 -0
  25. package/cursor/skills/mars-build-complete-feature/SKILL.md +228 -0
  26. package/cursor/skills/mars-build-dashboard/SKILL.md +211 -0
  27. package/cursor/skills/mars-build-data-table/SKILL.md +284 -0
  28. package/cursor/skills/mars-build-form/SKILL.md +229 -0
  29. package/cursor/skills/mars-build-landing-page/SKILL.md +248 -0
  30. package/cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  31. package/cursor/skills/mars-configure-ai/SKILL.md +617 -0
  32. package/cursor/skills/mars-configure-analytics/SKILL.md +413 -0
  33. package/cursor/skills/mars-configure-dark-mode/SKILL.md +309 -0
  34. package/cursor/skills/mars-configure-email/SKILL.md +170 -0
  35. package/cursor/skills/mars-configure-email-verification/SKILL.md +333 -0
  36. package/cursor/skills/mars-configure-feature-flags/SKILL.md +361 -0
  37. package/cursor/skills/mars-configure-i18n/SKILL.md +518 -0
  38. package/cursor/skills/mars-configure-jobs/SKILL.md +500 -0
  39. package/cursor/skills/mars-configure-magic-links/SKILL.md +385 -0
  40. package/cursor/skills/mars-configure-multi-tenancy/SKILL.md +611 -0
  41. package/cursor/skills/mars-configure-notifications/SKILL.md +569 -0
  42. package/cursor/skills/mars-configure-oauth/SKILL.md +217 -0
  43. package/cursor/skills/mars-configure-onboarding/SKILL.md +483 -0
  44. package/cursor/skills/mars-configure-payments/SKILL.md +243 -0
  45. package/cursor/skills/mars-configure-realtime/SKILL.md +733 -0
  46. package/cursor/skills/mars-configure-search/SKILL.md +581 -0
  47. package/cursor/skills/mars-configure-storage/SKILL.md +273 -0
  48. package/cursor/skills/mars-configure-two-factor/SKILL.md +518 -0
  49. package/cursor/skills/mars-create-execution-plan/SKILL.md +204 -0
  50. package/cursor/skills/mars-create-seed/SKILL.md +191 -0
  51. package/cursor/skills/mars-deploy-to-vercel/SKILL.md +300 -0
  52. package/cursor/skills/mars-design-tokens/SKILL.md +138 -0
  53. package/cursor/skills/mars-setup-billing/SKILL.md +322 -0
  54. package/cursor/skills/mars-setup-project/SKILL.md +104 -0
  55. package/cursor/skills/mars-setup-teams/SKILL.md +688 -0
  56. package/cursor/skills/mars-test-api-route/SKILL.md +219 -0
  57. package/cursor/skills/mars-update-architecture-docs/SKILL.md +189 -0
  58. package/dist/api-error/index.d.ts +27 -0
  59. package/dist/api-error/index.d.ts.map +1 -0
  60. package/dist/api-error/index.js +2 -0
  61. package/dist/auth/credential-tag.d.ts +5 -0
  62. package/dist/auth/credential-tag.d.ts.map +1 -0
  63. package/dist/auth/credential-tag.js +2 -0
  64. package/dist/auth/crypto-utils.d.ts +43 -0
  65. package/dist/auth/crypto-utils.d.ts.map +1 -0
  66. package/dist/auth/crypto-utils.js +1 -0
  67. package/dist/auth/csrf.d.ts +32 -0
  68. package/dist/auth/csrf.d.ts.map +1 -0
  69. package/dist/auth/csrf.js +2 -0
  70. package/dist/auth/hooks/index.d.ts +4 -0
  71. package/dist/auth/hooks/index.d.ts.map +1 -0
  72. package/dist/auth/hooks/index.js +68 -0
  73. package/dist/auth/hooks/useCSRF.d.ts +7 -0
  74. package/dist/auth/hooks/useCSRF.d.ts.map +1 -0
  75. package/dist/auth/hooks/usePasswordStrength.d.ts +17 -0
  76. package/dist/auth/hooks/usePasswordStrength.d.ts.map +1 -0
  77. package/dist/auth/internal-api-key.d.ts +5 -0
  78. package/dist/auth/internal-api-key.d.ts.map +1 -0
  79. package/dist/auth/internal-api-key.js +30 -0
  80. package/dist/auth/link-utils.d.ts +13 -0
  81. package/dist/auth/link-utils.d.ts.map +1 -0
  82. package/dist/auth/link-utils.js +1 -0
  83. package/dist/auth/middleware.d.ts +56 -0
  84. package/dist/auth/middleware.d.ts.map +1 -0
  85. package/dist/auth/middleware.js +3 -0
  86. package/dist/auth/password.d.ts +28 -0
  87. package/dist/auth/password.d.ts.map +1 -0
  88. package/dist/auth/password.js +1 -0
  89. package/dist/auth/reset-token.d.ts +3 -0
  90. package/dist/auth/reset-token.d.ts.map +1 -0
  91. package/dist/auth/reset-token.js +9 -0
  92. package/dist/auth/responses.d.ts +15 -0
  93. package/dist/auth/responses.d.ts.map +1 -0
  94. package/dist/auth/responses.js +2 -0
  95. package/dist/auth/session.d.ts +79 -0
  96. package/dist/auth/session.d.ts.map +1 -0
  97. package/dist/auth/session.js +1 -0
  98. package/dist/auth/types.d.ts +18 -0
  99. package/dist/auth/types.d.ts.map +1 -0
  100. package/dist/auth/types.js +10 -0
  101. package/dist/auth/validation.d.ts +146 -0
  102. package/dist/auth/validation.d.ts.map +1 -0
  103. package/dist/auth/validation.js +116 -0
  104. package/dist/auth/validators.d.ts +4 -0
  105. package/dist/auth/validators.d.ts.map +1 -0
  106. package/dist/auth/validators.js +27 -0
  107. package/dist/auth/verification.d.ts +54 -0
  108. package/dist/auth/verification.d.ts.map +1 -0
  109. package/dist/auth/verification.js +39 -0
  110. package/dist/chunk-4LS3QDD5.js +162 -0
  111. package/dist/chunk-ABBUHT5Z.js +110 -0
  112. package/dist/chunk-CTYAVMOF.js +15 -0
  113. package/dist/chunk-GVLH2GQP.js +14 -0
  114. package/dist/chunk-HOSMMQMA.js +109 -0
  115. package/dist/chunk-MXQ66RUN.js +28 -0
  116. package/dist/chunk-PZE3JGXO.js +149 -0
  117. package/dist/chunk-QAH2Y5WK.js +93 -0
  118. package/dist/chunk-QWMN5UJC.js +76 -0
  119. package/dist/chunk-ROQV54MU.js +117 -0
  120. package/dist/chunk-U4NZQ366.js +46 -0
  121. package/dist/chunk-WBJOIENS.js +22 -0
  122. package/dist/chunk-WO6FHJHG.js +29 -0
  123. package/dist/chunk-Z5BEKPJI.js +96 -0
  124. package/dist/chunk-ZA46T6GX.js +24 -0
  125. package/dist/configure-mars.d.ts +104 -0
  126. package/dist/configure-mars.d.ts.map +1 -0
  127. package/dist/database/index.d.ts +8 -0
  128. package/dist/database/index.d.ts.map +1 -0
  129. package/dist/database/index.js +1 -0
  130. package/dist/email/index.d.ts +25 -0
  131. package/dist/email/index.d.ts.map +1 -0
  132. package/dist/email/index.js +2 -0
  133. package/dist/email/types.d.ts +18 -0
  134. package/dist/email/types.d.ts.map +1 -0
  135. package/dist/env/index.d.ts +36 -0
  136. package/dist/env/index.d.ts.map +1 -0
  137. package/dist/env/index.js +1 -0
  138. package/dist/index.d.ts +6 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +163 -0
  141. package/dist/logger/index.d.ts +80 -0
  142. package/dist/logger/index.d.ts.map +1 -0
  143. package/dist/logger/index.js +1 -0
  144. package/dist/payments/index.d.ts +53 -0
  145. package/dist/payments/index.d.ts.map +1 -0
  146. package/dist/payments/index.js +72 -0
  147. package/dist/plugin/builtin/email-plugins.d.ts +10 -0
  148. package/dist/plugin/builtin/email-plugins.d.ts.map +1 -0
  149. package/dist/plugin/builtin/index.d.ts +4 -0
  150. package/dist/plugin/builtin/index.d.ts.map +1 -0
  151. package/dist/plugin/builtin/index.js +324 -0
  152. package/dist/plugin/builtin/payment-plugins.d.ts +4 -0
  153. package/dist/plugin/builtin/payment-plugins.d.ts.map +1 -0
  154. package/dist/plugin/builtin/storage-plugins.d.ts +5 -0
  155. package/dist/plugin/builtin/storage-plugins.d.ts.map +1 -0
  156. package/dist/plugin/index.d.ts +21 -0
  157. package/dist/plugin/index.d.ts.map +1 -0
  158. package/dist/plugin/index.js +30 -0
  159. package/dist/rate-limit/index.d.ts +89 -0
  160. package/dist/rate-limit/index.d.ts.map +1 -0
  161. package/dist/rate-limit/index.js +166 -0
  162. package/dist/seo/faq.d.ts +37 -0
  163. package/dist/seo/faq.d.ts.map +1 -0
  164. package/dist/seo/index.d.ts +75 -0
  165. package/dist/seo/index.d.ts.map +1 -0
  166. package/dist/seo/index.js +1 -0
  167. package/dist/storage/index.d.ts +50 -0
  168. package/dist/storage/index.d.ts.map +1 -0
  169. package/dist/storage/index.js +211 -0
  170. package/dist/test-utils/factories.d.ts +38 -0
  171. package/dist/test-utils/factories.d.ts.map +1 -0
  172. package/dist/test-utils/index.d.ts +6 -0
  173. package/dist/test-utils/index.d.ts.map +1 -0
  174. package/dist/test-utils/index.js +117 -0
  175. package/dist/test-utils/mock-auth.d.ts +25 -0
  176. package/dist/test-utils/mock-auth.d.ts.map +1 -0
  177. package/dist/test-utils/mock-prisma.d.ts +55 -0
  178. package/dist/test-utils/mock-prisma.d.ts.map +1 -0
  179. package/dist/test-utils/render.d.ts +4 -0
  180. package/dist/test-utils/render.d.ts.map +1 -0
  181. package/dist/test-utils/request-helpers.d.ts +6 -0
  182. package/dist/test-utils/request-helpers.d.ts.map +1 -0
  183. package/dist/types.d.ts +53 -0
  184. package/dist/types.d.ts.map +1 -0
  185. package/dist/utils/math.d.ts +2 -0
  186. package/dist/utils/math.d.ts.map +1 -0
  187. package/dist/utils/math.js +7 -0
  188. package/dist/utils/optional-import.d.ts +14 -0
  189. package/dist/utils/optional-import.d.ts.map +1 -0
  190. package/package.json +205 -0
  191. package/scripts/generate-skill-adapters.ts +146 -0
  192. package/scripts/postinstall.mjs +146 -0
@@ -0,0 +1,120 @@
1
+ # Skill: Add an API Route
2
+
3
+ Create a new Next.js API route following MARS conventions for authentication, validation, error handling, and testing.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to create a new endpoint, API route, or backend handler.
8
+
9
+ ## Decision: Public or Protected?
10
+
11
+ | Type | Location | Auth Wrapper | Use Case |
12
+ |------|----------|-------------|----------|
13
+ | Public | `src/app/api/<name>/route.ts` | None (but add rate limiting) | Auth endpoints, webhooks, health checks |
14
+ | Protected | `src/app/api/protected/<name>/route.ts` | `withAuth` / `withRole` / `withOwnership` | User data, settings, admin |
15
+
16
+ ## Protected Route Template
17
+
18
+ ```typescript
19
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
20
+ import { NextResponse } from 'next/server';
21
+
22
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
23
+ try {
24
+ const userId = request.session.userId;
25
+ // ... business logic using userId for scoping
26
+ return NextResponse.json({ data: result });
27
+ } catch (error) {
28
+ return handleApiError(error, { endpoint: '/api/protected/<name>' });
29
+ }
30
+ });
31
+ ```
32
+
33
+ ### Auth Wrapper Reference
34
+
35
+ | Wrapper | Signature | When to Use |
36
+ |---------|-----------|-------------|
37
+ | `withAuth` | `(request, { params })` | Routes with dynamic URL params |
38
+ | `withAuthNoParams` | `(request)` | Routes without URL params |
39
+ | `withAuthSimple` | `()` | Routes that only need session verification |
40
+ | `withRole` | `withRole(['admin'], handler)` | Admin-only routes (verifies role from DB) |
41
+ | `withOwnership` | `withOwnership(getResourceUserId, handler)` | User must own the resource |
42
+
43
+ ## Public Route Template (with Rate Limiting)
44
+
45
+ ```typescript
46
+ import { handleApiError } from '@/lib/mars';
47
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
48
+ import { NextResponse } from 'next/server';
49
+
50
+ export async function POST(request: Request) {
51
+ const ip = getClientIP(request);
52
+ const rateLimit = await checkRateLimit(ip, RATE_LIMITS.default);
53
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
54
+
55
+ try {
56
+ // ... business logic
57
+ return NextResponse.json({ data: result });
58
+ } catch (error) {
59
+ return handleApiError(error, { endpoint: '/api/<name>' });
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## Input Validation
65
+
66
+ Always validate request bodies with Zod before use:
67
+
68
+ ```typescript
69
+ import { z } from 'zod';
70
+
71
+ const schema = z.object({
72
+ name: z.string().min(1).max(100),
73
+ email: z.string().email(),
74
+ });
75
+
76
+ // Inside the handler:
77
+ const body = schema.parse(await request.json());
78
+ ```
79
+
80
+ `handleApiError` will automatically catch `ZodError` and return a 400 with the first error message.
81
+
82
+ ## Dynamic Routes
83
+
84
+ For routes with URL parameters like `/api/protected/widgets/[id]/route.ts`:
85
+
86
+ ```typescript
87
+ export const GET = withAuth(async (request, context) => {
88
+ try {
89
+ const { id } = await context.params;
90
+ const userId = request.session.userId;
91
+ // ... fetch by id, scoped to userId
92
+ } catch (error) {
93
+ return handleApiError(error, { endpoint: '/api/protected/widgets/[id]' });
94
+ }
95
+ });
96
+ ```
97
+
98
+ ## Error Handling Reference
99
+
100
+ `handleApiError` from `@/lib/mars` handles:
101
+
102
+ | Error Type | HTTP Status | Client Response |
103
+ |-----------|-------------|----------------|
104
+ | `ZodError` | 400 | First validation error message |
105
+ | Prisma / Database | 503 | "Service temporarily unavailable" + detailed terminal diagnostics |
106
+ | All others | 500 | Custom `fallbackMessage` or "An unexpected error occurred" |
107
+
108
+ ## Testing
109
+
110
+ Create `route.test.ts` beside the route file. See the `add-feature` skill for the full testing pattern.
111
+
112
+ ## Checklist
113
+
114
+ - [ ] Correct directory (public vs protected)
115
+ - [ ] Appropriate auth wrapper applied
116
+ - [ ] Rate limiting on public endpoints
117
+ - [ ] Zod validation for all inputs
118
+ - [ ] Queries scoped by `request.session.userId`
119
+ - [ ] `handleApiError` in every catch block
120
+ - [ ] Test file created
@@ -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