@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.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. 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