@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,333 @@
1
+ # Skill: Configure Email Verification
2
+
3
+ Set up and customize the email verification flow in a MARS application, including resend logic, expiry, custom pages, and rate limiting.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add email verification, customize the verification flow, handle resend logic, change verification token expiry, build a custom verification page, or handle already-verified users.
8
+
9
+ ## Prerequisites
10
+
11
+ - Email provider configured (see `configure-email` skill)
12
+ - `appConfig.features.emailVerification` set to `true` in `src/config/app.config.ts`
13
+ - Prisma schema includes a `User` model with `emailVerified` and `emailVerifyToken` fields
14
+
15
+ ## Architecture
16
+
17
+ MARS email verification uses a token-based flow:
18
+ 1. User signs up → server generates a random token, stores it hashed in DB, sends raw token via email
19
+ 2. User clicks link → server hashes the incoming token, compares against DB, marks user verified
20
+ 3. Token has a configurable expiry; resend requests are rate-limited
21
+
22
+ The verification service lives in `src/features/auth/server/verification.ts` and all token comparisons use constant-time equality.
23
+
24
+ ## Step 1: Prisma Schema Fields
25
+
26
+ Ensure the `User` model in `prisma/schema/auth.prisma` includes:
27
+
28
+ ```prisma
29
+ model User {
30
+ id String @id @default(cuid())
31
+ email String @unique
32
+ emailVerified Boolean @default(false)
33
+ emailVerifyToken String?
34
+ emailVerifyExpires DateTime?
35
+ // ... other fields
36
+ }
37
+ ```
38
+
39
+ Run `yarn db:push` after changes.
40
+
41
+ ## Step 2: Verification Configuration
42
+
43
+ Add verification settings to `src/config/app.config.ts`:
44
+
45
+ ```typescript
46
+ features: {
47
+ emailVerification: true,
48
+ },
49
+ auth: {
50
+ verification: {
51
+ tokenExpiryHours: 24,
52
+ resendCooldownMinutes: 2,
53
+ maxResendAttempts: 5,
54
+ },
55
+ },
56
+ ```
57
+
58
+ ## Step 3: Token Generation and Verification Service
59
+
60
+ ```typescript
61
+ // src/features/auth/server/verification.ts
62
+ import 'server-only';
63
+
64
+ import { randomBytes, createHash } from 'crypto';
65
+ import { prisma } from '@/lib/prisma';
66
+ import { sendEmail } from '@/lib/mars';
67
+ import { appConfig } from '@/config/app.config';
68
+ import { constantTimeEqual } from '@/lib/mars';
69
+
70
+ function hashToken(token: string): string {
71
+ return createHash('sha256').update(token).digest('hex');
72
+ }
73
+
74
+ export async function generateVerificationToken(userId: string): Promise<string> {
75
+ const rawToken = randomBytes(32).toString('hex');
76
+ const hashedToken = hashToken(rawToken);
77
+ const expiryHours = appConfig.auth.verification.tokenExpiryHours;
78
+
79
+ await prisma.user.update({
80
+ where: { id: userId },
81
+ data: {
82
+ emailVerifyToken: hashedToken,
83
+ emailVerifyExpires: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
84
+ },
85
+ });
86
+
87
+ return rawToken;
88
+ }
89
+
90
+ export async function verifyEmail(token: string): Promise<{ success: boolean; error?: string }> {
91
+ const hashedToken = hashToken(token);
92
+
93
+ const user = await prisma.user.findFirst({
94
+ where: { emailVerifyToken: hashedToken },
95
+ });
96
+
97
+ if (!user) {
98
+ return { success: false, error: 'Invalid verification token' };
99
+ }
100
+
101
+ if (user.emailVerified) {
102
+ return { success: false, error: 'Email already verified' };
103
+ }
104
+
105
+ if (user.emailVerifyExpires && user.emailVerifyExpires < new Date()) {
106
+ return { success: false, error: 'Verification token has expired' };
107
+ }
108
+
109
+ await prisma.user.update({
110
+ where: { id: user.id },
111
+ data: {
112
+ emailVerified: true,
113
+ emailVerifyToken: null,
114
+ emailVerifyExpires: null,
115
+ },
116
+ });
117
+
118
+ return { success: true };
119
+ }
120
+
121
+ export async function sendVerificationEmail(userId: string, email: string): Promise<void> {
122
+ const token = await generateVerificationToken(userId);
123
+ const baseUrl = process.env.APP_URL || 'http://localhost:3000';
124
+ const verifyUrl = `${baseUrl}/auth/verify-email?token=${token}`;
125
+
126
+ await sendEmail({
127
+ to: email,
128
+ subject: `Verify your email for ${appConfig.name}`,
129
+ text: `Verify your email by visiting: ${verifyUrl}\n\nThis link expires in ${appConfig.auth.verification.tokenExpiryHours} hours.`,
130
+ html: `
131
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
132
+ <h2>Verify your email</h2>
133
+ <p>Click the button below to verify your email address:</p>
134
+ <div style="text-align: center; margin: 30px 0;">
135
+ <a href="${verifyUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
136
+ Verify Email
137
+ </a>
138
+ </div>
139
+ <p style="color: #6b7280; font-size: 14px;">This link expires in ${appConfig.auth.verification.tokenExpiryHours} hours.</p>
140
+ </div>
141
+ `,
142
+ });
143
+ }
144
+ ```
145
+
146
+ ## Step 4: Verification API Route
147
+
148
+ ```typescript
149
+ // src/app/api/auth/verify-email/route.ts
150
+ import { handleApiError } from '@/lib/mars';
151
+ import { verifyEmail } from '@/features/auth/server/verification';
152
+ import { NextResponse, type NextRequest } from 'next/server';
153
+ import { z } from 'zod';
154
+
155
+ const verifySchema = z.object({
156
+ token: z.string().min(1, 'Token is required'),
157
+ });
158
+
159
+ export async function GET(request: NextRequest) {
160
+ try {
161
+ const { searchParams } = new URL(request.url);
162
+ const { token } = verifySchema.parse({ token: searchParams.get('token') });
163
+
164
+ const result = await verifyEmail(token);
165
+
166
+ if (!result.success) {
167
+ return NextResponse.redirect(
168
+ new URL(`/auth/verify-email?error=${encodeURIComponent(result.error!)}`, request.url),
169
+ );
170
+ }
171
+
172
+ return NextResponse.redirect(new URL('/auth/verify-email?success=true', request.url));
173
+ } catch (error) {
174
+ return handleApiError(error, { endpoint: '/api/auth/verify-email' });
175
+ }
176
+ }
177
+ ```
178
+
179
+ ## Step 5: Resend Verification Endpoint
180
+
181
+ ```typescript
182
+ // src/app/api/auth/resend-verification/route.ts
183
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
184
+ import { checkRateLimit, RATE_LIMITS } from '@/lib/core/rate-limit';
185
+ import { sendVerificationEmail } from '@/features/auth/server/verification';
186
+ import { prisma } from '@/lib/prisma';
187
+ import { NextResponse } from 'next/server';
188
+
189
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
190
+ try {
191
+ const userId = request.session.userId;
192
+
193
+ await checkRateLimit(`resend-verify:${userId}`, {
194
+ maxAttempts: 5,
195
+ windowMs: 10 * 60 * 1000, // 10 minutes
196
+ });
197
+
198
+ const user = await prisma.user.findUnique({ where: { id: userId } });
199
+
200
+ if (!user) {
201
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
202
+ }
203
+
204
+ if (user.emailVerified) {
205
+ return NextResponse.json({ error: 'Email already verified' }, { status: 400 });
206
+ }
207
+
208
+ await sendVerificationEmail(userId, user.email);
209
+
210
+ return NextResponse.json({ message: 'Verification email sent' });
211
+ } catch (error) {
212
+ return handleApiError(error, { endpoint: '/api/auth/resend-verification' });
213
+ }
214
+ });
215
+ ```
216
+
217
+ ## Step 6: Verification Page
218
+
219
+ ```typescript
220
+ // src/app/auth/verify-email/page.tsx
221
+ 'use client';
222
+
223
+ import { useSearchParams } from 'next/navigation';
224
+ import { useState } from 'react';
225
+
226
+ export default function VerifyEmailPage() {
227
+ const searchParams = useSearchParams();
228
+ const success = searchParams.get('success') === 'true';
229
+ const error = searchParams.get('error');
230
+ const [resending, setResending] = useState(false);
231
+ const [resendMessage, setResendMessage] = useState('');
232
+
233
+ async function handleResend() {
234
+ setResending(true);
235
+ try {
236
+ const res = await fetch('/api/auth/resend-verification', { method: 'POST' });
237
+ const data = await res.json();
238
+
239
+ if (!res.ok) {
240
+ setResendMessage(data.error || 'Failed to resend');
241
+ } else {
242
+ setResendMessage('Verification email sent! Check your inbox.');
243
+ }
244
+ } catch {
245
+ setResendMessage('Something went wrong. Try again later.');
246
+ } finally {
247
+ setResending(false);
248
+ }
249
+ }
250
+
251
+ if (success) {
252
+ return (
253
+ <div className="flex min-h-screen items-center justify-center">
254
+ <div className="text-center">
255
+ <h1 className="text-2xl font-bold text-content-primary">Email Verified!</h1>
256
+ <p className="mt-2 text-content-secondary">Your email has been verified. You can now access all features.</p>
257
+ <a href="/dashboard" className="mt-4 inline-block text-interactive-primary hover:underline">
258
+ Go to Dashboard
259
+ </a>
260
+ </div>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ return (
266
+ <div className="flex min-h-screen items-center justify-center">
267
+ <div className="text-center">
268
+ <h1 className="text-2xl font-bold text-content-primary">Verify Your Email</h1>
269
+ {error && <p className="mt-2 text-status-error">{decodeURIComponent(error)}</p>}
270
+ <p className="mt-2 text-content-secondary">
271
+ Check your inbox for a verification link. Didn&apos;t receive it?
272
+ </p>
273
+ <button
274
+ onClick={handleResend}
275
+ disabled={resending}
276
+ className="mt-4 rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
277
+ >
278
+ {resending ? 'Sending...' : 'Resend Verification Email'}
279
+ </button>
280
+ {resendMessage && <p className="mt-2 text-sm text-content-secondary">{resendMessage}</p>}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+ ```
286
+
287
+ ## Step 7: Gate Features Behind Verification
288
+
289
+ Create a helper to check verification status:
290
+
291
+ ```typescript
292
+ // src/features/auth/server/index.ts
293
+ export async function requireVerifiedEmail(userId: string): Promise<void> {
294
+ const user = await prisma.user.findUnique({
295
+ where: { id: userId },
296
+ select: { emailVerified: true },
297
+ });
298
+
299
+ if (!user?.emailVerified) {
300
+ throw new ApiError(403, 'Email verification required');
301
+ }
302
+ }
303
+ ```
304
+
305
+ Use in protected routes:
306
+
307
+ ```typescript
308
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
309
+ await requireVerifiedEmail(request.session.userId);
310
+ // ... proceed with logic
311
+ });
312
+ ```
313
+
314
+ ## Testing
315
+
316
+ 1. Sign up with a new account using `console` email provider — check terminal for the verification link.
317
+ 2. Click the link — verify the user is marked as verified in the database.
318
+ 3. Try clicking again — should show "already verified" message.
319
+ 4. Wait for expiry (or manually set `emailVerifyExpires` to the past) — should show "expired" error.
320
+ 5. Test resend rate limiting — send more than 5 requests in 10 minutes, verify the 6th is rejected.
321
+
322
+ ## Checklist
323
+
324
+ - [ ] Prisma schema updated with `emailVerifyToken` and `emailVerifyExpires` fields
325
+ - [ ] Verification config added to `app.config.ts`
326
+ - [ ] Token generation uses `randomBytes` and stores hashed token
327
+ - [ ] Verification route validates and clears token on success
328
+ - [ ] Resend endpoint is rate-limited and checks if already verified
329
+ - [ ] Verification page handles success, error, and resend states
330
+ - [ ] Auth routes call `sendVerificationEmail` on signup
331
+ - [ ] Feature gate helper `requireVerifiedEmail` available
332
+ - [ ] Tokens use constant-time comparison
333
+ - [ ] `db:push` run after schema changes
@@ -0,0 +1,361 @@
1
+ # Skill: Configure Feature Flags
2
+
3
+ Set up a runtime feature flag system with environment-based overrides, server-side checks, client-side context, and gradual rollout support.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add feature flags, feature toggles, gradual rollout, A/B testing, canary releases, or runtime feature gating.
8
+
9
+ ## Prerequisites
10
+
11
+ - Read `src/config/app.config.ts` to understand the current feature flag structure.
12
+ - Read `src/lib/mars.ts` to check available core exports.
13
+
14
+ ## How Feature Flags Work in Mars
15
+
16
+ Mars uses a layered feature flag system:
17
+
18
+ 1. **Build-time flags** — `appConfig.features.*` in `app.config.ts` (static, checked at scaffold time)
19
+ 2. **Runtime flags** — Environment variable overrides that can change without redeployment
20
+ 3. **User-level flags** — Database-driven flags for per-user or percentage-based rollouts
21
+
22
+ ## Step 1: Define Flag Schema
23
+
24
+ Add new flags to the app config type and defaults:
25
+
26
+ ```typescript
27
+ // src/config/app.config.ts — extend the features block
28
+ export const appConfig = {
29
+ features: {
30
+ // ... existing flags
31
+ newDashboard: false,
32
+ betaSearch: false,
33
+ aiAssistant: false,
34
+ },
35
+ // ...
36
+ } as const;
37
+ ```
38
+
39
+ ## Step 2: Create the Feature Flags Service
40
+
41
+ ```typescript
42
+ // src/features/feature-flags/server/index.ts
43
+ import 'server-only';
44
+
45
+ import { appConfig } from '@/config/app.config';
46
+ import { prisma } from '@/lib/prisma';
47
+
48
+ type FeatureFlag = keyof typeof appConfig.features;
49
+
50
+ /**
51
+ * Check if a feature is enabled. Resolution order:
52
+ * 1. Environment variable override: FEATURE_FLAG_{SCREAMING_SNAKE} = "true" | "false"
53
+ * 2. User-level flag from database (if userId provided)
54
+ * 3. Percentage rollout from database (if userId provided)
55
+ * 4. Static default from appConfig.features
56
+ */
57
+ export async function isFeatureEnabled(
58
+ flag: FeatureFlag,
59
+ userId?: string,
60
+ ): Promise<boolean> {
61
+ const envKey = `FEATURE_FLAG_${toScreamingSnake(flag)}`;
62
+ const envValue = process.env[envKey];
63
+ if (envValue === 'true') return true;
64
+ if (envValue === 'false') return false;
65
+
66
+ if (userId) {
67
+ const userFlag = await prisma.featureFlag.findUnique({
68
+ where: { flag_userId: { flag, userId } },
69
+ });
70
+ if (userFlag) return userFlag.enabled;
71
+
72
+ const rollout = await prisma.featureFlagRollout.findUnique({
73
+ where: { flag },
74
+ });
75
+ if (rollout) {
76
+ return isInRolloutPercentage(userId, rollout.percentage);
77
+ }
78
+ }
79
+
80
+ return appConfig.features[flag] ?? false;
81
+ }
82
+
83
+ export async function getEnabledFlags(userId?: string): Promise<Record<string, boolean>> {
84
+ const flags = Object.keys(appConfig.features) as FeatureFlag[];
85
+ const results: Record<string, boolean> = {};
86
+
87
+ for (const flag of flags) {
88
+ results[flag] = await isFeatureEnabled(flag, userId);
89
+ }
90
+
91
+ return results;
92
+ }
93
+
94
+ function toScreamingSnake(str: string): string {
95
+ return str.replace(/([A-Z])/g, '_$1').toUpperCase();
96
+ }
97
+
98
+ function isInRolloutPercentage(userId: string, percentage: number): boolean {
99
+ let hash = 0;
100
+ for (let i = 0; i < userId.length; i++) {
101
+ hash = (hash * 31 + userId.charCodeAt(i)) | 0;
102
+ }
103
+ return Math.abs(hash % 100) < percentage;
104
+ }
105
+ ```
106
+
107
+ ## Step 3: Prisma Schema
108
+
109
+ ```prisma
110
+ // prisma/schema/feature-flags.prisma
111
+ model FeatureFlag {
112
+ id String @id @default(cuid())
113
+ flag String
114
+ userId String
115
+ enabled Boolean @default(true)
116
+ createdAt DateTime @default(now())
117
+ updatedAt DateTime @updatedAt
118
+
119
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
120
+
121
+ @@unique([flag, userId])
122
+ @@index([flag])
123
+ @@index([userId])
124
+ }
125
+
126
+ model FeatureFlagRollout {
127
+ id String @id @default(cuid())
128
+ flag String @unique
129
+ percentage Int @default(0)
130
+ createdAt DateTime @default(now())
131
+ updatedAt DateTime @updatedAt
132
+ }
133
+ ```
134
+
135
+ Run `yarn db:push` to sync.
136
+
137
+ ## Step 4: API Route for Client Flags
138
+
139
+ ```typescript
140
+ // src/app/api/protected/feature-flags/route.ts
141
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
142
+ import { getEnabledFlags } from '@/features/feature-flags/server';
143
+ import { NextResponse } from 'next/server';
144
+
145
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
146
+ try {
147
+ const flags = await getEnabledFlags(request.session.userId);
148
+ return NextResponse.json(flags);
149
+ } catch (error) {
150
+ return handleApiError(error, { endpoint: '/api/protected/feature-flags' });
151
+ }
152
+ });
153
+ ```
154
+
155
+ ## Step 5: Client-Side Context and Hook
156
+
157
+ ```tsx
158
+ // src/features/feature-flags/context/FeatureFlagProvider.tsx
159
+ 'use client';
160
+
161
+ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
162
+
163
+ interface FeatureFlagContextValue {
164
+ flags: Record<string, boolean>;
165
+ isEnabled: (flag: string) => boolean;
166
+ loading: boolean;
167
+ }
168
+
169
+ const FeatureFlagContext = createContext<FeatureFlagContextValue>({
170
+ flags: {},
171
+ isEnabled: () => false,
172
+ loading: true,
173
+ });
174
+
175
+ export function FeatureFlagProvider({ children }: { children: ReactNode }) {
176
+ const [flags, setFlags] = useState<Record<string, boolean>>({});
177
+ const [loading, setLoading] = useState(true);
178
+
179
+ useEffect(() => {
180
+ fetch('/api/protected/feature-flags')
181
+ .then((res) => res.json())
182
+ .then(setFlags)
183
+ .catch(() => {})
184
+ .finally(() => setLoading(false));
185
+ }, []);
186
+
187
+ const isEnabled = (flag: string) => flags[flag] ?? false;
188
+
189
+ return (
190
+ <FeatureFlagContext value={{ flags, isEnabled, loading }}>
191
+ {children}
192
+ </FeatureFlagContext>
193
+ );
194
+ }
195
+
196
+ export function useFeatureFlags() {
197
+ return useContext(FeatureFlagContext);
198
+ }
199
+
200
+ export function useFeatureFlag(flag: string): boolean {
201
+ const { isEnabled } = useFeatureFlags();
202
+ return isEnabled(flag);
203
+ }
204
+ ```
205
+
206
+ Add the provider to the protected layout:
207
+
208
+ ```tsx
209
+ // src/app/(protected)/layout.tsx
210
+ import { FeatureFlagProvider } from '@/features/feature-flags/context/FeatureFlagProvider';
211
+
212
+ export default function ProtectedLayout({ children }: { children: React.ReactNode }) {
213
+ return (
214
+ <FeatureFlagProvider>
215
+ {children}
216
+ </FeatureFlagProvider>
217
+ );
218
+ }
219
+ ```
220
+
221
+ ## Step 6: Usage Patterns
222
+
223
+ ### Server-side gating
224
+
225
+ ```typescript
226
+ import { isFeatureEnabled } from '@/features/feature-flags/server';
227
+
228
+ export const GET = withAuthNoParams(async (request) => {
229
+ if (!(await isFeatureEnabled('betaSearch', request.session.userId))) {
230
+ return NextResponse.json({ error: 'Feature not available' }, { status: 403 });
231
+ }
232
+ // ... feature logic
233
+ });
234
+ ```
235
+
236
+ ### Client-side gating
237
+
238
+ ```tsx
239
+ 'use client';
240
+
241
+ import { useFeatureFlag } from '@/features/feature-flags/context/FeatureFlagProvider';
242
+
243
+ export function DashboardContent() {
244
+ const showNewDashboard = useFeatureFlag('newDashboard');
245
+
246
+ if (showNewDashboard) {
247
+ return <NewDashboard />;
248
+ }
249
+ return <LegacyDashboard />;
250
+ }
251
+ ```
252
+
253
+ ### Environment variable override
254
+
255
+ ```bash
256
+ # Override a flag without redeployment
257
+ FEATURE_FLAG_BETA_SEARCH=true
258
+ FEATURE_FLAG_NEW_DASHBOARD=false
259
+ ```
260
+
261
+ ## Step 7: Admin API for Managing Rollouts
262
+
263
+ ```typescript
264
+ // src/app/api/protected/admin/feature-flags/route.ts
265
+ import { handleApiError, withRole, type AuthenticatedRequest } from '@/lib/mars';
266
+ import { prisma } from '@/lib/prisma';
267
+ import { NextResponse } from 'next/server';
268
+ import { z } from 'zod';
269
+
270
+ const updateRolloutSchema = z.object({
271
+ flag: z.string().min(1),
272
+ percentage: z.number().int().min(0).max(100),
273
+ });
274
+
275
+ export const GET = withRole(['admin'], async () => {
276
+ try {
277
+ const rollouts = await prisma.featureFlagRollout.findMany({
278
+ orderBy: { flag: 'asc' },
279
+ });
280
+ return NextResponse.json(rollouts);
281
+ } catch (error) {
282
+ return handleApiError(error, { endpoint: '/api/protected/admin/feature-flags' });
283
+ }
284
+ });
285
+
286
+ export const PUT = withRole(['admin'], async (request: AuthenticatedRequest) => {
287
+ try {
288
+ const body = updateRolloutSchema.parse(await request.json());
289
+ const rollout = await prisma.featureFlagRollout.upsert({
290
+ where: { flag: body.flag },
291
+ update: { percentage: body.percentage },
292
+ create: { flag: body.flag, percentage: body.percentage },
293
+ });
294
+ return NextResponse.json(rollout);
295
+ } catch (error) {
296
+ return handleApiError(error, { endpoint: '/api/protected/admin/feature-flags' });
297
+ }
298
+ });
299
+ ```
300
+
301
+ ## A/B Testing Integration
302
+
303
+ For A/B testing, combine feature flags with analytics:
304
+
305
+ ```typescript
306
+ import { isFeatureEnabled } from '@/features/feature-flags/server';
307
+
308
+ export async function getVariant(
309
+ flag: string,
310
+ userId: string,
311
+ ): Promise<'control' | 'treatment'> {
312
+ const enabled = await isFeatureEnabled(flag as any, userId);
313
+ return enabled ? 'treatment' : 'control';
314
+ }
315
+ ```
316
+
317
+ Track variant exposure in your analytics provider when the feature is rendered.
318
+
319
+ ## Testing
320
+
321
+ ```typescript
322
+ import { describe, it, expect, vi } from 'vitest';
323
+ import { isFeatureEnabled } from './index';
324
+
325
+ vi.mock('@/config/app.config', () => ({
326
+ appConfig: { features: { betaSearch: false, newDashboard: true } },
327
+ }));
328
+
329
+ vi.mock('@/lib/prisma', () => ({
330
+ prisma: {
331
+ featureFlag: { findUnique: vi.fn() },
332
+ featureFlagRollout: { findUnique: vi.fn() },
333
+ },
334
+ }));
335
+
336
+ describe('isFeatureEnabled', () => {
337
+ it('returns static default when no overrides exist', async () => {
338
+ expect(await isFeatureEnabled('newDashboard')).toBe(true);
339
+ expect(await isFeatureEnabled('betaSearch')).toBe(false);
340
+ });
341
+
342
+ it('respects environment variable overrides', async () => {
343
+ process.env.FEATURE_FLAG_BETA_SEARCH = 'true';
344
+ expect(await isFeatureEnabled('betaSearch')).toBe(true);
345
+ delete process.env.FEATURE_FLAG_BETA_SEARCH;
346
+ });
347
+ });
348
+ ```
349
+
350
+ ## Checklist
351
+
352
+ - [ ] Feature flags defined in `appConfig.features`
353
+ - [ ] Server-side `isFeatureEnabled()` with env override support
354
+ - [ ] Prisma models for user-level flags and rollout percentages
355
+ - [ ] API route for client-side flag fetching
356
+ - [ ] `FeatureFlagProvider` context and `useFeatureFlag` hook
357
+ - [ ] Provider added to protected layout
358
+ - [ ] Admin API for managing rollout percentages
359
+ - [ ] Environment variable override convention documented
360
+ - [ ] `db:push` run after schema changes
361
+ - [ ] Tests written for flag resolution logic