@mars-stack/cli 0.2.0 → 1.0.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/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- 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 +375 -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 +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Skill: Add a User Role
|
|
2
|
+
|
|
3
|
+
Add a new role to the MARS role-based access control system.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add a new role (e.g., "moderator", "editor", "manager"), restrict access by role, or implement role-based permissions.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
MARS uses string-based roles (not enums) stored on the User model. This is intentional -- string roles are more flexible for migrations than Prisma enums.
|
|
12
|
+
|
|
13
|
+
## Step 1: Define the Role
|
|
14
|
+
|
|
15
|
+
Add the role to your application's type system. Create or update `src/features/admin/types.ts`:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
export const ROLES = ['user', 'admin', 'moderator', 'editor'] as const;
|
|
19
|
+
export type Role = (typeof ROLES)[number];
|
|
20
|
+
|
|
21
|
+
export const ROLE_HIERARCHY: Record<Role, number> = {
|
|
22
|
+
user: 0,
|
|
23
|
+
editor: 1,
|
|
24
|
+
moderator: 2,
|
|
25
|
+
admin: 3,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function hasMinimumRole(userRole: string, requiredRole: Role): boolean {
|
|
29
|
+
return (ROLE_HIERARCHY[userRole as Role] ?? 0) >= ROLE_HIERARCHY[requiredRole];
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Step 2: Protect API Routes
|
|
34
|
+
|
|
35
|
+
Use `withRole` for routes that require specific roles:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { withRole } from '@/lib/mars';
|
|
39
|
+
|
|
40
|
+
// Only admins and moderators
|
|
41
|
+
export const DELETE = withRole(['admin', 'moderator'], async (request, context) => {
|
|
42
|
+
// ...
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Only admins
|
|
46
|
+
export const PATCH = withRole(['admin'], async (request, context) => {
|
|
47
|
+
// ...
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Step 3: Protect Pages (UI Guard)
|
|
52
|
+
|
|
53
|
+
Check the role in client components via `useAuth`:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
'use client';
|
|
57
|
+
|
|
58
|
+
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
59
|
+
|
|
60
|
+
export function AdminPanel() {
|
|
61
|
+
const { user } = useAuth();
|
|
62
|
+
|
|
63
|
+
if (user?.role !== 'admin') {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return <div>{/* Admin content */}</div>;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For full page protection, use middleware (see `add-middleware` skill):
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const moderatorRoutes = ['/moderation'];
|
|
75
|
+
|
|
76
|
+
if (moderatorRoutes.some((r) => pathname.startsWith(r))) {
|
|
77
|
+
if (!isAuthenticated) { /* redirect to sign-in */ }
|
|
78
|
+
if (!['admin', 'moderator'].includes(session?.role || '')) {
|
|
79
|
+
return NextResponse.redirect(new URL('/dashboard', request.url));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Step 4: Admin UI for Role Management
|
|
85
|
+
|
|
86
|
+
Create an admin route to update user roles:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// src/app/api/protected/admin/users/[id]/role/route.ts
|
|
90
|
+
import { handleApiError, withRole, type AuthenticatedRequest } from '@/lib/mars';
|
|
91
|
+
import { prisma } from '@/lib/prisma';
|
|
92
|
+
import { ROLES } from '@/features/admin/types';
|
|
93
|
+
import { NextResponse } from 'next/server';
|
|
94
|
+
import { z } from 'zod';
|
|
95
|
+
|
|
96
|
+
const updateRoleSchema = z.object({
|
|
97
|
+
role: z.enum(ROLES),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const PATCH = withRole(['admin'], async (request: AuthenticatedRequest, context) => {
|
|
101
|
+
try {
|
|
102
|
+
const { id } = await context.params;
|
|
103
|
+
const { role } = updateRoleSchema.parse(await request.json());
|
|
104
|
+
|
|
105
|
+
// Prevent admins from demoting themselves
|
|
106
|
+
if (id === request.session.userId) {
|
|
107
|
+
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const user = await prisma.user.update({
|
|
111
|
+
where: { id },
|
|
112
|
+
data: { role },
|
|
113
|
+
select: { id: true, email: true, role: true },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return NextResponse.json(user);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return handleApiError(error, { endpoint: '/api/protected/admin/users/[id]/role' });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Step 5: Seed the Admin User
|
|
124
|
+
|
|
125
|
+
Update `prisma/seed.ts` to create users with the new role:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
await prisma.user.create({
|
|
129
|
+
data: {
|
|
130
|
+
email: 'moderator@example.com',
|
|
131
|
+
name: 'Moderator',
|
|
132
|
+
password: await hash('mod123456', 12),
|
|
133
|
+
role: 'moderator',
|
|
134
|
+
emailVerified: new Date(),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Security Notes
|
|
140
|
+
|
|
141
|
+
- **`withRole` always checks the database**, not the JWT claim. This means role changes take effect immediately.
|
|
142
|
+
- **The middleware role check is a UI guard only.** The real security boundary is `withRole` in the API layer.
|
|
143
|
+
- **Never trust client-reported roles.** The `user.role` from `useAuth()` is for UI display only.
|
|
144
|
+
- **Log role changes** for audit purposes.
|
|
145
|
+
|
|
146
|
+
## Checklist
|
|
147
|
+
|
|
148
|
+
- [ ] Role added to `ROLES` constant and `Role` type
|
|
149
|
+
- [ ] Role hierarchy updated (if applicable)
|
|
150
|
+
- [ ] API routes protected with `withRole`
|
|
151
|
+
- [ ] UI guards added for role-specific pages
|
|
152
|
+
- [ ] Middleware updated for new role-specific route groups
|
|
153
|
+
- [ ] Admin API route for role management
|
|
154
|
+
- [ ] Self-demotion prevented
|
|
155
|
+
- [ ] Seed data updated with new role users
|
|
156
|
+
- [ ] Role changes logged for auditing
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Skill: Add a Server Action
|
|
2
|
+
|
|
3
|
+
Create a Next.js Server Action for form submissions and mutations, following MARS conventions for auth, validation, and error handling.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add a form submission handler, a mutation that doesn't need a full API route, or wants to use React Server Actions.
|
|
8
|
+
|
|
9
|
+
## When to Use Server Actions vs API Routes
|
|
10
|
+
|
|
11
|
+
| Use Server Actions | Use API Routes |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Form submissions from pages | External API consumers |
|
|
14
|
+
| Simple mutations from client components | Webhooks |
|
|
15
|
+
| Progressive enhancement (works without JS) | Complex request/response patterns |
|
|
16
|
+
| Single-use handlers tied to a specific page | Shared endpoints used by multiple clients |
|
|
17
|
+
|
|
18
|
+
## Directory
|
|
19
|
+
|
|
20
|
+
Server actions live in the feature's server directory:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/features/<name>/server/actions.ts
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Template: Basic Server Action
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
'use server';
|
|
30
|
+
|
|
31
|
+
import { prisma } from '@/lib/prisma';
|
|
32
|
+
import { verifySessionForAPI } from '@/lib/mars';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
|
|
35
|
+
const updateProfileSchema = z.object({
|
|
36
|
+
name: z.string().min(1, 'Name is required').max(100),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
interface ActionResult {
|
|
40
|
+
success: boolean;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function updateProfile(formData: FormData): Promise<ActionResult> {
|
|
45
|
+
const session = await verifySessionForAPI();
|
|
46
|
+
if (!session) {
|
|
47
|
+
return { success: false, error: 'Authentication required' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const raw = {
|
|
51
|
+
name: formData.get('name'),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const parsed = updateProfileSchema.safeParse(raw);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
return { success: false, error: parsed.error.errors[0].message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await prisma.user.update({
|
|
61
|
+
where: { id: session.userId },
|
|
62
|
+
data: { name: parsed.data.name },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return { success: true };
|
|
66
|
+
} catch {
|
|
67
|
+
return { success: false, error: 'Failed to update profile. Please try again.' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Template: With Revalidation
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
'use server';
|
|
76
|
+
|
|
77
|
+
import { prisma } from '@/lib/prisma';
|
|
78
|
+
import { verifySessionForAPI } from '@/lib/mars';
|
|
79
|
+
import { revalidatePath } from 'next/cache';
|
|
80
|
+
import { z } from 'zod';
|
|
81
|
+
|
|
82
|
+
const createItemSchema = z.object({
|
|
83
|
+
title: z.string().min(1).max(200),
|
|
84
|
+
description: z.string().max(1000).optional(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export async function createItem(formData: FormData) {
|
|
88
|
+
const session = await verifySessionForAPI();
|
|
89
|
+
if (!session) {
|
|
90
|
+
return { success: false, error: 'Authentication required' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const parsed = createItemSchema.safeParse({
|
|
94
|
+
title: formData.get('title'),
|
|
95
|
+
description: formData.get('description'),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
return { success: false, error: parsed.error.errors[0].message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await prisma.item.create({
|
|
104
|
+
data: { ...parsed.data, userId: session.userId },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
revalidatePath('/dashboard');
|
|
108
|
+
return { success: true };
|
|
109
|
+
} catch {
|
|
110
|
+
return { success: false, error: 'Failed to create item' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Using in a Client Component
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
'use client';
|
|
119
|
+
|
|
120
|
+
import { Button, Input } from '@mars-stack/ui';
|
|
121
|
+
import { useActionState } from 'react';
|
|
122
|
+
import { updateProfile } from '@/features/settings/server/actions';
|
|
123
|
+
|
|
124
|
+
export function ProfileForm({ currentName }: { currentName: string }) {
|
|
125
|
+
const [state, formAction, isPending] = useActionState(updateProfile, {
|
|
126
|
+
success: false,
|
|
127
|
+
error: undefined,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<form action={formAction} className="space-y-4">
|
|
132
|
+
<Input
|
|
133
|
+
name="name"
|
|
134
|
+
label="Display Name"
|
|
135
|
+
defaultValue={currentName}
|
|
136
|
+
error={state.error}
|
|
137
|
+
/>
|
|
138
|
+
<Button type="submit" disabled={isPending}>
|
|
139
|
+
{isPending ? 'Saving...' : 'Save Changes'}
|
|
140
|
+
</Button>
|
|
141
|
+
{state.success && (
|
|
142
|
+
<p className="text-sm text-text-success">Profile updated successfully.</p>
|
|
143
|
+
)}
|
|
144
|
+
</form>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Rules
|
|
150
|
+
|
|
151
|
+
- **Always verify the session** at the top of every server action. Server actions are callable from the client.
|
|
152
|
+
- **Always validate input** with Zod. Never trust `formData` values.
|
|
153
|
+
- **Return a result object** (`{ success, error }`) instead of throwing -- this gives the client a clean way to handle errors.
|
|
154
|
+
- **Scope database queries by `session.userId`** -- never use a userId from formData for authorization.
|
|
155
|
+
- **Use `revalidatePath` or `revalidateTag`** to refresh cached data after mutations.
|
|
156
|
+
- **Mark the file with `'use server'`** at the top (not inline on individual functions).
|
|
157
|
+
|
|
158
|
+
## Checklist
|
|
159
|
+
|
|
160
|
+
- [ ] File marked with `'use server'` at top
|
|
161
|
+
- [ ] Session verified before any database access
|
|
162
|
+
- [ ] Input validated with Zod `.safeParse()`
|
|
163
|
+
- [ ] Database queries scoped by `session.userId`
|
|
164
|
+
- [ ] Returns `{ success, error }` result object
|
|
165
|
+
- [ ] `revalidatePath` called after mutations
|
|
166
|
+
- [ ] Client component uses `useActionState` for form binding
|
|
167
|
+
- [ ] No sensitive data in the return value
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Skill: Add a Webhook Handler
|
|
2
|
+
|
|
3
|
+
Create an incoming webhook endpoint that securely receives and processes external service callbacks (e.g., Stripe, SendGrid, GitHub).
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user needs to handle webhooks from a third-party service, payment processor, or external system.
|
|
8
|
+
|
|
9
|
+
## Key Principles
|
|
10
|
+
|
|
11
|
+
1. **Verify signatures** -- always validate the webhook signature before processing.
|
|
12
|
+
2. **Idempotent** -- handle duplicate deliveries gracefully (use event IDs).
|
|
13
|
+
3. **Fast response** -- return 200 quickly; do heavy processing after responding or in a background job.
|
|
14
|
+
4. **No auth wrappers** -- webhooks are public endpoints authenticated by signature, not session.
|
|
15
|
+
|
|
16
|
+
## Directory
|
|
17
|
+
|
|
18
|
+
Webhooks live at `src/app/api/webhooks/<service>/route.ts` (outside `/protected/`).
|
|
19
|
+
|
|
20
|
+
## Template: Stripe Webhook
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { handleApiError, apiLogger } from '@/lib/mars';
|
|
24
|
+
import { prisma } from '@/lib/prisma';
|
|
25
|
+
import { headers } from 'next/headers';
|
|
26
|
+
import { NextResponse } from 'next/server';
|
|
27
|
+
import Stripe from 'stripe';
|
|
28
|
+
|
|
29
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
30
|
+
apiVersion: '2024-12-18.acacia',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
|
34
|
+
|
|
35
|
+
export async function POST(request: Request) {
|
|
36
|
+
try {
|
|
37
|
+
const body = await request.text();
|
|
38
|
+
const headersList = await headers();
|
|
39
|
+
const signature = headersList.get('stripe-signature');
|
|
40
|
+
|
|
41
|
+
if (!signature) {
|
|
42
|
+
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let event: Stripe.Event;
|
|
46
|
+
try {
|
|
47
|
+
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
|
48
|
+
} catch {
|
|
49
|
+
apiLogger.warn('Webhook signature verification failed');
|
|
50
|
+
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
apiLogger.info({ eventType: event.type, eventId: event.id }, 'Webhook received');
|
|
54
|
+
|
|
55
|
+
switch (event.type) {
|
|
56
|
+
case 'checkout.session.completed':
|
|
57
|
+
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
|
58
|
+
break;
|
|
59
|
+
case 'customer.subscription.updated':
|
|
60
|
+
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
61
|
+
break;
|
|
62
|
+
case 'customer.subscription.deleted':
|
|
63
|
+
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
apiLogger.info({ eventType: event.type }, 'Unhandled webhook event type');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return NextResponse.json({ received: true });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return handleApiError(error, { endpoint: '/api/webhooks/stripe' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|
76
|
+
// Idempotency: check if already processed
|
|
77
|
+
// Update database with payment/subscription info
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
|
81
|
+
// Update subscription status in database
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
|
85
|
+
// Mark subscription as cancelled
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Template: Generic Webhook (HMAC Signature)
|
|
90
|
+
|
|
91
|
+
For services that use HMAC-SHA256 signatures:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { handleApiError, apiLogger } from '@/lib/mars';
|
|
95
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
96
|
+
import { NextResponse } from 'next/server';
|
|
97
|
+
|
|
98
|
+
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET_<SERVICE>!;
|
|
99
|
+
|
|
100
|
+
function verifySignature(payload: string, signature: string): boolean {
|
|
101
|
+
const expected = createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex');
|
|
102
|
+
try {
|
|
103
|
+
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function POST(request: Request) {
|
|
110
|
+
try {
|
|
111
|
+
const body = await request.text();
|
|
112
|
+
const signature = request.headers.get('x-webhook-signature');
|
|
113
|
+
|
|
114
|
+
if (!signature || !verifySignature(body, signature)) {
|
|
115
|
+
apiLogger.warn('Webhook signature verification failed');
|
|
116
|
+
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const payload = JSON.parse(body);
|
|
120
|
+
|
|
121
|
+
apiLogger.info({ eventType: payload.type }, 'Webhook received');
|
|
122
|
+
|
|
123
|
+
// Process the event...
|
|
124
|
+
|
|
125
|
+
return NextResponse.json({ received: true });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return handleApiError(error, { endpoint: '/api/webhooks/<service>' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Security Rules
|
|
133
|
+
|
|
134
|
+
- **Always use `timingSafeEqual`** for signature comparison (never `===`).
|
|
135
|
+
- **Read the raw body** with `request.text()`, not `request.json()`, before verifying the signature.
|
|
136
|
+
- **Never trust the payload** until the signature is verified.
|
|
137
|
+
- **Log the event type and ID** for debugging, never log the full payload (may contain PII).
|
|
138
|
+
- **Add the webhook URL to CSRF exclusions** in middleware (webhooks don't carry CSRF tokens).
|
|
139
|
+
|
|
140
|
+
## Middleware Update
|
|
141
|
+
|
|
142
|
+
Exclude the webhook route from CSRF validation in `src/middleware.ts`:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// In the middleware, webhook routes bypass CSRF
|
|
146
|
+
if (pathname.startsWith('/api/webhooks/')) {
|
|
147
|
+
return NextResponse.next();
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Idempotency Pattern
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
async function processEvent(eventId: string, handler: () => Promise<void>) {
|
|
155
|
+
const existing = await prisma.webhookEvent.findUnique({
|
|
156
|
+
where: { externalId: eventId },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (existing) {
|
|
160
|
+
apiLogger.info({ eventId }, 'Duplicate webhook event, skipping');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await prisma.$transaction(async (tx) => {
|
|
165
|
+
await tx.webhookEvent.create({
|
|
166
|
+
data: { externalId: eventId, processedAt: new Date() },
|
|
167
|
+
});
|
|
168
|
+
await handler();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Environment Variables
|
|
174
|
+
|
|
175
|
+
Add webhook secrets to `src/core/env/index.ts` in `buildEnvSchema()`:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
base.STRIPE_WEBHOOK_SECRET = z.string().min(1).optional();
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Checklist
|
|
182
|
+
|
|
183
|
+
- [ ] Route at `src/app/api/webhooks/<service>/route.ts`
|
|
184
|
+
- [ ] Signature verification before processing
|
|
185
|
+
- [ ] Constant-time comparison (`timingSafeEqual`) for signatures
|
|
186
|
+
- [ ] Raw body read (`request.text()`) before signature check
|
|
187
|
+
- [ ] Event type switch with specific handlers
|
|
188
|
+
- [ ] Idempotency check (prevent duplicate processing)
|
|
189
|
+
- [ ] Logger used (not `console.log`)
|
|
190
|
+
- [ ] Excluded from CSRF in middleware
|
|
191
|
+
- [ ] Webhook secret added to env schema
|
|
192
|
+
- [ ] `handleApiError` in catch block
|