@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.
- package/README.md +32 -0
- package/cursor/manifest.json +304 -0
- package/cursor/rules/mars-composition-patterns.mdc +186 -0
- package/cursor/rules/mars-data-access.mdc +26 -0
- package/cursor/rules/mars-project-structure.mdc +34 -0
- package/cursor/rules/mars-security.mdc +25 -0
- package/cursor/rules/mars-testing.mdc +24 -0
- package/cursor/rules/mars-ui-conventions.mdc +29 -0
- package/cursor/skills/mars-add-api-route/SKILL.md +120 -0
- package/cursor/skills/mars-add-audit-log/SKILL.md +373 -0
- package/cursor/skills/mars-add-blog/SKILL.md +447 -0
- package/cursor/skills/mars-add-command-palette/SKILL.md +438 -0
- package/cursor/skills/mars-add-component/SKILL.md +158 -0
- package/cursor/skills/mars-add-crud-routes/SKILL.md +221 -0
- package/cursor/skills/mars-add-e2e-test/SKILL.md +227 -0
- package/cursor/skills/mars-add-error-boundary/SKILL.md +472 -0
- package/cursor/skills/mars-add-feature/SKILL.md +174 -0
- package/cursor/skills/mars-add-middleware/SKILL.md +135 -0
- package/cursor/skills/mars-add-page/SKILL.md +153 -0
- package/cursor/skills/mars-add-prisma-model/SKILL.md +148 -0
- package/cursor/skills/mars-add-protected-resource/SKILL.md +192 -0
- package/cursor/skills/mars-add-role/SKILL.md +156 -0
- package/cursor/skills/mars-add-server-action/SKILL.md +167 -0
- package/cursor/skills/mars-add-webhook/SKILL.md +192 -0
- package/cursor/skills/mars-build-complete-feature/SKILL.md +228 -0
- package/cursor/skills/mars-build-dashboard/SKILL.md +211 -0
- package/cursor/skills/mars-build-data-table/SKILL.md +284 -0
- package/cursor/skills/mars-build-form/SKILL.md +229 -0
- package/cursor/skills/mars-build-landing-page/SKILL.md +248 -0
- package/cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/cursor/skills/mars-configure-ai/SKILL.md +617 -0
- package/cursor/skills/mars-configure-analytics/SKILL.md +413 -0
- package/cursor/skills/mars-configure-dark-mode/SKILL.md +309 -0
- package/cursor/skills/mars-configure-email/SKILL.md +170 -0
- package/cursor/skills/mars-configure-email-verification/SKILL.md +333 -0
- package/cursor/skills/mars-configure-feature-flags/SKILL.md +361 -0
- package/cursor/skills/mars-configure-i18n/SKILL.md +518 -0
- package/cursor/skills/mars-configure-jobs/SKILL.md +500 -0
- package/cursor/skills/mars-configure-magic-links/SKILL.md +385 -0
- package/cursor/skills/mars-configure-multi-tenancy/SKILL.md +611 -0
- package/cursor/skills/mars-configure-notifications/SKILL.md +569 -0
- package/cursor/skills/mars-configure-oauth/SKILL.md +217 -0
- package/cursor/skills/mars-configure-onboarding/SKILL.md +483 -0
- package/cursor/skills/mars-configure-payments/SKILL.md +243 -0
- package/cursor/skills/mars-configure-realtime/SKILL.md +733 -0
- package/cursor/skills/mars-configure-search/SKILL.md +581 -0
- package/cursor/skills/mars-configure-storage/SKILL.md +273 -0
- package/cursor/skills/mars-configure-two-factor/SKILL.md +518 -0
- package/cursor/skills/mars-create-execution-plan/SKILL.md +204 -0
- package/cursor/skills/mars-create-seed/SKILL.md +191 -0
- package/cursor/skills/mars-deploy-to-vercel/SKILL.md +300 -0
- package/cursor/skills/mars-design-tokens/SKILL.md +138 -0
- package/cursor/skills/mars-setup-billing/SKILL.md +322 -0
- package/cursor/skills/mars-setup-project/SKILL.md +104 -0
- package/cursor/skills/mars-setup-teams/SKILL.md +688 -0
- package/cursor/skills/mars-test-api-route/SKILL.md +219 -0
- package/cursor/skills/mars-update-architecture-docs/SKILL.md +189 -0
- package/dist/api-error/index.d.ts +27 -0
- package/dist/api-error/index.d.ts.map +1 -0
- package/dist/api-error/index.js +2 -0
- package/dist/auth/credential-tag.d.ts +5 -0
- package/dist/auth/credential-tag.d.ts.map +1 -0
- package/dist/auth/credential-tag.js +2 -0
- package/dist/auth/crypto-utils.d.ts +43 -0
- package/dist/auth/crypto-utils.d.ts.map +1 -0
- package/dist/auth/crypto-utils.js +1 -0
- package/dist/auth/csrf.d.ts +32 -0
- package/dist/auth/csrf.d.ts.map +1 -0
- package/dist/auth/csrf.js +2 -0
- package/dist/auth/hooks/index.d.ts +4 -0
- package/dist/auth/hooks/index.d.ts.map +1 -0
- package/dist/auth/hooks/index.js +68 -0
- package/dist/auth/hooks/useCSRF.d.ts +7 -0
- package/dist/auth/hooks/useCSRF.d.ts.map +1 -0
- package/dist/auth/hooks/usePasswordStrength.d.ts +17 -0
- package/dist/auth/hooks/usePasswordStrength.d.ts.map +1 -0
- package/dist/auth/internal-api-key.d.ts +5 -0
- package/dist/auth/internal-api-key.d.ts.map +1 -0
- package/dist/auth/internal-api-key.js +30 -0
- package/dist/auth/link-utils.d.ts +13 -0
- package/dist/auth/link-utils.d.ts.map +1 -0
- package/dist/auth/link-utils.js +1 -0
- package/dist/auth/middleware.d.ts +56 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +3 -0
- package/dist/auth/password.d.ts +28 -0
- package/dist/auth/password.d.ts.map +1 -0
- package/dist/auth/password.js +1 -0
- package/dist/auth/reset-token.d.ts +3 -0
- package/dist/auth/reset-token.d.ts.map +1 -0
- package/dist/auth/reset-token.js +9 -0
- package/dist/auth/responses.d.ts +15 -0
- package/dist/auth/responses.d.ts.map +1 -0
- package/dist/auth/responses.js +2 -0
- package/dist/auth/session.d.ts +79 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +1 -0
- package/dist/auth/types.d.ts +18 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +10 -0
- package/dist/auth/validation.d.ts +146 -0
- package/dist/auth/validation.d.ts.map +1 -0
- package/dist/auth/validation.js +116 -0
- package/dist/auth/validators.d.ts +4 -0
- package/dist/auth/validators.d.ts.map +1 -0
- package/dist/auth/validators.js +27 -0
- package/dist/auth/verification.d.ts +54 -0
- package/dist/auth/verification.d.ts.map +1 -0
- package/dist/auth/verification.js +39 -0
- package/dist/chunk-4LS3QDD5.js +162 -0
- package/dist/chunk-ABBUHT5Z.js +110 -0
- package/dist/chunk-CTYAVMOF.js +15 -0
- package/dist/chunk-GVLH2GQP.js +14 -0
- package/dist/chunk-HOSMMQMA.js +109 -0
- package/dist/chunk-MXQ66RUN.js +28 -0
- package/dist/chunk-PZE3JGXO.js +149 -0
- package/dist/chunk-QAH2Y5WK.js +93 -0
- package/dist/chunk-QWMN5UJC.js +76 -0
- package/dist/chunk-ROQV54MU.js +117 -0
- package/dist/chunk-U4NZQ366.js +46 -0
- package/dist/chunk-WBJOIENS.js +22 -0
- package/dist/chunk-WO6FHJHG.js +29 -0
- package/dist/chunk-Z5BEKPJI.js +96 -0
- package/dist/chunk-ZA46T6GX.js +24 -0
- package/dist/configure-mars.d.ts +104 -0
- package/dist/configure-mars.d.ts.map +1 -0
- package/dist/database/index.d.ts +8 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +1 -0
- package/dist/email/index.d.ts +25 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +2 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/email/types.d.ts.map +1 -0
- package/dist/env/index.d.ts +36 -0
- package/dist/env/index.d.ts.map +1 -0
- package/dist/env/index.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +163 -0
- package/dist/logger/index.d.ts +80 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +1 -0
- package/dist/payments/index.d.ts +53 -0
- package/dist/payments/index.d.ts.map +1 -0
- package/dist/payments/index.js +72 -0
- package/dist/plugin/builtin/email-plugins.d.ts +10 -0
- package/dist/plugin/builtin/email-plugins.d.ts.map +1 -0
- package/dist/plugin/builtin/index.d.ts +4 -0
- package/dist/plugin/builtin/index.d.ts.map +1 -0
- package/dist/plugin/builtin/index.js +324 -0
- package/dist/plugin/builtin/payment-plugins.d.ts +4 -0
- package/dist/plugin/builtin/payment-plugins.d.ts.map +1 -0
- package/dist/plugin/builtin/storage-plugins.d.ts +5 -0
- package/dist/plugin/builtin/storage-plugins.d.ts.map +1 -0
- package/dist/plugin/index.d.ts +21 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +30 -0
- package/dist/rate-limit/index.d.ts +89 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +166 -0
- package/dist/seo/faq.d.ts +37 -0
- package/dist/seo/faq.d.ts.map +1 -0
- package/dist/seo/index.d.ts +75 -0
- package/dist/seo/index.d.ts.map +1 -0
- package/dist/seo/index.js +1 -0
- package/dist/storage/index.d.ts +50 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +211 -0
- package/dist/test-utils/factories.d.ts +38 -0
- package/dist/test-utils/factories.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +6 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/index.js +117 -0
- package/dist/test-utils/mock-auth.d.ts +25 -0
- package/dist/test-utils/mock-auth.d.ts.map +1 -0
- package/dist/test-utils/mock-prisma.d.ts +55 -0
- package/dist/test-utils/mock-prisma.d.ts.map +1 -0
- package/dist/test-utils/render.d.ts +4 -0
- package/dist/test-utils/render.d.ts.map +1 -0
- package/dist/test-utils/request-helpers.d.ts +6 -0
- package/dist/test-utils/request-helpers.d.ts.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/math.d.ts +2 -0
- package/dist/utils/math.d.ts.map +1 -0
- package/dist/utils/math.js +7 -0
- package/dist/utils/optional-import.d.ts +14 -0
- package/dist/utils/optional-import.d.ts.map +1 -0
- package/package.json +205 -0
- package/scripts/generate-skill-adapters.ts +146 -0
- 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
|