@mars-stack/cli 0.2.0 → 0.2.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/package.json +2 -2
- 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 +373 -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 +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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'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
|