diff --git a/docs/skills/epic-auth/SKILL.md b/docs/skills/epic-auth/SKILL.md new file mode 100644 index 000000000..dad6b3153 --- /dev/null +++ b/docs/skills/epic-auth/SKILL.md @@ -0,0 +1,762 @@ +--- +name: epic-auth +description: Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack +categories: + - authentication + - sessions + - oauth + - security +--- + +# Epic Stack: Authentication + +## When to use this skill + +Use this skill when you need to: +- Implement user authentication +- Work with sessions and cookies +- Configure OAuth providers (GitHub, Google, etc.) +- Implement 2FA (Two-Factor Authentication) +- Implement WebAuthn/Passkeys +- Handle login, signup, logout flows +- Manage email verification +- Implement password reset + +## Patterns and conventions + +### Authentication Philosophy + +Following Epic Web principles: + +**Least privilege** - Users should only have access to what they need, when they need it. Sessions should have minimal permissions and expire appropriately. Don't grant more access than necessary. + +**Design to fail fast and early** - Validate authentication and authorization as early as possible. Check session validity immediately, verify permissions before processing requests, and return clear errors quickly. + +**Example - Least privilege in sessions:** +```typescript +// ✅ Good - Minimal session data, explicit permissions +const session = await prisma.session.create({ + data: { + expirationDate: getSessionExpirationDate(), + userId, // Only store user ID, not full user data + }, +}) + +// Session only grants access to this specific user +// Permissions checked separately when needed + +// ❌ Avoid - Storing too much in session +const session = await prisma.session.create({ + data: { + expirationDate: getSessionExpirationDate(), + userId, + userRole: 'admin', // Don't store roles in session + permissions: ['all'], // Don't store permissions in session + }, +}) +// Roles and permissions should be checked from database, not session +``` + +**Example - Fail fast authentication:** +```typescript +// ✅ Good - Validate authentication early +export async function loader({ request }: Route.LoaderArgs) { + // Check authentication immediately - fail fast + const userId = await requireUserId(request) + + // Check permissions early - fail fast + await requireUserWithPermission(request, 'read:note:own') + + // Only proceed if authenticated and authorized + const notes = await prisma.note.findMany({ + where: { ownerId: userId }, + }) + + return { notes } +} + +// ❌ Avoid - Delayed authentication check +export async function loader({ request }: Route.LoaderArgs) { + // Process request first... + const notes = await prisma.note.findMany() + + // Check authentication at the end - too late! + const userId = await getUserId(request) + if (!userId) { + // Already processed request + throw redirect('/login') + } +} +``` + +### Cookie-based Sessions + +Epic Stack uses cookie-based sessions for authentication. Sessions are stored in the database and identified by signed cookies. + +**Session configuration:** +```typescript +// app/utils/session.server.ts +import { createCookieSessionStorage } from 'react-router' + +export const authSessionStorage = createCookieSessionStorage({ + cookie: { + name: 'en_session', + sameSite: 'lax', // CSRF protection advised if changing to 'none' + path: '/', + httpOnly: true, + secrets: process.env.SESSION_SECRET.split(','), + secure: process.env.NODE_ENV === 'production', + }, +}) +``` + +### Get current user + +**Server-side:** +```typescript +import { getUserId, requireUserId } from '#app/utils/auth.server.ts' + +// Get userId or null if not authenticated +const userId = await getUserId(request) + +// Require authenticated user (redirects to /login if not) +const userId = await requireUserId(request) +const userId = await requireUserId(request, { redirectTo: '/custom-login' }) + +// Require that user is NOT authenticated +import { requireAnonymous } from '#app/utils/auth.server.ts' +await requireAnonymous(request) // Redirects to / if authenticated +``` + +**Client-side:** +```typescript +import { useOptionalUser, useUser } from '#app/utils/user.ts' + +// Get user or undefined if not authenticated +const user = useOptionalUser() + +// Get authenticated user (throws error if not) +const user = useUser() +``` + +### Login with Email/Password + +**Validation schema:** +```typescript +const LoginSchema = z.object({ + username: UsernameSchema, + password: z.string().min(1, 'Password is required'), + redirectTo: z.string().optional(), + remember: z.boolean().optional(), +}) +``` + +**Login action (fail fast):** +```typescript +import { login } from '#app/utils/auth.server.ts' +import { handleNewSession } from './login.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + // Validate input early - fail fast + const submission = await parseWithZod(formData, { + schema: LoginSchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + const { username, password, redirectTo, remember } = submission.value + + // Authenticate early - fail fast if invalid + const session = await login({ username, password }) + + if (!session) { + // Return error immediately - don't process further + return data( + { + result: submission.reply({ + formErrors: ['Invalid username or password'], + }), + }, + { status: 400 }, + ) + } + + // Only create session if authentication succeeded + return handleNewSession({ + request, + session, + redirectTo, + remember: remember ?? false, + }) +} +``` + +### Signup with Email/Password + +**Signup action:** +```typescript +import { signup } from '#app/utils/auth.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + // Validate form... + + const session = await signup({ + email, + username, + password, + name, + }) + + // Handle session and redirect... +} +``` + +### OAuth Providers (GitHub, Google, etc.) + +Epic Stack uses `remix-auth` for OAuth providers. + +**Configure provider (GitHub example):** +```typescript +// app/utils/providers/github.server.ts +import { GitHubStrategy } from 'remix-auth-github' + +export class GitHubProvider implements AuthProvider { + getAuthStrategy() { + return new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: '/auth/github/callback', + }, + async ({ profile }) => { + // Return user profile + return { + id: profile.id, + email: profile.emails[0].value, + username: profile.displayName, + name: profile.displayName, + } + }, + ) + } +} +``` + +**Callback handler:** +```typescript +// app/routes/_auth/auth.$provider/callback.ts +export async function loader({ request, params }: Route.LoaderArgs) { + const providerName = ProviderNameSchema.parse(params.provider) + const authResult = await authenticator.authenticate(providerName, request) + + if (!authResult.success) { + throw redirectWithToast('/login', { + title: 'Auth Failed', + description: `Error authenticating with ${providerName}`, + type: 'error', + }) + } + + const { data: profile } = authResult + + // Check if connection exists + const existingConnection = await prisma.connection.findUnique({ + where: { + providerName_providerId: { + providerName, + providerId: String(profile.id), + }, + }, + }) + + // If exists, create session + if (existingConnection) { + return makeSession({ request, userId: existingConnection.userId }) + } + + // If email exists, link account + const user = await prisma.user.findUnique({ + where: { email: profile.email.toLowerCase() }, + }) + if (user) { + await prisma.connection.create({ + data: { + providerName, + providerId: String(profile.id), + userId: user.id, + }, + }) + return makeSession({ request, userId: user.id }) + } + + // New user, go to onboarding + // ... +} +``` + +### WebAuthn/Passkeys + +Epic Stack supports authentication with passkeys using WebAuthn. + +**Loader to generate options:** +```typescript +// app/routes/_auth/webauthn/authentication.ts +import { generateAuthenticationOptions } from '@simplewebauthn/server' + +export async function loader({ request }: Route.LoaderArgs) { + const config = getWebAuthnConfig(request) + const options = await generateAuthenticationOptions({ + rpID: config.rpID, + userVerification: 'preferred', + }) + + const cookieHeader = await passkeyCookie.serialize({ + challenge: options.challenge, + }) + + return Response.json({ options }, { + headers: { 'Set-Cookie': cookieHeader } + }) +} +``` + +**Action to verify authentication:** +```typescript +import { verifyAuthenticationResponse } from '@simplewebauthn/server' + +export async function action({ request }: Route.ActionArgs) { + const cookie = await passkeyCookie.parse(request.headers.get('Cookie')) + + if (!cookie?.challenge) { + throw new Error('Authentication challenge not found') + } + + const { authResponse } = await request.json() + const passkey = await prisma.passkey.findUnique({ + where: { id: authResponse.id }, + include: { user: true }, + }) + + const verification = await verifyAuthenticationResponse({ + response: authResponse, + expectedChallenge: cookie.challenge, + expectedOrigin: config.origin, + expectedRPID: config.rpID, + credential: { + id: authResponse.id, + publicKey: passkey.publicKey, + counter: Number(passkey.counter), + }, + }) + + if (!verification.verified) { + throw new Error('Authentication verification failed') + } + + // Actualizar counter + await prisma.passkey.update({ + where: { id: passkey.id }, + data: { counter: BigInt(verification.authenticationInfo.newCounter) }, + }) + + // Create sesión + const session = await prisma.session.create({ + data: { + expirationDate: getSessionExpirationDate(), + userId: passkey.userId, + }, + }) + + return handleNewSession({ request, session, remember: true }) +} +``` + +### Two-Factor Authentication (2FA) with TOTP + +Epic Stack uses TOTP (Time-based One-Time Password) para 2FA. + +**Check if user has 2FA:** +```typescript +const verification = await prisma.verification.findUnique({ + where: { + target_type: { + target: session.userId, + type: twoFAVerificationType, + }, + }, +}) +const userHasTwoFactor = Boolean(verification) +``` + +**Handle session with 2FA:** +```typescript +export async function handleNewSession({ + request, + session, + redirectTo, + remember, +}: { + request: Request + session: { userId: string; id: string; expirationDate: Date } + redirectTo?: string + remember: boolean +}) { + const verification = await prisma.verification.findUnique({ + where: { + target_type: { + target: session.userId, + type: twoFAVerificationType, + }, + }, + }) + const userHasTwoFactor = Boolean(verification) + + if (userHasTwoFactor) { + // Save unverified session + const verifySession = await verifySessionStorage.getSession() + verifySession.set(unverifiedSessionIdKey, session.id) + verifySession.set(rememberKey, remember) + + // Redirect to 2FA verification + const redirectUrl = getRedirectToUrl({ + request, + type: twoFAVerificationType, + target: session.userId, + redirectTo, + }) + return redirect(redirectUrl.toString(), { + headers: { + 'set-cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) + } else { + // User without 2FA, create session directly + const authSession = await authSessionStorage.getSession( + request.headers.get('cookie'), + ) + authSession.set(sessionKey, session.id) + return redirect(safeRedirect(redirectTo), { + headers: { + 'set-cookie': await authSessionStorage.commitSession(authSession, { + expires: remember ? session.expirationDate : undefined, + }), + }, + }) + } +} +``` + +**Verify 2FA code:** +```typescript +import { prepareTOTP, verifyTOTP } from '@epic-web/totp' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + const submission = await parseWithZod(formData, { + schema: VerifySchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + const { code } = submission.value + const verifySession = await verifySessionStorage.getSession( + request.headers.get('cookie'), + ) + const target = verifySession.get(targetKey) + const type = verifySession.get(typeKey) + + if (!target || !type) { + throw redirect('/login') + } + + const verification = await prisma.verification.findUnique({ + where: { target_type: { target, type } }, + select: { + secret: true, + algorithm: true, + period: true, + digits: true, + }, + }) + + if (!verification) { + throw redirect('/login') + } + + const isValid = verifyTOTP({ + otp: code, + secret: verification.secret, + algorithm: verification.algorithm as any, + period: verification.period, + digits: verification.digits, + }) + + if (!isValid) { + return data( + { + result: submission.reply({ + formErrors: ['Invalid code'], + }), + }, + { status: 400 }, + ) + } + + // Verify session and complete login + return handleVerification({ request, submission }) +} +``` + +### Email Verification + +Epic Stack uses TOTP codes sent via email for verification. + +**Prepare verification:** +```typescript +import { prepareVerification } from './verify.server.ts' + +const { verifyUrl, redirectTo, otp } = await prepareVerification({ + period: 10 * 60, // 10 minutes + request, + type: 'onboarding', + target: email, +}) + +// Enviar email con código y URL +await sendEmail({ + to: email, + subject: 'Welcome!', + react: , +}) + +return redirect(redirectTo.toString()) +``` + +**Verify code:** +```typescript +export async function loader({ request }: Route.LoaderArgs) { + const verifySession = await verifySessionStorage.getSession( + request.headers.get('cookie'), + ) + const target = verifySession.get(targetKey) + const type = verifySession.get(typeKey) + + if (!target || !type) { + throw redirect('/signup') + } + + return { target, type } +} + +export async function action({ request }: Route.ActionArgs) { + // Verify code (similar to 2FA) + // ... +} +``` + +### Password Reset + +**Request reset:** +```typescript +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + const submission = await parseWithZod(formData, { + schema: ForgotPasswordSchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + const { email } = submission.value + + // Prepare verification + const { verifyUrl, redirectTo, otp } = await prepareVerification({ + period: 10 * 60, + request, + type: 'reset-password', + target: email, + }) + + // Enviar email con código y URL + await sendEmail({ + to: email, + subject: 'Reset your password', + react: , + }) + + return redirect(redirectTo.toString()) +} +``` + +**Reset password:** +```typescript +export async function action({ request }: Route.ActionArgs) { + // Verify code first (similar to email verification) + // Then reset password + + const formData = await request.formData() + const submission = await parseWithZod(formData, { + schema: ResetPasswordSchema, + }) + + // Verify that code is valid + // ... + + const { password } = submission.value + + await resetUserPassword({ + username, + password, + }) + + // Destroy verification session and redirect to login + // ... +} +``` + +### Logout + +```typescript +import { logout } from '#app/utils/auth.server.ts' + +export async function action({ request }: Route.ActionArgs) { + return logout({ request, redirectTo: '/' }) +} +``` + +### Session Management + +**Create session:** +```typescript +const session = await prisma.session.create({ + data: { + expirationDate: getSessionExpirationDate(), + userId, + }, + select: { id: true, expirationDate: true }, +}) +``` + +**Session expiration:** +```typescript +export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days + +export const getSessionExpirationDate = () => + new Date(Date.now() + SESSION_EXPIRATION_TIME) +``` + +**Destroy session:** +```typescript +await prisma.session.deleteMany({ where: { id: sessionId } }) +``` + +**Destroy all user sessions:** +```typescript +await prisma.session.deleteMany({ where: { userId } }) +``` + +## Common examples + +### Example 1: Complete login + +```typescript +// app/routes/_auth/login.tsx +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + await checkHoneypot(formData) + + const submission = await parseWithZod(formData, { + schema: LoginSchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + const { username, password, redirectTo, remember } = submission.value + + const session = await login({ username, password }) + + if (!session) { + return data( + { + result: submission.reply({ + formErrors: ['Invalid username or password'], + }), + }, + { status: 400 }, + ) + } + + return handleNewSession({ + request, + session, + redirectTo, + remember: remember ?? false, + }) +} +``` + +### Example 2: Protect route + +```typescript +// app/routes/protected.tsx +export async function loader({ request }: Route.LoaderArgs) { + const userId = await requireUserId(request) + + const data = await prisma.something.findMany({ + where: { userId }, + }) + + return { data } +} + +export default function ProtectedRoute({ loaderData }: Route.ComponentProps) { + return
{/* Datos protegidos */}
+} +``` + +### Example 3: Route only for unauthenticated users + +```typescript +// app/routes/_auth/signup.tsx +export async function loader({ request }: Route.LoaderArgs) { + await requireAnonymous(request) + return null +} +``` + +## Common mistakes to avoid + +- ❌ **Delayed authentication checks**: Validate authentication and authorization as early as possible - fail fast +- ❌ **Granting excessive privileges**: Follow least privilege - only grant access to what's needed, when it's needed +- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user ID), check permissions from database +- ❌ **Not verifying session on each request**: Always use `getUserId` or `requireUserId` in protected loaders/actions +- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating session +- ❌ **Not destroying expired sessions**: Sessions must be verified against `expirationDate` - check early +- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and cookie creation +- ❌ **Forgetting to handle `remember`**: Make sure to respect user preference +- ❌ **Not validating OAuth callbacks**: Always validate that provider exists and result is successful - fail fast +- ❌ **Not linking OAuth accounts**: If email exists, link the account instead of creating duplicate +- ❌ **Not updating counter in passkeys**: Always update counter after successful verification + +## References + +- [Epic Stack Authentication Docs](../epic-stack/docs/authentication.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [Remix Auth](https://github.com/sergiodxa/remix-auth) +- [SimpleWebAuthn](https://simplewebauthn.dev/) +- `app/utils/auth.server.ts` - Authentication utilities +- `app/utils/session.server.ts` - Gestión de sesiones +- `app/routes/_auth/login.server.ts` - Helpers de login +- `app/routes/_auth/auth.$provider/callback.ts` - OAuth callback +- `app/routes/_auth/webauthn/` - WebAuthn implementation diff --git a/docs/skills/epic-caching/SKILL.md b/docs/skills/epic-caching/SKILL.md new file mode 100644 index 000000000..891cff269 --- /dev/null +++ b/docs/skills/epic-caching/SKILL.md @@ -0,0 +1,554 @@ +--- +name: epic-caching +description: Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack +categories: + - caching + - performance + - optimization +--- + +# Epic Stack: Caching + +## When to use this skill + +Use this skill when you need to: +- Cache results of expensive queries +- Cache responses from external APIs +- Optimize performance of data that doesn't change frequently +- Implement stale-while-revalidate +- Manage cache invalidation +- Integrate cache with server timing + +## Patterns and conventions + +### Caching Philosophy + +Following Epic Web principles: + +**Weigh the cost-benefit of performance optimizations** - Caching adds complexity. Only add cache when there's a clear, measurable benefit. Don't cache "just in case" - cache when you have a real performance problem that caching solves. + +**When NOT to use cache:** +- Data that changes frequently (cache invalidation becomes a problem) +- Data that's already fast to fetch (no measurable benefit) +- Data that's only fetched once (no benefit from caching) +- Simple queries that don't need optimization +- When cache invalidation logic becomes more complex than the problem it solves + +**Example - Evaluating cost-benefit:** +```typescript +// ✅ Good - Cache expensive external API call +export async function getGitHubEvents({ username, timings }: { username: string; timings?: Timings }) { + return await cachified({ + key: `github:${username}:events`, + cache, + timings, + getFreshValue: async () => { + // Expensive: External API call, rate limits, network latency + const response = await fetch(`https://api.github.com/users/${username}/events/public`) + return await response.json() + }, + checkValue: GitHubEventSchema.array(), + ttl: 1000 * 60 * 60, // 1 hour - reasonable for external data + }) +} + +// ❌ Avoid - Caching simple, fast database query +export async function getUser({ userId }: { userId: string }) { + // This query is already fast - caching adds complexity without benefit + return await cachified({ + key: `user:${userId}`, + cache, + getFreshValue: async () => { + // Simple query, already fast + return await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true }, + }) + }, + ttl: 1000 * 60 * 5, + }) + // Better: Just query directly without cache +} +``` + +### Two Types of Cache + +Epic Stack provides two types of cache: + +1. **SQLite Cache** - Long-lived, replicated with LiteFS + - Persistent across restarts + - Replicated across all instances + - Ideal for data that changes infrequently + +2. **LRU Cache** - Short-lived, in-memory + - Cleared on restart + - Not replicated (only on current instance) + - Ideal for deduplication and temporary cache + +### Using cachified + +Epic Stack uses `@epic-web/cachified` as an abstraction for cache management. + +**Basic import:** +```typescript +import { cachified, cache } from '#app/utils/cache.server.ts' +import { type Timings } from '#app/utils/timing.server.ts' +``` + +**Basic structure:** +```typescript +export async function getCachedData({ + timings, +}: { + timings?: Timings +} = {}) { + return await cachified({ + key: 'my-cache-key', + cache, + timings, + getFreshValue: async () => { + // Get fresh data + return await fetchDataFromAPI() + }, + checkValue: z.object({ /* schema */ }), // Validation with Zod + ttl: 1000 * 60 * 60 * 24, // 24 hours + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days + }) +} +``` + +### Cache Keys + +**Naming conventions:** +- Use format: `entity:identifier:data` +- Examples: + - `user:${userId}:profile` + - `note:${noteId}:full` + - `api:github:events` + - `tito:scheduled-events` + +**Avoid:** +- Keys that are too long +- Keys with special characters +- Keys that don't clearly identify the content + +### TTL (Time To Live) + +**Define TTL:** +```typescript +await cachified({ + key: 'my-key', + cache, + getFreshValue: () => fetchData(), + ttl: 1000 * 60 * 60 * 24, // 24 hours in milliseconds +}) +``` + +**Null TTL to never expire:** +```typescript +ttl: null, // Never expires (not recommended unless necessary) +``` + +### Stale-While-Revalidate (SWR) + +SWR allows returning stale data while fresh data is fetched in the background. + +**Example:** +```typescript +await cachified({ + key: 'my-key', + cache, + getFreshValue: () => fetchData(), + ttl: 1000 * 60 * 60 * 24, // 24 hours - after this it's considered stale + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days - up to here returns stale while revalidating +}) +``` + +**Behavior:** +- **Less than 24h**: Returns cache, no request made +- **24h - 30 days**: Returns stale cache immediately, updates in background +- **More than 30 days**: Waits for fresh data before returning + +### Validation with Zod + +Always validate cached data with Zod: + +```typescript +import { z } from 'zod' + +const EventSchema = z.object({ + id: z.string(), + title: z.string(), + date: z.string(), +}) + +export async function getEvents({ timings }: { timings?: Timings } = {}) { + return await cachified({ + key: 'events:all', + cache, + timings, + getFreshValue: async () => { + const response = await fetch('https://api.example.com/events') + return await response.json() + }, + checkValue: EventSchema.array(), // Validates it's an array of events + ttl: 1000 * 60 * 60 * 24, // 24 hours + }) +} +``` + +If cached data doesn't pass validation, fresh data is fetched. + +### Server Timing Integration + +Integrate cache with server timing for monitoring: + +```typescript +import { type Timings } from '#app/utils/timing.server.ts' + +export async function loader({ request }: Route.LoaderArgs) { + const timings: Timings = {} + + const events = await getEvents({ timings }) + + // Timings are automatically added to headers + return json({ events }, { + headers: combineServerTimings(timings), + }) +} +``` + +### Cache Invalidation + +**Invalidate by key:** +```typescript +import { cache } from '#app/utils/cache.server.ts' + +await cache.delete('user:123:profile') +``` + +**Invalidate multiple keys:** +```typescript +// Search and delete matching keys +import { searchCacheKeys } from '#app/utils/cache.server.ts' + +const keys = await searchCacheKeys('user:123', 100) +await Promise.all(keys.map(key => cache.delete(key))) +``` + +**Invalidate entire SQLite cache:** +```typescript +// Use admin dashboard or +await cache.clear() // If available +``` + +### Using LRU Cache + +For temporary data, use LRU cache directly: + +```typescript +import { lru } from '#app/utils/cache.server.ts' + +// LRU cache is useful for: +// - Request deduplication +// - Very temporary cache (< 5 minutes) +// - Data that doesn't need to persist + +const cachedValue = lru.get('temp-key') +if (!cachedValue) { + const freshValue = await computeExpensiveValue() + lru.set('temp-key', freshValue, { ttl: 1000 * 60 * 5 }) // 5 minutes + return freshValue +} +return cachedValue +``` + +### Multi-Region Cache + +With LiteFS, SQLite cache is automatically replicated: + +**Behavior:** +- Only the primary instance writes to cache +- Replicas can read from cache +- Writes are automatically synchronized + +**Best practices:** +- Don't assume all writes are immediate +- Use `ensurePrimary()` if you need to guarantee writes + +```typescript +import { ensurePrimary } from '#app/utils/litefs.server.ts' + +export async function action({ request }: Route.ActionArgs) { + await ensurePrimary() // Ensure we're on primary instance + + // Invalidate cache + await cache.delete('my-key') + + // ... +} +``` + +### Error Handling + +**Handle errors in getFreshValue:** +```typescript +await cachified({ + key: 'my-key', + cache, + getFreshValue: async () => { + try { + return await fetchData() + } catch (error) { + console.error('Failed to fetch fresh data:', error) + throw error // Re-throw so cachified handles it + } + }, + // If getFreshValue fails and there's stale cache, it returns it + fallbackToCache: true, // Default: true +}) +``` + +### Cache Admin Dashboard + +Epic Stack includes a dashboard to manage cache: + +**Route:** `/admin/cache` + +**Features:** +- View all cache keys +- Search keys +- View details of a key +- Delete keys +- Clear entire cache + +## Common examples + +### Example 1: Cache external API response + +```typescript +// app/utils/api.server.ts +import { cachified, cache } from '#app/utils/cache.server.ts' +import { type Timings } from '#app/utils/timing.server.ts' +import { z } from 'zod' + +const GitHubEventSchema = z.object({ + id: z.string(), + type: z.string(), + actor: z.object({ + login: z.string(), + }), + created_at: z.string(), +}) + +export async function getGitHubEvents({ + username, + timings, +}: { + username: string + timings?: Timings +}) { + return await cachified({ + key: `github:${username}:events`, + cache, + timings, + getFreshValue: async () => { + const response = await fetch( + `https://api.github.com/users/${username}/events/public`, + ) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`) + } + const data = await response.json() + return data + }, + checkValue: GitHubEventSchema.array(), + ttl: 1000 * 60 * 60, // 1 hour + staleWhileRevalidate: 1000 * 60 * 60 * 24, // 24 hours + }) +} +``` + +### Example 2: Cache Prisma query + +```typescript +// app/utils/user.server.ts +import { cachified, cache } from '#app/utils/cache.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { z } from 'zod' + +const UserStatsSchema = z.object({ + totalNotes: z.number(), + totalLikes: z.number(), + joinDate: z.string(), +}) + +export async function getUserStats({ + userId, + timings, +}: { + userId: string + timings?: Timings +}) { + return await cachified({ + key: `user:${userId}:stats`, + cache, + timings, + getFreshValue: async () => { + const [totalNotes, totalLikes, user] = await Promise.all([ + prisma.note.count({ where: { ownerId: userId } }), + prisma.like.count({ where: { userId } }), + prisma.user.findUnique({ + where: { id: userId }, + select: { createdAt: true }, + }), + ]) + + return { + totalNotes, + totalLikes, + joinDate: user?.createdAt.toISOString() ?? '', + } + }, + checkValue: UserStatsSchema, + ttl: 1000 * 60 * 5, // 5 minutes + staleWhileRevalidate: 1000 * 60 * 60, // 1 hour + }) +} +``` + +### Example 3: Invalidate cache after mutation + +```typescript +// app/routes/users/$username/notes/new.tsx +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + const formData = await request.formData() + + // ... validate and create note + + const note = await prisma.note.create({ + data: { + title, + content, + ownerId: userId, + }, + include: { owner: true }, + }) + + // Invalidate related cache + await Promise.all([ + cache.delete(`user:${userId}:notes`), + cache.delete(`user:${userId}:stats`), + cache.delete(`note:${note.id}:full`), + ]) + + return redirect(`/users/${note.owner.username}/notes/${note.id}`) +} +``` + +### Example 4: Cache with dependencies + +```typescript +export async function getUserWithNotes({ + userId, + timings, +}: { + userId: string + timings?: Timings +}) { + const user = await cachified({ + key: `user:${userId}:profile`, + cache, + timings, + getFreshValue: async () => { + return await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + name: true, + }, + }) + }, + checkValue: z.object({ + id: z.string(), + username: z.string(), + name: z.string().nullable(), + }).nullable(), + ttl: 1000 * 60 * 30, // 30 minutes + }) + + const notes = await cachified({ + key: `user:${userId}:notes`, + cache, + timings, + getFreshValue: async () => { + return await prisma.note.findMany({ + where: { ownerId: userId }, + select: { + id: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + }) + }, + checkValue: z.array(z.object({ + id: z.string(), + title: z.string(), + updatedAt: z.date(), + })), + ttl: 1000 * 60 * 10, // 10 minutes + }) + + return { user, notes } +} +``` + +### Example 5: Use LRU for deduplication + +```typescript +// Avoid multiple simultaneous requests to the same URL +const requestCache = new Map>() + +export async function fetchWithDedup(url: string) { + if (requestCache.has(url)) { + return requestCache.get(url) + } + + const promise = fetch(url).then(res => res.json()) + requestCache.set(url, promise) + + // Clean up after 1 second + setTimeout(() => { + requestCache.delete(url) + }, 1000) + + return promise +} +``` + +## Common mistakes to avoid + +- ❌ **Caching without measuring benefit**: Only add cache when there's a clear, measurable performance problem +- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to fetch - it adds complexity without benefit +- ❌ **Caching frequently changing data**: Cache invalidation becomes more complex than the problem it solves +- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive personal data +- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely necessary +- ❌ **Not validating cached data**: Always use `checkValue` with Zod to validate data +- ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations +- ❌ **Assuming cache always works**: Cache can fail, always handle errors +- ❌ **Keys too long or ambiguous**: Use consistent and descriptive format +- ❌ **Not using timings**: Integrate with server timing for monitoring +- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when appropriate +- ❌ **Over-caching**: Too much caching makes the system harder to understand and debug + +## References + +- [Epic Stack Caching Docs](../epic-stack/docs/caching.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [@epic-web/cachified](https://www.npmjs.com/package/@epic-web/cachified) +- `app/utils/cache.server.ts` - Cache implementation +- `app/routes/admin/cache/` - Admin dashboard +- `app/utils/timing.server.ts` - Server timing utilities diff --git a/docs/skills/epic-database/SKILL.md b/docs/skills/epic-database/SKILL.md new file mode 100644 index 000000000..9ff3654a1 --- /dev/null +++ b/docs/skills/epic-database/SKILL.md @@ -0,0 +1,758 @@ +--- +name: epic-database +description: Guide on Prisma, SQLite, and LiteFS for Epic Stack +categories: + - database + - prisma + - sqlite + - litefs +--- + +# Epic Stack: Database + +## When to use this skill + +Use this skill when you need to: +- Design database schema with Prisma +- Create migrations +- Work with SQLite and LiteFS +- Optimize queries and performance +- Create seed scripts +- Work with multi-region deployments +- Manage backups and restores + +## Patterns and conventions + +### Database Philosophy + +Following Epic Web principles: + +**Do as little as possible** - Only fetch the data you actually need. Use `select` to fetch specific fields instead of entire models. Avoid over-fetching data "just in case" - fetch what you need, when you need it. + +**Pragmatism over purity** - Optimize queries when there's a measurable benefit, but don't over-optimize prematurely. Simple, readable queries are often better than complex optimized ones. Add indexes when queries are slow, not before. + +**Example - Fetch only what you need:** +```typescript +// ✅ Good - Fetch only needed fields +const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + name: true, + // Only fetch what you actually use + }, +}) + +// ❌ Avoid - Fetching everything +const user = await prisma.user.findUnique({ + where: { id: userId }, + // Fetches all fields including password hash, email, etc. +}) +``` + +**Example - Pragmatic optimization:** +```typescript +// ✅ Good - Simple query first, optimize if needed +const notes = await prisma.note.findMany({ + where: { ownerId: userId }, + select: { id: true, title: true, updatedAt: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, +}) + +// Only add indexes if this query is actually slow +// Don't pre-optimize + +// ❌ Avoid - Over-optimizing before measuring +// Adding complex indexes, joins, etc. before knowing if it's needed +``` + +### Prisma Schema + +Epic Stack uses Prisma with SQLite as the database. + +**Basic configuration:** +```prisma +// prisma/schema.prisma +generator client { + provider = "prisma-client-js" + previewFeatures = ["typedSql"] +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} +``` + +**Basic model:** +```prisma +model User { + id String @id @default(cuid()) + email String @unique + username String @unique + name String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + notes Note[] + roles Role[] +} + +model Note { + id String @id @default(cuid()) + title String + content String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + + @@index([ownerId]) + @@index([ownerId, updatedAt]) +} +``` + +### CUID2 for IDs + +Epic Stack uses CUID2 to generate unique IDs. + +**Advantages:** +- Globally unique +- Sortable +- Secure (no exposed information) +- URL-friendly + +**Example:** +```prisma +model User { + id String @id @default(cuid()) // Automatically generates CUID2 +} +``` + +### Timestamps + +**Standard fields:** +```prisma +model User { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Automatically updated +} +``` + +### Relationships + +**One-to-Many:** +```prisma +model User { + id String @id @default(cuid()) + notes Note[] +} + +model Note { + id String @id @default(cuid()) + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + + @@index([ownerId]) +} +``` + +**One-to-One:** +```prisma +model User { + id String @id @default(cuid()) + image UserImage? +} + +model UserImage { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String @unique +} +``` + +**Many-to-Many:** +```prisma +model User { + id String @id @default(cuid()) + roles Role[] +} + +model Role { + id String @id @default(cuid()) + users User[] +} +``` + +### Indexes + +**Create indexes:** +```prisma +model Note { + id String @id @default(cuid()) + ownerId String + updatedAt DateTime + + @@index([ownerId]) // Simple index + @@index([ownerId, updatedAt]) // Composite index +} +``` + +**Best practices:** +- Index foreign keys +- Index fields used in `where` frequently +- Index fields used in `orderBy` +- Use composite indexes for complex queries + +### Cascade Delete + +**Configure cascade:** +```prisma +model User { + id String @id @default(cuid()) + notes Note[] +} + +model Note { + id String @id @default(cuid()) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String +} +``` + +**Options:** +- `onDelete: Cascade` - Deletes children when parent is deleted +- `onDelete: SetNull` - Sets to null when parent is deleted +- `onDelete: Restrict` - Prevents deletion if there are children + +### Migrations + +**Create migration:** +```bash +npx prisma migrate dev --name add_user_field +``` + +**Apply migrations in production:** +```bash +npx prisma migrate deploy +``` + +**Automatic migrations:** +Migrations are automatically applied on deploy via `litefs.yml`. + +**"Widen then Narrow" strategy for zero-downtime:** + +1. **Widen app** - App accepts A or B +2. **Widen db** - DB provides A and B, app writes to both +3. **Narrow app** - App only uses B +4. **Narrow db** - DB only provides B + +**Example: Rename field `name` to `firstName` and `lastName`:** + +```prisma +// Step 1: Widen app (accepts both) +model User { + id String @id @default(cuid()) + name String? // Deprecated + firstName String? // New + lastName String? // New +} + +// Step 2: Widen db (migration copies data) +// In SQL migration: +ALTER TABLE User ADD COLUMN firstName TEXT; +ALTER TABLE User ADD COLUMN lastName TEXT; +UPDATE User SET firstName = name; + +// Step 3: Narrow app (only uses new fields) +// Code only uses firstName and lastName + +// Step 4: Narrow db (removes old field) +ALTER TABLE User DROP COLUMN name; +``` + +### Prisma Client + +**Import Prisma Client:** +```typescript +import { prisma } from '#app/utils/db.server.ts' +``` + +**Basic query:** +```typescript +const user = await prisma.user.findUnique({ + where: { id: userId }, +}) +``` + +**Specific select:** +```typescript +const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + // Don't include password or sensitive data + }, +}) +``` + +**Include relations:** +```typescript +const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + notes: { + select: { + id: true, + title: true, + }, + orderBy: { updatedAt: 'desc' }, + }, + roles: true, + }, +}) +``` + +**Complex queries:** +```typescript +const notes = await prisma.note.findMany({ + where: { + ownerId: userId, + title: { contains: searchTerm }, + }, + select: { + id: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + take: 20, + skip: (page - 1) * 20, +}) +``` + +### Transactions + +**Use transactions:** +```typescript +await prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email, + username, + roles: { connect: { name: 'user' } }, + }, + }) + + await tx.note.create({ + data: { + title: 'Welcome', + content: 'Welcome to the app!', + ownerId: user.id, + }, + }) + + return user +}) +``` + +### SQLite con LiteFS + +**Multi-region with LiteFS:** +- Only the primary instance can write +- Replicas can only read +- Writes are automatically replicated + +**Check primary instance:** +```typescript +import { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts' + +export async function action({ request }: Route.ActionArgs) { + // Ensure we're on primary instance for writes + await ensurePrimary() + + // Now we can write safely + await prisma.user.create({ data: { /* ... */ } }) +} +``` + +**Get instance information:** +```typescript +import { getInstanceInfo } from '#app/utils/litefs.server.ts' + +const { currentIsPrimary, primaryInstance } = await getInstanceInfo() + +if (currentIsPrimary) { + // Can write +} else { + // Read-only, redirect to primary if necessary +} +``` + +### Seed Scripts + +**Create seed:** +```typescript +// prisma/seed.ts +import { prisma } from '#app/utils/db.server.ts' + +async function seed() { + // Create roles + await prisma.role.createMany({ + data: [ + { name: 'user', description: 'Standard user' }, + { name: 'admin', description: 'Administrator' }, + ], + }) + + // Create users + const user = await prisma.user.create({ + data: { + email: 'user@example.com', + username: 'testuser', + roles: { connect: { name: 'user' } }, + }, + }) + + console.log('Seed complete!') +} + +seed() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) +``` + +**Run seed:** +```bash +npx prisma db seed +# Or directly: +npx tsx prisma/seed.ts +``` + +### Query Optimization + +**Guidelines (pragmatic approach):** +- Use `select` to fetch only needed fields - do as little as possible +- Use selective `include` - only include relations you actually use +- Index fields used in `where` and `orderBy` - but only if queries are slow +- Use composite indexes for complex queries - when you have a real performance problem +- Avoid `select: true` (fetches everything) - be explicit about what you need +- Measure first, optimize second - don't pre-optimize + +**Optimized example (do as little as possible):** +```typescript +// ❌ Avoid: Fetches everything unnecessarily +const user = await prisma.user.findUnique({ + where: { id: userId }, + // Fetches password hash, email, all relations, etc. +}) + +// ✅ Good: Only needed fields - do as little as possible +const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + name: true, + // Only what you actually use + }, +}) + +// ✅ Better: With selective relations (only if you need them) +const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + notes: { + select: { + id: true, + title: true, + }, + take: 10, // Only fetch what you need + }, + }, +}) +``` + +### Prisma Query Logging + +**Configure logging:** +```typescript +// app/utils/db.server.ts +const client = new PrismaClient({ + log: [ + { level: 'query', emit: 'event' }, + { level: 'error', emit: 'stdout' }, + { level: 'warn', emit: 'stdout' }, + ], +}) + +client.$on('query', async (e) => { + if (e.duration < 20) return // Only log slow queries + + console.info(`prisma:query - ${e.duration}ms - ${e.query}`) +}) +``` + +### Database URL + +**Development:** +```bash +DATABASE_URL=file:./data/db.sqlite +``` + +**Production (Fly.io):** +```bash +DATABASE_URL=file:/litefs/data/sqlite.db +``` + +### Connecting to DB in Production + +**SSH to Fly instance:** +```bash +fly ssh console --app [YOUR_APP_NAME] +``` + +**Connect to DB CLI:** +```bash +fly ssh console -C database-cli --app [YOUR_APP_NAME] +``` + +**Prisma Studio:** +```bash +# Terminal 1: Start Prisma Studio +fly ssh console -C "npx prisma studio" -s --app [YOUR_APP_NAME] + +# Terminal 2: Local proxy +fly proxy 5556:5555 --app [YOUR_APP_NAME] + +# Open in browser +# http://localhost:5556 +``` + +## Common examples + +### Example 1: Create model with relations + +```prisma +model Post { + id String @id @default(cuid()) + title String + content String + published Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String + + comments Comment[] + tags Tag[] + + @@index([authorId]) + @@index([authorId, published]) + @@index([published, updatedAt]) +} + +model Comment { + id String @id @default(cuid()) + content String + + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String + + author User @relation(fields: [authorId], references: [id]) + authorId String + + @@index([postId]) + @@index([authorId]) +} +``` + +### Example 2: Complex query with pagination + +```typescript +export async function getPosts({ + userId, + page = 1, + perPage = 20, + published, +}: { + userId?: string + page?: number + perPage?: number + published?: boolean +}) { + const where: Prisma.PostWhereInput = {} + + if (userId) { + where.authorId = userId + } + if (published !== undefined) { + where.published = published + } + + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where, + select: { + id: true, + title: true, + updatedAt: true, + author: { + select: { + id: true, + username: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + take: perPage, + skip: (page - 1) * perPage, + }), + prisma.post.count({ where }), + ]) + + return { + posts, + total, + pages: Math.ceil(total / perPage), + } +} +``` + +### Example 3: Transaction with multiple operations + +```typescript +export async function createPostWithTags({ + authorId, + title, + content, + tagNames, +}: { + authorId: string + title: string + content: string + tagNames: string[] +}) { + return await prisma.$transaction(async (tx) => { + // Create tags if they don't exist + await Promise.all( + tagNames.map((name) => + tx.tag.upsert({ + where: { name }, + update: {}, + create: { name }, + }), + ), + ) + + // Create post + const post = await tx.post.create({ + data: { + title, + content, + authorId, + tags: { + connect: tagNames.map((name) => ({ name })), + }, + }, + }) + + return post + }) +} +``` + +### Example 4: Seed with related data + +```typescript +async function seed() { + // Create permissions + const permissions = await Promise.all([ + prisma.permission.create({ + data: { + action: 'create', + entity: 'note', + access: 'own', + description: 'Can create own notes', + }, + }), + prisma.permission.create({ + data: { + action: 'read', + entity: 'note', + access: 'own', + description: 'Can read own notes', + }, + }), + ]) + + // Create roles with permissions + const userRole = await prisma.role.create({ + data: { + name: 'user', + description: 'Standard user', + permissions: { + connect: permissions.map(p => ({ id: p.id })), + }, + }, + }) + + // Create user with role + const user = await prisma.user.create({ + data: { + email: 'user@example.com', + username: 'testuser', + roles: { + connect: { id: userRole.id }, + }, + }, + }) + + console.log('Seed complete!') +} +``` + +## Common mistakes to avoid + +- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need - do as little as possible +- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add indexes "just in case" +- ❌ **Not using indexes when needed**: Index foreign keys and fields used in frequent queries, but only if they're actually slow +- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when you need them +- ❌ **Not using transactions for related operations**: Always use transactions when multiple operations must be atomic +- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in production +- ❌ **Breaking migrations without strategy**: Use "widen then narrow" for zero-downtime +- ❌ **Not validating data before inserting**: Always validate with Zod before create/update +- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when parent is deleted +- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or others +- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but ensure in scripts +- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries over complex optimized ones unless there's a real problem + +## References + +- [Epic Stack Database Docs](../epic-stack/docs/database.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [Prisma Documentation](https://www.prisma.io/docs) +- [LiteFS Documentation](https://fly.io/docs/litefs/) +- [SQLite Documentation](https://www.sqlite.org/docs.html) +- `prisma/schema.prisma` - Complete schema +- `prisma/seed.ts` - Seed example +- `app/utils/db.server.ts` - Prisma Client setup +- `app/utils/litefs.server.ts` - LiteFS utilities diff --git a/docs/skills/epic-deployment/SKILL.md b/docs/skills/epic-deployment/SKILL.md new file mode 100644 index 000000000..2761c9869 --- /dev/null +++ b/docs/skills/epic-deployment/SKILL.md @@ -0,0 +1,745 @@ +--- +name: epic-deployment +description: Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack +categories: + - deployment + - fly-io + - ci-cd + - docker +--- + +# Epic Stack: Deployment + +## When to use this skill + +Use this skill when you need to: +- Configure deployment on Fly.io +- Setup multi-region deployment +- Configure CI/CD with GitHub Actions +- Manage secrets in production +- Configure healthchecks +- Work with LiteFS and volumes +- Local deployment with Docker + +## Patterns and conventions + +### Fly.io Configuration + +Epic Stack uses Fly.io for hosting with configuration in `fly.toml`. + +**Basic configuration:** +```toml +# fly.toml +app = "your-app-name" +primary_region = "sjc" +kill_signal = "SIGINT" +kill_timeout = 5 + +[build] +dockerfile = "/other/Dockerfile" +ignorefile = "/other/Dockerfile.dockerignore" + +[mounts] +source = "data" +destination = "/data" +``` + +### Primary Region + +**Configure primary region:** +```toml +primary_region = "sjc" # Change according to your location +``` + +**Important:** The primary region must be the same for: +- `primary_region` en `fly.toml` +- Region del volume `data` +- `PRIMARY_REGION` en variables de entorno + +### LiteFS Configuration + +**Configuration in `other/litefs.yml`:** +```yaml +fuse: + dir: '${LITEFS_DIR}' + +data: + dir: '/data/litefs' + +proxy: + addr: ':${INTERNAL_PORT}' + target: 'localhost:${PORT}' + db: '${DATABASE_FILENAME}' + +lease: + type: 'consul' + candidate: ${FLY_REGION == PRIMARY_REGION} + promote: true + advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202' + consul: + url: '${FLY_CONSUL_URL}' + key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}' + +exec: + - cmd: npx prisma migrate deploy + if-candidate: true + - cmd: sqlite3 $DATABASE_PATH "PRAGMA journal_mode = WAL;" + if-candidate: true + - cmd: sqlite3 $CACHE_DATABASE_PATH "PRAGMA journal_mode = WAL;" + if-candidate: true + - cmd: npx prisma generate --sql + - cmd: npm start +``` + +### Healthchecks + +**Configuration in `fly.toml`:** +```toml +[[services.http_checks]] +interval = "10s" +grace_period = "5s" +method = "get" +path = "/resources/healthcheck" +protocol = "http" +timeout = "2s" +tls_skip_verify = false +``` + +**Healthcheck implementation:** +```typescript +// app/routes/resources/healthcheck.tsx +export async function loader({ request }: Route.LoaderArgs) { + const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') + + try { + await Promise.all([ + prisma.user.count(), // Verify DB + fetch(`${new URL(request.url).protocol}${host}`, { + method: 'HEAD', + headers: { 'X-Healthcheck': 'true' }, + }), + ]) + return new Response('OK') + } catch (error) { + console.log('healthcheck ❌', { error }) + return new Response('ERROR', { status: 500 }) + } +} +``` + +### Environment Variables + +**Secrets in Fly.io:** +```bash +# Generate secrets +fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME] +fly secrets set HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME] + +# List secrets +fly secrets list --app [YOUR_APP_NAME] + +# Delete secret +fly secrets unset SECRET_NAME --app [YOUR_APP_NAME] +``` + +**Common secrets:** +- `SESSION_SECRET` - Secret for signing session cookies +- `HONEYPOT_SECRET` - Secret for honeypot fields +- `DATABASE_URL` - Automatically configured by LiteFS +- `CACHE_DATABASE_PATH` - Automatically configured +- `RESEND_API_KEY` - For sending emails (optional) +- `TIGRIS_*` - For image storage (automatic) +- `SENTRY_DSN` - For error monitoring (optional) + +### Volumes + +**Create volume:** +```bash +fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME] +``` + +**List volumes:** +```bash +fly volumes list --app [YOUR_APP_NAME] +``` + +**Expand volume:** +```bash +fly volumes extend --size 10 --app [YOUR_APP_NAME] +``` + +### Multi-Region Deployment + +**Deploy to multiple regions:** +```bash +# Deploy in primary region (more instances) +fly scale count 2 --region sjc --app [YOUR_APP_NAME] + +# Deploy in secondary regions (read-only) +fly scale count 1 --region ams --app [YOUR_APP_NAME] +fly scale count 1 --region syd --app [YOUR_APP_NAME] +``` + +**Verify instances:** +```bash +fly status --app [YOUR_APP_NAME] +# The ROLE column will show "primary" or "replica" +``` + +### Consul Setup + +**Attach Consul:** +```bash +fly consul attach --app [YOUR_APP_NAME] +``` + +**Consul manages:** +- Which instance is primary +- Automatic failover +- Data replication + +### GitHub Actions CI/CD + +**Basic workflow:** +```yaml +# .github/workflows/deploy.yml +name: Deploy + +on: + push: + branches: [main, dev] + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} +``` + +**Complete configuration:** +- Deploy to `production` from `main` branch +- Deploy to `staging` from `dev` branch +- Tests before deploy (optional) + +### Deployable Commits + +Following Epic Web principles: + +**Deployable commits** - Every commit to the main branch should be deployable. This means: +- The code should be in a working state +- Tests should pass +- The application should build successfully +- No "WIP" or "TODO" commits that break the build + +**Example - Deployable commit workflow:** +```bash +# ✅ Good - Each commit is deployable +git commit -m "Add user profile page" +# This commit is complete, tested, and deployable + +git commit -m "Fix login redirect bug" +# This commit fixes a bug and is deployable + +# ❌ Avoid - Non-deployable commits +git commit -m "WIP: working on feature" +# This commit might not work, not deployable + +git commit -m "Add feature (tests failing)" +# This commit breaks the build, not deployable +``` + +**Benefits:** +- Easy rollback - any commit can be deployed +- Continuous deployment - deploy any time +- Clear history - each commit represents a working state +- Faster recovery - can deploy any previous commit + +### Small and Short Lived Merge Requests + +Following Epic Web principles: + +**Small and short lived merge requests** - Keep PRs small and merge them quickly. Large PRs are hard to review, risky to merge, and slow down the team. + +**Guidelines:** +- **Small PRs** - Focus on one feature or fix per PR +- **Short-lived** - Merge within a day or two, not weeks +- **Reviewable** - PRs should be reviewable in 30 minutes or less +- **Independent** - Each PR should be independently deployable + +**Example - Small, focused PR:** +```bash +# ✅ Good - Small, focused PR +# PR: "Add email validation to signup form" +# - Only changes signup validation +# - Includes tests +# - Can be reviewed quickly +# - Can be merged and deployed independently + +# ❌ Avoid - Large, complex PR +# PR: "Refactor authentication system and add 2FA and OAuth" +# - Too many changes at once +# - Hard to review +# - Risky to merge +# - Takes days to review +``` + +**Benefits:** +- Faster reviews - easier to understand and review +- Lower risk - smaller changes are less risky +- Faster feedback - get feedback sooner +- Easier rollback - smaller changes are easier to revert +- Better collaboration - team can work in parallel on different small PRs + +**When PRs get too large:** +- Split into multiple smaller PRs +- Use feature flags to merge incrementally +- Break down into logical pieces + +### Tigris Object Storage + +**Create storage:** +```bash +fly storage create --app [YOUR_APP_NAME] +``` + +**This creates:** +- Tigris bucket +- Automatic environment variables: + - `TIGRIS_ENDPOINT` + - `TIGRIS_ACCESS_KEY_ID` + - `TIGRIS_SECRET_ACCESS_KEY` + - `TIGRIS_BUCKET_NAME` + +### Database Migrations + +**Automatic migrations:** +Migrations are automatically applied on deploy via `litefs.yml`: + +```yaml +exec: + - cmd: npx prisma migrate deploy + if-candidate: true +``` + +**Note:** Only the primary instance runs migrations (`if-candidate: true`). + +### Database Backups + +**Create backup:** +```bash +# SSH to instance +fly ssh console --app [YOUR_APP_NAME] + +# Create backup +mkdir /backups +litefs export -name sqlite.db /backups/backup-$(date +%Y-%m-%d).db +exit + +# Download backup +fly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME] +``` + +**Restore backup:** +```bash +# Upload backup +fly ssh sftp shell --app [YOUR_APP_NAME] +put backup-2024-01-01.db +# Ctrl+C to exit + +# SSH and restore +fly ssh console --app [YOUR_APP_NAME] +litefs import -name sqlite.db /backup-2024-01-01.db +exit +``` + +### Deployment Local + +**Deploy con Fly CLI:** +```bash +fly deploy +``` + +**Deploy con Docker:** +```bash +# Build +docker build -t epic-stack . -f other/Dockerfile \ + --build-arg COMMIT_SHA=$(git rev-parse --short HEAD) + +# Run +docker run -d \ + -p 8081:8081 \ + -e SESSION_SECRET='secret' \ + -e HONEYPOT_SECRET='secret' \ + -e FLY='false' \ + -v ~/litefs:/litefs \ + epic-stack +``` + +### Zero-Downtime Deploys + +**Strategy:** +- Deploy to multiple instances +- Automatic blue-green deployment +- Healthchecks verify app is ready +- Auto-rollback if healthcheck fails + +**Configuration:** +```toml +[experimental] +auto_rollback = true +``` + +### Monitoring + +**View logs:** +```bash +fly logs --app [YOUR_APP_NAME] +``` + +**View metrics:** +```bash +fly dashboard --app [YOUR_APP_NAME] +# Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring +``` + +**Sentry (opcional):** +```bash +fly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME] +``` + +## Common examples + +### Example 1: Complete initial setup + +```bash +# 1. Create apps +fly apps create my-app +fly apps create my-app-staging + +# 2. Configure secrets +fly secrets set \ + SESSION_SECRET=$(openssl rand -hex 32) \ + HONEYPOT_SECRET=$(openssl rand -hex 32) \ + --app my-app + +fly secrets set \ + SESSION_SECRET=$(openssl rand -hex 32) \ + HONEYPOT_SECRET=$(openssl rand -hex 32) \ + ALLOW_INDEXING=false \ + --app my-app-staging + +# 3. Create volumes +fly volumes create data --region sjc --size 1 --app my-app +fly volumes create data --region sjc --size 1 --app my-app-staging + +# 4. Attach Consul +fly consul attach --app my-app +fly consul attach --app my-app-staging + +# 5. Create storage +fly storage create --app my-app +fly storage create --app my-app-staging + +# 6. Deploy +fly deploy --app my-app +``` + +### Example 2: Multi-region setup + +```bash +# First region (primary) - 2 instances +fly scale count 2 --region sjc --app my-app + +# Secondary regions - 1 instance each +fly scale count 1 --region ams --app my-app +fly scale count 1 --region syd --app my-app + +# Verify +fly status --app my-app +``` + +### Example 3: GitHub Actions workflow + +```yaml +# .github/workflows/deploy.yml +name: Deploy + +on: + push: + branches: [main, dev] + +jobs: + deploy-production: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only --app my-app + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + deploy-staging: + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only --app my-app-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} +``` + +### Example 4: Deploy with migrations + +```bash +# Create migration +npx prisma migrate dev --name add_field + +# Commit and push +git add . +git commit -m "Add field" +git push origin main + +# GitHub Actions automatically runs: +# 1. Build +# 2. Deploy +# 3. litefs.yml runs: npx prisma migrate deploy (only on primary) +``` + +## Common mistakes to avoid + +- ❌ **Non-deployable commits**: Every commit to main should be deployable - no WIP or broken commits +- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are hard to review and risky +- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml` matches the volume region +- ❌ **Secrets not configured**: Configure all secrets before first deploy +- ❌ **Volume not created**: Create the `data` volume before deploy +- ❌ **Consul not attached**: Attach Consul before first deploy +- ❌ **Migrations on replicas**: Only the primary instance should run migrations +- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime deploys +- ❌ **Deploy breaking changes without strategy**: Use "widen then narrow" for migrations +- ❌ **Secrets in code**: Never commit secrets, use `fly secrets` +- ❌ **Not making backups**: Make regular database backups +- ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets + +## References + +- [Epic Stack Deployment Docs](../epic-stack/docs/deployment.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [Fly.io Documentation](https://fly.io/docs) +- [LiteFS Documentation](https://fly.io/docs/litefs/) +- [Fly.io CLI Reference](https://fly.io/docs/flyctl/) +- `fly.toml` - Fly.io configuration +- `other/litefs.yml` - LiteFS configuration +- `other/Dockerfile` - Deployment Dockerfile +- `.github/workflows/deploy.yml` - CI/CD workflow + +### Preview Deployments (Inspired by Vercel Deploy Claimable) + +Epic Stack can implement preview deployments similar to Vercel's deploy claimable pattern. + +**✅ Good - Preview deployments for pull requests:** +```yaml +# .github/workflows/preview-deploy.yml +name: Preview Deploy + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - name: Deploy preview + run: | + # Create or reuse preview app + PREVIEW_APP="my-app-pr-${{ github.event.pull_request.number }}" + flyctl apps list | grep "$PREVIEW_APP" || flyctl apps create "$PREVIEW_APP" + + # Deploy to preview app + flyctl deploy --app "$PREVIEW_APP" --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + - name: Comment preview URL + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `🚀 Preview deployment: https://$PREVIEW_APP.fly.dev` + }) +``` + +**✅ Good - Auto-cleanup preview deployments:** +```yaml +# .github/workflows/cleanup-preview.yml +name: Cleanup Preview + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: superfly/flyctl-actions/setup-flyctl@master + - name: Destroy preview app + run: | + PREVIEW_APP="my-app-pr-${{ github.event.pull_request.number }}" + flyctl apps destroy "$PREVIEW_APP" --yes + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} +``` + +### Environment Detection + +**✅ Good - Detect deployment environment:** +```typescript +// app/utils/env.server.ts +export function getDeploymentEnv(): 'production' | 'staging' | 'preview' | 'development' { + if (process.env.NODE_ENV === 'development') { + return 'development' + } + + // Preview deployments + if (process.env.FLY_APP_NAME?.includes('pr-')) { + return 'preview' + } + + // Staging environment + if (process.env.FLY_APP_NAME?.includes('staging')) { + return 'staging' + } + + // Production + return 'production' +} +``` + +**✅ Good - Environment-specific configuration:** +```typescript +const env = getDeploymentEnv() + +export const config = { + production: env === 'production', + staging: env === 'staging', + preview: env === 'preview', + development: env === 'development', + + // Preview deployments might have limited features + features: { + analytics: env === 'production', + sentry: env !== 'development', + indexing: env === 'production', + }, +} +``` + +### Build Artifact Exclusion + +**✅ Good - Optimize Docker builds:** +```dockerfile +# other/Dockerfile +# Multi-stage build for smaller image size +FROM node:20-alpine AS base +WORKDIR /app + +# Install dependencies +FROM base AS deps +COPY package*.json ./ +RUN npm ci --only=production + +# Build application +FROM base AS builder +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production image +FROM base AS runner +ENV NODE_ENV=production + +# Copy only what's needed +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/build ./build +COPY --from=builder /app/public ./public +COPY --from=builder /app/server ./server +COPY --from=builder /app/other ./other +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package.json ./ + +# Exclude unnecessary files +# node_modules/.cache, .git, etc. are already excluded via .dockerignore + +CMD ["npm", "start"] +``` + +**✅ Good - Docker ignore file:** +```dockerignore +# .dockerignore (in other/) +node_modules +.git +.env +.env.* +!.env.example +*.log +.DS_Store +coverage +.vscode +.idea +*.swp +*.swo +*~ +.cache +dist +build +``` + +### Deployment Status and Monitoring + +**✅ Good - Deployment status tracking:** +```typescript +// app/routes/admin/deployment-status.tsx +export async function loader({ request }: Route.LoaderArgs) { + const deploymentInfo = { + appName: process.env.FLY_APP_NAME, + region: process.env.FLY_REGION, + environment: getDeploymentEnv(), + commitSha: process.env.COMMIT_SHA, + deployedAt: process.env.DEPLOYED_AT, + } + + return { deploymentInfo } +} +``` + +### Rollback Strategies + +**✅ Good - Quick rollback with Fly.io:** +```bash +# List recent releases +fly releases list --app my-app + +# Rollback to previous release +fly releases rollback --app my-app +``` + +**✅ Good - Automated rollback on failure:** +```toml +# fly.toml +[experimental] + auto_rollback = true + min_machines_running = 1 +``` diff --git a/docs/skills/epic-forms/SKILL.md b/docs/skills/epic-forms/SKILL.md new file mode 100644 index 000000000..fa9c3d1ab --- /dev/null +++ b/docs/skills/epic-forms/SKILL.md @@ -0,0 +1,737 @@ +--- +name: epic-forms +description: Guide on forms with Conform and validation with Zod for Epic Stack +categories: + - forms + - conform + - zod + - validation +--- + +# Epic Stack: Forms + +## When to use this skill + +Use this skill when you need to: +- Create forms in an Epic Stack application +- Implement form validation with Zod +- Work with Conform for progressively enhanced forms +- Handle file uploads +- Implement honeypot fields for spam protection +- Handle form errors +- Work with complex forms (fieldsets, arrays) + +## Patterns and conventions + +### Validation Philosophy + +Following Epic Web principles: + +**Explicit is better than implicit** - Make validation rules clear and explicit using Zod schemas. Every validation rule should be visible in the schema, not hidden in business logic. Error messages should be specific and helpful, telling users exactly what went wrong and how to fix it. + +**Design to fail fast and early** - Validate input as early as possible, ideally on the client side before submission, and always on the server side. Return clear, specific error messages immediately so users can fix issues without frustration. + +**Example - Explicit validation:** +```typescript +// ✅ Good - Explicit validation with clear error messages +const SignupSchema = z.object({ + email: z + .string({ required_error: 'Email is required' }) + .email({ message: 'Please enter a valid email address' }) + .min(3, { message: 'Email must be at least 3 characters' }) + .max(100, { message: 'Email must be less than 100 characters' }) + .transform((val) => val.toLowerCase().trim()), + password: z + .string({ required_error: 'Password is required' }) + .min(6, { message: 'Password must be at least 6 characters' }) + .max(72, { message: 'Password must be less than 72 characters' }), +}) + +// ❌ Avoid - Implicit validation +const SignupSchema = z.object({ + email: z.string().email(), // No clear error messages + password: z.string().min(6), // Generic error +}) +``` + +**Example - Fail fast validation:** +```typescript +// ✅ Good - Validate early and return specific errors immediately +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + // Validate immediately - fail fast + const submission = await parseWithZod(formData, { + schema: SignupSchema, + }) + + // Return errors immediately if validation fails + if (submission.status !== 'success') { + return data( + { result: submission.reply() }, + { status: 400 }, // Clear error status + ) + } + + // Only proceed if validation passed + const { email, password } = submission.value + // ... continue with signup +} + +// ❌ Avoid - Delayed or unclear validation +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + const email = formData.get('email') + const password = formData.get('password') + + // Validation scattered throughout the function + if (!email) { + // Generic error, not specific + return json({ error: 'Invalid' }, { status: 400 }) + } + // ... more scattered validation +} +``` + +### Basic setup with Conform + +Epic Stack uses [Conform](https://conform.guide/) to handle forms with progressive enhancement. + +**Basic setup:** +```typescript +import { getFormProps, useForm } from '@conform-to/react' +import { getZodConstraint, parseWithZod } from '@conform-to/zod' +import { z } from 'zod' +import { Form } from 'react-router' + +const SignupSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}) + +export default function SignupRoute({ actionData }: Route.ComponentProps) { + const [form, fields] = useForm({ + id: 'signup-form', + constraint: getZodConstraint(SignupSchema), + lastResult: actionData?.result, + onValidate({ formData }) { + return parseWithZod(formData, { schema: SignupSchema }) + }, + shouldRevalidate: 'onBlur', + }) + + return ( +
+ {/* Form fields */} +
+ ) +} +``` + +### Integration with Zod + +Conform integrates seamlessly with Zod for validation. + +**Define schema:** +```typescript +import { z } from 'zod' + +const SignupSchema = z.object({ + email: z.string().email('Invalid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string(), +}).superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + path: ['confirmPassword'], + code: 'custom', + message: 'Passwords must match', + }) + } +}) +``` + +**Validation in action (fail fast):** +```typescript +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + // Validate immediately - explicit and fail fast + const submission = await parseWithZod(formData, { + schema: SignupSchema, + }) + + // Return explicit errors immediately if validation fails + if (submission.status !== 'success') { + return data( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) + } + + // Only proceed if validation passed - submission.value is type-safe + const { email, password } = submission.value + // ... process with validated data +} +``` + +### Async validation + +For validations that require querying the database: + +```typescript +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + const submission = await parseWithZod(formData, { + schema: SignupSchema.superRefine(async (data, ctx) => { + const existingUser = await prisma.user.findUnique({ + where: { email: data.email }, + select: { id: true }, + }) + if (existingUser) { + ctx.addIssue({ + path: ['email'], + code: z.ZodIssueCode.custom, + message: 'A user already exists with this email', + }) + } + }), + async: true, // Important: enable async validation + }) + + if (submission.status !== 'success') { + return data( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) + } + + // ... +} +``` + +### Field Components + +Epic Stack provides pre-built field components: + +**Basic Field:** +```typescript +import { Field, ErrorList } from '#app/components/forms.tsx' +import { getInputProps } from '@conform-to/react' + + +``` + +**TextareaField:** +```typescript +import { TextareaField } from '#app/components/forms.tsx' +import { getTextareaProps } from '@conform-to/react' + + +``` + +**CheckboxField:** +```typescript +import { CheckboxField } from '#app/components/forms.tsx' +import { getInputProps } from '@conform-to/react' + + +``` + +**OTPField:** +```typescript +import { OTPField } from '#app/components/forms.tsx' + + +``` + +### Error Handling + +**Display field errors:** +```typescript + +``` + +**Display form errors:** +```typescript +import { ErrorList } from '#app/components/forms.tsx' + + +``` + +**Error structure:** +- `fields.fieldName.errors` - Errors for a specific field +- `form.errors` - General form errors (like `formErrors`) + +### Honeypot Fields + +Epic Stack includes spam protection with honeypot fields. + +**In the form:** +```typescript +import { HoneypotInputs } from 'remix-utils/honeypot/react' + +
+ {/* Always include in public forms */} + {/* Rest of fields */} + +``` + +**In the action:** +```typescript +import { checkHoneypot } from '#app/utils/honeypot.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + await checkHoneypot(formData) // Throws error if spam + + // ... rest of code +} +``` + +### File Uploads + +For forms with file uploads, use `encType="multipart/form-data"`. + +**Schema for files:** +```typescript +const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB + +const ImageFieldsetSchema = z.object({ + id: z.string().optional(), + file: z + .instanceof(File) + .optional() + .refine((file) => { + return !file || file.size <= MAX_UPLOAD_SIZE + }, 'File must be less than 3MB'), + altText: z.string().optional(), +}) + +const NoteEditorSchema = z.object({ + title: z.string().min(1).max(100), + content: z.string().min(1).max(10000), + images: z.array(ImageFieldsetSchema).max(5).optional(), +}) +``` + +**Form with file upload:** +```typescript +
+ {/* Fields */} +
+``` + +**Process files in action:** +```typescript +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + const submission = await parseWithZod(formData, { + schema: NoteEditorSchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + const { images } = submission.value + + // Process files + for (const image of images ?? []) { + if (image.file) { + // Upload file, save to storage, etc. + } + } + + // ... +} +``` + +### Fieldsets y Arrays + +For forms with repetitive fields (like multiple images): + +**Schema:** +```typescript +const ImageFieldsetSchema = z.object({ + id: z.string().optional(), + file: z.instanceof(File).optional(), + altText: z.string().optional(), +}) + +const FormSchema = z.object({ + images: z.array(ImageFieldsetSchema).max(5).optional(), +}) +``` + +**In the component:** +```typescript +import { FormProvider, getFieldsetProps } from '@conform-to/react' + +const [form, fields] = useForm({ + // ... + defaultValue: { + images: note?.images ?? [{}], + }, +}) + +const imageList = fields.images.getFieldList() + +return ( + +
+ {imageList.map((image, index) => { + const imageFieldset = getFieldsetProps(fields.images[index]) + return ( +
+ + +
+ ) + })} + + +
+
+) +``` + +### StatusButton + +Use `StatusButton` to display submission status: + +```typescript +import { StatusButton } from '#app/components/ui/status-button.tsx' +import { useIsPending } from '#app/utils/misc.tsx' + +const isPending = useIsPending() + + + Submit + +``` + +### Progressive Enhancement + +Forms work without JavaScript thanks to Conform: + +- Native HTML5 validation +- Server-side validation +- Enhancements with JavaScript when available + +**You don't need to do anything special** - Conform handles this automatically. + +### Reusable Schema Validation + +Create reusable schemas in `app/utils/user-validation.ts`: + +```typescript +// app/utils/user-validation.ts +import { z } from 'zod' + +export const EmailSchema = z + .string({ required_error: 'Email is required' }) + .email({ message: 'Email is invalid' }) + .min(3, { message: 'Email is too short' }) + .max(100, { message: 'Email is too long' }) + .transform((value) => value.toLowerCase()) + +export const PasswordSchema = z + .string({ required_error: 'Password is required' }) + .min(6, { message: 'Password is too short' }) + .refine((val) => new TextEncoder().encode(val).length <= 72, { + message: 'Password is too long', + }) + +export const PasswordAndConfirmPasswordSchema = z + .object({ password: PasswordSchema, confirmPassword: PasswordSchema }) + .superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + path: ['confirmPassword'], + code: 'custom', + message: 'The passwords must match', + }) + } + }) +``` + +**Use in forms:** +```typescript +import { EmailSchema, PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts' + +const SignupSchema = z + .object({ + email: EmailSchema, + username: UsernameSchema, + }) + .and(PasswordAndConfirmPasswordSchema) +``` + +## Common examples + +### Example 1: Simple login form + +```typescript +// app/routes/_auth/login.tsx +import { getFormProps, getInputProps, useForm } from '@conform-to/react' +import { getZodConstraint, parseWithZod } from '@conform-to/zod' +import { z } from 'zod' +import { Field, ErrorList } from '#app/components/forms.tsx' +import { StatusButton } from '#app/components/ui/status-button.tsx' + +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}) + +export default function LoginRoute({ actionData }: Route.ComponentProps) { + const isPending = useIsPending() + + const [form, fields] = useForm({ + id: 'login-form', + constraint: getZodConstraint(LoginSchema), + lastResult: actionData?.result, + onValidate({ formData }) { + return parseWithZod(formData, { schema: LoginSchema }) + }, + shouldRevalidate: 'onBlur', + }) + + return ( +
+ + + + + Login + + + ) +} +``` + +### Example 2: Form with async validation + +```typescript +// app/routes/_auth/signup.tsx +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + await checkHoneypot(formData) + + const submission = await parseWithZod(formData, { + schema: SignupSchema.superRefine(async (data, ctx) => { + const existingUser = await prisma.user.findUnique({ + where: { email: data.email }, + select: { id: true }, + }) + if (existingUser) { + ctx.addIssue({ + path: ['email'], + code: z.ZodIssueCode.custom, + message: 'A user already exists with this email', + }) + } + }), + async: true, + }) + + if (submission.status !== 'success') { + return data( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) + } + + // Procesar signup... +} +``` + +### Example 3: Form with file upload + +```typescript +// app/routes/users/$username/notes/new.tsx +const NoteEditorSchema = z.object({ + title: z.string().min(1).max(100), + content: z.string().min(1).max(10000), + images: z.array(ImageFieldsetSchema).max(5).optional(), +}) + +export default function NewNoteRoute({ actionData }: Route.ComponentProps) { + const [form, fields] = useForm({ + id: 'note-editor', + constraint: getZodConstraint(NoteEditorSchema), + lastResult: actionData?.result, + onValidate({ formData }) { + return parseWithZod(formData, { schema: NoteEditorSchema }) + }, + defaultValue: { + images: [{}], + }, + shouldRevalidate: 'onBlur', + }) + + const imageList = fields.images.getFieldList() + + return ( + +
+ + + + {imageList.map((image, index) => { + const imageFieldset = getFieldsetProps(fields.images[index]) + return ( +
+ + +
+ ) + })} + + + + + Create Note + +
+ ) +} +``` + +## Common mistakes to avoid + +- ❌ **Implicit validation**: Always use explicit Zod schemas with clear error messages, not hidden validation logic +- ❌ **Delayed validation**: Validate input immediately and fail fast - don't wait until the end of the function +- ❌ **Generic error messages**: Use specific, helpful error messages that tell users exactly what's wrong +- ❌ **Forgetting `async: true` in async validation**: Always include `async: true` when using `superRefine` with async +- ❌ **Not including `HoneypotInputs` in public forms**: Always include honeypot in forms accessible without authentication +- ❌ **Forgetting `encType="multipart/form-data"`**: Required for file uploads +- ❌ **Not validating file sizes**: Always validate size in the schema +- ❌ **Not using `getZodConstraint`**: Required for native HTML5 validation +- ❌ **Forgetting `lastResult` in `useForm`**: Required to display server errors +- ❌ **Not using `shouldRevalidate: 'onBlur'`**: Improves UX by validating on field blur +- ❌ **Not using pre-built field components**: `Field`, `TextareaField`, etc. already handle accessibility and errors + +## References + +- [Conform Documentation](https://conform.guide/) +- [Zod Documentation](https://zod.dev/) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- `app/components/forms.tsx` - Field components +- `app/routes/_auth/signup.tsx` - Complete signup example +- `app/routes/_auth/onboarding/index.tsx` - Complex form example +- `app/routes/users/$username/notes/+shared/note-editor.tsx` - File uploads example +- `app/utils/user-validation.ts` - Reusable schemas +- `docs/decisions/033-honeypot.md` - Honeypot documentation diff --git a/docs/skills/epic-permissions/SKILL.md b/docs/skills/epic-permissions/SKILL.md new file mode 100644 index 000000000..c6d1019c5 --- /dev/null +++ b/docs/skills/epic-permissions/SKILL.md @@ -0,0 +1,537 @@ +--- +name: epic-permissions +description: Guide on RBAC system and permissions for Epic Stack +categories: + - permissions + - rbac + - access-control +--- + +# Epic Stack: Permissions + +## When to use this skill + +Use this skill when you need to: +- Implement role-based access control (RBAC) +- Validate permissions on server-side or client-side +- Create new permissions or roles +- Restrict access to routes or actions +- Implement granular permissions (`own` vs `any`) + +## Patterns and conventions + +### Permissions Philosophy + +Following Epic Web principles: + +**Explicit is better than implicit** - Always explicitly check permissions. Don't assume a user has access based on implicit rules or hidden logic. Every permission check should be visible and clear in the code. + +**Example - Explicit permission checks:** +```typescript +// ✅ Good - Explicit permission check +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + + // Explicitly check permission - clear and visible + await requireUserWithPermission(request, 'delete:note:own') + + // Permission check is explicit and obvious + await prisma.note.delete({ where: { id: noteId } }) +} + +// ❌ Avoid - Implicit permission check +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + const note = await prisma.note.findUnique({ where: { id: noteId } }) + + // Implicit check - not clear what permission is being checked + if (note.ownerId !== userId) { + throw new Response('Forbidden', { status: 403 }) + } + // What permission does this represent? Not explicit +} +``` + +**Example - Explicit permission strings:** +```typescript +// ✅ Good - Explicit permission string +const permission: PermissionString = 'delete:note:own' +// Clear: action (delete), entity (note), access (own) + +await requireUserWithPermission(request, permission) + +// ❌ Avoid - Implicit or unclear permissions +const canDelete = checkUserCanDelete(user, note) +// What permission is this checking? Not explicit +``` + +### RBAC Model + +Epic Stack uses an RBAC (Role-Based Access Control) model where: +- **Users** have **Roles** +- **Roles** have **Permissions** +- A user's permissions are the union of all permissions from their roles + +### Permission Structure + +Permissions follow the format: `action:entity:access` + +**Components:** +- `action`: The allowed action (`create`, `read`, `update`, `delete`) +- `entity`: The entity being acted upon (`user`, `note`, etc.) +- `access`: The access level (`own`, `any`, `own,any`) + +**Examples:** +- `create:note:own` - Can create own notes +- `read:note:any` - Can read any note +- `delete:user:any` - Can delete any user (admin) +- `update:note:own` - Can update only own notes + +### Prisma Schema + +**Models:** +```prisma +model Permission { + id String @id @default(cuid()) + action String // e.g. create, read, update, delete + entity String // e.g. note, user, etc. + access String // e.g. own or any + description String @default("") + + roles Role[] + + @@unique([action, entity, access]) +} + +model Role { + id String @id @default(cuid()) + name String @unique + description String @default("") + + users User[] + permissions Permission[] +} + +model User { + id String @id @default(cuid()) + // ... + roles Role[] +} +``` + +### Validate Permissions Server-Side + +**Require specific permission:** +```typescript +import { requireUserWithPermission } from '#app/utils/permissions.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserWithPermission( + request, + 'delete:note:own', // Throws 403 error if doesn't have permission + ) + + // User has the permission, continue... +} +``` + +**Require specific role:** +```typescript +import { requireUserWithRole } from '#app/utils/permissions.server.ts' + +export async function loader({ request }: Route.LoaderArgs) { + const userId = await requireUserWithRole(request, 'admin') + + // User has admin role, continue... +} +``` + +**Conditional permissions (own vs any) - explicit:** +```typescript +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + + // Explicitly determine ownership + const note = await prisma.note.findUnique({ + where: { id: noteId }, + select: { ownerId: true }, + }) + + const isOwner = note.ownerId === userId + + // Explicitly check the appropriate permission based on ownership + await requireUserWithPermission( + request, + isOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string + ) + + // Permission check is explicit and clear + // Proceed with deletion... +} +``` + +### Validate Permissions Client-Side + +**Check if user has permission:** +```typescript +import { userHasPermission, useOptionalUser } from '#app/utils/user.ts' + +export default function NoteRoute({ loaderData }: Route.ComponentProps) { + const user = useOptionalUser() + const isOwner = user?.id === loaderData.note.ownerId + + const canDelete = userHasPermission( + user, + isOwner ? 'delete:note:own' : 'delete:note:any', + ) + + return ( +
+ {canDelete && ( + + )} +
+ ) +} +``` + +**Check if user has role:** +```typescript +import { userHasRole } from '#app/utils/user.ts' + +export default function AdminRoute() { + const user = useOptionalUser() + const isAdmin = userHasRole(user, 'admin') + + if (!isAdmin) { + return
Access Denied
+ } + + return
Admin Panel
+} +``` + +### Create New Permissions + +**En Prisma Studio o seed:** +```typescript +// prisma/seed.ts +await prisma.permission.create({ + data: { + action: 'create', + entity: 'post', + access: 'own', + description: 'Can create their own posts', + roles: { + connect: { name: 'user' }, + }, + }, +}) +``` + +**Permiso con múltiples niveles de acceso:** +```typescript +await prisma.permission.createMany({ + data: [ + { + action: 'read', + entity: 'post', + access: 'own', + description: 'Can read own posts', + }, + { + action: 'read', + entity: 'post', + access: 'any', + description: 'Can read any post', + }, + ], +}) +``` + +### Assign Roles to Users + +**When creating user:** +```typescript +const user = await prisma.user.create({ + data: { + email, + username, + roles: { + connect: { name: 'user' }, // Assign 'user' role + }, + }, +}) +``` + +**Assign multiple roles:** +```typescript +await prisma.user.update({ + where: { id: userId }, + data: { + roles: { + connect: [ + { name: 'user' }, + { name: 'moderator' }, + ], + }, + }, +}) +``` + +### Permissions and Roles Seed + +**Seed example:** +```typescript +// prisma/seed.ts + +// Create permissions +const permissions = await Promise.all([ + // User permissions + prisma.permission.create({ + data: { + action: 'create', + entity: 'note', + access: 'own', + description: 'Can create own notes', + }, + }), + prisma.permission.create({ + data: { + action: 'read', + entity: 'note', + access: 'own', + description: 'Can read own notes', + }, + }), + prisma.permission.create({ + data: { + action: 'update', + entity: 'note', + access: 'own', + description: 'Can update own notes', + }, + }), + prisma.permission.create({ + data: { + action: 'delete', + entity: 'note', + access: 'own', + description: 'Can delete own notes', + }, + }), + // Admin permissions + prisma.permission.create({ + data: { + action: 'delete', + entity: 'user', + access: 'any', + description: 'Can delete any user', + }, + }), +]) + +// Create roles +const userRole = await prisma.role.create({ + data: { + name: 'user', + description: 'Standard user', + permissions: { + connect: permissions.slice(0, 4).map(p => ({ id: p.id })), + }, + }, +}) + +const adminRole = await prisma.role.create({ + data: { + name: 'admin', + description: 'Administrator', + permissions: { + connect: permissions.map(p => ({ id: p.id })), + }, + }, +}) +``` + +### Permission Type + +**Type-safe permission strings:** +```typescript +import { type PermissionString } from '#app/utils/user.ts' + +// Tipo: 'create:note:own' | 'read:note:own' | etc. +const permission: PermissionString = 'delete:note:own' +``` + +**Parsear permission string:** +```typescript +import { parsePermissionString } from '#app/utils/user.ts' + +const { action, entity, access } = parsePermissionString('delete:note:own') +// action: 'delete' +// entity: 'note' +// access: ['own'] +``` + +## Common examples + +### Example 1: Proteger action con permiso + +```typescript +// app/routes/users/$username/notes/$noteId.tsx +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + const formData = await request.formData() + const { noteId } = Object.fromEntries(formData) + + const note = await prisma.note.findFirst({ + select: { id: true, ownerId: true, owner: { select: { username: true } } }, + where: { id: noteId }, + }) + + if (!note) { + throw new Response('Not found', { status: 404 }) + } + + const isOwner = note.ownerId === userId + + // Validate permiso según si es propietario o no + await requireUserWithPermission( + request, + isOwner ? 'delete:note:own' : 'delete:note:any', + ) + + await prisma.note.delete({ where: { id: note.id } }) + + return redirect(`/users/${note.owner.username}/notes`) +} +``` + +### Example 2: Mostrar UI condicional basada en permisos + +```typescript +export default function NoteRoute({ loaderData }: Route.ComponentProps) { + const user = useOptionalUser() + const isOwner = user?.id === loaderData.note.ownerId + + const canDelete = userHasPermission( + user, + isOwner ? 'delete:note:own' : 'delete:note:any', + ) + const canEdit = userHasPermission( + user, + isOwner ? 'update:note:own' : 'update:note:any', + ) + + return ( +
+

{loaderData.note.title}

+

{loaderData.note.content}

+ + {(canEdit || canDelete) && ( +
+ {canEdit && ( + + + + )} + {canDelete && ( + + )} +
+ )} +
+ ) +} +``` + +### Example 3: Ruta solo para admin + +```typescript +// app/routes/admin/users.tsx +export async function loader({ request }: Route.LoaderArgs) { + await requireUserWithRole(request, 'admin') + + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + username: true, + }, + }) + + return { users } +} + +export default function AdminUsersRoute({ loaderData }: Route.ComponentProps) { + return ( +
+

All Users

+ {loaderData.users.map(user => ( +
{user.username}
+ ))} +
+ ) +} +``` + +### Example 4: Create new permission and assign it + +```typescript +// Migración o seed +async function setupPostPermissions() { + // Create post permissions + const createOwn = await prisma.permission.create({ + data: { + action: 'create', + entity: 'post', + access: 'own', + description: 'Can create own posts', + }, + }) + + const readAny = await prisma.permission.create({ + data: { + action: 'read', + entity: 'post', + access: 'any', + description: 'Can read any post', + }, + }) + + // Assign to user role + await prisma.role.update({ + where: { name: 'user' }, + data: { + permissions: { + connect: [ + { id: createOwn.id }, + { id: readAny.id }, + ], + }, + }, + }) +} +``` + +## Common mistakes to avoid + +- ❌ **Implicit permission checks**: Always explicitly check permissions - make permission requirements visible in code +- ❌ **Not validating permissions on server-side**: Always validate permissions in action/loader, never trust client-side only +- ❌ **Forgetting to verify `own` vs `any`**: Explicitly determine if user is owner before validating permission +- ❌ **Not using correct helpers**: Use `requireUserWithPermission` for server-side and `userHasPermission` for client-side - explicit helpers +- ❌ **Not creating unique permissions**: Use `@@unique([action, entity, access])` in schema - explicit permission structure +- ❌ **Assuming permissions instead of verifying**: Always verify explicitly, even if you think user has the permission +- ❌ **Not handling 403 errors**: Helpers throw errors that must be handled by ErrorBoundary +- ❌ **Not using types**: Use `PermissionString` type for type-safety - explicit types +- ❌ **Hidden permission logic**: Don't hide permission checks in utility functions - make them explicit at the call site + +## References + +- [Epic Stack Permissions Docs](../epic-stack/docs/permissions.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [RBAC Explained](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac) +- `app/utils/permissions.server.ts` - Server-side permission utilities +- `app/utils/user.ts` - Client-side permission utilities +- `prisma/schema.prisma` - Permission and Role models +- `prisma/seed.ts` - Permission seed examples diff --git a/docs/skills/epic-react-patterns/SKILL.md b/docs/skills/epic-react-patterns/SKILL.md new file mode 100644 index 000000000..b1d182f95 --- /dev/null +++ b/docs/skills/epic-react-patterns/SKILL.md @@ -0,0 +1,643 @@ +--- +name: epic-react-patterns +description: Guide on React patterns, performance optimization, and code quality for Epic Stack +categories: + - react + - performance + - patterns + - optimization +--- + +# Epic Stack: React Patterns and Guidelines + +## When to use this skill + +Use this skill when you need to: +- Write efficient React components in Epic Stack applications +- Optimize performance and bundle size +- Follow React Router patterns and conventions +- Avoid common React anti-patterns +- Implement proper code splitting +- Optimize re-renders and data fetching +- Use React hooks correctly + +## Philosophy + +Following Epic Web principles: +- **Make it work, make it right, make it fast** - In that order. First make it functional, then refactor for clarity, then optimize for performance. +- **Pragmatism over purity** - Choose practical solutions that work well in your context rather than theoretically perfect ones. +- **Optimize for sustainable velocity** - Write code that's easy to maintain and extend, not just fast to write initially. +- **Do as little as possible** - Only add complexity when it provides real value. + +## Patterns and conventions + +### Data Fetching in React Router + +Epic Stack uses React Router loaders for data fetching, not `useEffect`. + +**✅ Good - Use loaders:** +```typescript +// app/routes/users/$username.tsx +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + }) + return { user } +} + +export default function UserRoute({ loaderData }: Route.ComponentProps) { + return
{loaderData.user.name}
+} +``` + +**❌ Avoid - Don't fetch in useEffect:** +```typescript +// ❌ Don't do this +export default function UserRoute({ params }: Route.ComponentProps) { + const [user, setUser] = useState(null) + + useEffect(() => { + fetch(`/api/users/${params.username}`) + .then(res => res.json()) + .then(setUser) + }, [params.username]) + + return user ?
{user.name}
:
Loading...
+} +``` + +### Avoid useEffect for Side Effects + +[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect) + +Instead of using `useEffect`, use event handlers, CSS, ref callbacks, or `useSyncExternalStore`. + +**✅ Good - Use event handlers:** +```typescript +function ProductPage({ product, addToCart }: Route.ComponentProps) { + function buyProduct() { + addToCart(product) + showNotification(`Added ${product.name} to cart!`) + } + + function handleBuyClick() { + buyProduct() + } + + function handleCheckoutClick() { + buyProduct() + navigate('/checkout') + } + + return ( +
+ + +
+ ) +} +``` + +**❌ Avoid - Side effects in useEffect:** +```typescript +// ❌ Don't do this +function ProductPage({ product, addToCart }: Route.ComponentProps) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name} to cart!`) + } + }, [product]) + + function handleBuyClick() { + addToCart(product) + } + + // ... +} +``` + +**✅ Appropriate use of useEffect:** +```typescript +// ✅ Good - Event listeners are appropriate +useEffect(() => { + const controller = new AbortController() + + window.addEventListener( + 'keydown', + (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + // handle escape key + }, + { signal: controller.signal }, + ) + + return () => { + controller.abort() + } +}, []) +``` + +### Code Splitting with React Router + +React Router automatically code-splits by route. Use dynamic imports for heavy components. + +**✅ Good - Dynamic imports:** +```typescript +// app/routes/admin/dashboard.tsx +import { lazy } from 'react' + +const AdminChart = lazy(() => import('#app/components/admin/chart.tsx')) + +export default function AdminDashboard() { + return ( + Loading chart...}> + + + ) +} +``` + +### Optimizing Re-renders + +**✅ Good - Memoize expensive computations:** +```typescript +import { useMemo } from 'react' + +function UserList({ users }: { users: User[] }) { + const sortedUsers = useMemo(() => { + return [...users].sort((a, b) => a.name.localeCompare(b.name)) + }, [users]) + + return ( +
    + {sortedUsers.map(user => ( +
  • {user.name}
  • + ))} +
+ ) +} +``` + +**✅ Good - Memoize callbacks:** +```typescript +import { useCallback } from 'react' + +function NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) { + const handleSave = useCallback((note: Note) => { + onSave(note) + }, [onSave]) + + return +} +``` + +**❌ Avoid - Unnecessary memoization:** +```typescript +// ❌ Don't memoize simple values +const count = useMemo(() => items.length, [items]) // Just use items.length directly + +// ❌ Don't memoize simple callbacks +const handleClick = useCallback(() => { + console.log('clicked') +}, []) // Just define the function normally if it doesn't need memoization +``` + +### Bundle Size Optimization + +**✅ Good - Import only what you need:** +```typescript +// ✅ Import specific functions +import { useSearchParams } from 'react-router' +import { parseWithZod } from '@conform-to/zod' +``` + +**❌ Avoid - Barrel imports:** +```typescript +// ❌ Don't import entire libraries if you only need one thing +import * as ReactRouter from 'react-router' +import * as Conform from '@conform-to/zod' +``` + +### Form Handling with Conform + +**✅ Good - Use Conform for forms:** +```typescript +import { useForm, getFormProps } from '@conform-to/react' +import { parseWithZod } from '@conform-to/zod' +import { Form } from 'react-router' + +const SignupSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}) + +export default function SignupRoute({ actionData }: Route.ComponentProps) { + const [form, fields] = useForm({ + id: 'signup-form', + lastResult: actionData?.result, + onValidate({ formData }) { + return parseWithZod(formData, { schema: SignupSchema }) + }, + }) + + return ( +
+ {/* form fields */} +
+ ) +} +``` + +### Component Composition + +**✅ Good - Compose components:** +```typescript +function UserProfile({ user }: { user: User }) { + return ( + + + + + + ) +} +``` + +**❌ Avoid - Large monolithic components:** +```typescript +// ❌ Don't put everything in one component +function UserProfile({ user }: { user: User }) { + return ( +
+
+ {user.name} +

{user.name}

+
+
+

{user.email}

+

{user.bio}

+
+
+ + +
+
+ ) +} +``` + +### Error Boundaries + +**✅ Good - Use error boundaries:** +```typescript +// app/routes/users/$username.tsx +export function ErrorBoundary() { + return ( + ( +

User "{params.username}" not found

+ ), + }} + /> + ) +} +``` + +### TypeScript Guidelines + +**✅ Good - Type props explicitly:** +```typescript +interface UserCardProps { + user: { + id: string + name: string + email: string + } + onEdit?: (userId: string) => void +} + +function UserCard({ user, onEdit }: UserCardProps) { + return ( +
+

{user.name}

+

{user.email}

+ {onEdit && } +
+ ) +} +``` + +**✅ Good - Use Route types:** +```typescript +import type { Route } from './+types/users.$username' + +export async function loader({ params }: Route.LoaderArgs) { + // params is type-safe! + const user = await prisma.user.findUnique({ + where: { username: params.username }, + }) + return { user } +} + +export default function UserRoute({ loaderData }: Route.ComponentProps) { + // loaderData is type-safe! + return
{loaderData.user.name}
+} +``` + +### Loading States + +**✅ Good - Use React Router's pending states:** +```typescript +import { useNavigation } from 'react-router' + +function NoteForm() { + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + + return ( +
+ +
+ ) +} +``` + +### Preventing Data Fetching Waterfalls + +React Router loaders can prevent waterfalls by fetching data in parallel. + +**❌ Avoid - Sequential data fetching (waterfall):** +```typescript +// ❌ Don't do this - creates a waterfall +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + }) + // Second fetch waits for first to complete + const notes = await prisma.note.findMany({ + where: { ownerId: user.id }, + }) + return { user, notes } +} +``` + +**✅ Good - Parallel data fetching:** +```typescript +// ✅ Fetch data in parallel +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + select: { id: true, username: true, name: true }, + }) + + // Fetch notes in parallel with user data + const [notes, stats] = await Promise.all([ + user ? prisma.note.findMany({ + where: { ownerId: user.id }, + select: { id: true, title: true, updatedAt: true }, + }) : Promise.resolve([]), + user ? prisma.note.count({ where: { ownerId: user.id } }) : Promise.resolve(0), + ]) + + return { user, notes, stats } +} +``` + +**✅ Good - Nested route parallel loading:** +```typescript +// Parent route loader +// app/routes/users/$username.tsx +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + select: { id: true, username: true, name: true }, + }) + return { user } +} + +// Child route loader runs in parallel +// app/routes/users/$username/notes.tsx +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + select: { id: true }, + }) + + if (!user) { + throw new Response('Not Found', { status: 404 }) + } + + const notes = await prisma.note.findMany({ + where: { ownerId: user.id }, + select: { id: true, title: true, updatedAt: true }, + }) + + return { notes } +} +``` + +### Server-Side Rendering (SSR) Performance + +React Router provides SSR by default. Optimize by: + +**✅ Good - Selective data fetching:** +```typescript +export async function loader({ request }: Route.LoaderArgs) { + // Only fetch what's needed for initial render + const searchParams = new URL(request.url).searchParams + const page = Number(searchParams.get('page') || '1') + + const [items, total] = await Promise.all([ + prisma.item.findMany({ + take: 20, + skip: (page - 1) * 20, + select: { id: true, title: true }, // Only needed fields + }), + prisma.item.count(), + ]) + + return { items, total, page } +} +``` + +**✅ Good - Use caching for expensive operations:** +```typescript +import { cachified, cache } from '#app/utils/cache.server.ts' + +export async function loader({ request }: Route.LoaderArgs) { + const timings: Timings = {} + + // Cache expensive database queries + const stats = await cachified({ + key: 'user-stats', + cache, + timings, + getFreshValue: async () => { + return await prisma.user.aggregate({ + _count: { id: true }, + }) + }, + ttl: 1000 * 60 * 5, // 5 minutes + }) + + return { stats } +} +``` + +### Rendering Performance + +**✅ Good - Use React.memo for expensive components:** +```typescript +import { memo } from 'react' + +const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) { + // Expensive rendering logic + return +}) + +// Only re-renders when data changes +export default function Dashboard({ chartData }: { chartData: Data[] }) { + return +} +``` + +**✅ Good - Optimize list rendering:** +```typescript +import { memo } from 'react' + +const UserItem = memo(function UserItem({ user }: { user: User }) { + return ( +
  • +

    {user.name}

    +

    {user.email}

    +
  • + ) +}, (prev, next) => prev.user.id === next.user.id) + +function UserList({ users }: { users: User[] }) { + return ( +
      + {users.map(user => ( + + ))} +
    + ) +} +``` + +**❌ Avoid - Creating new objects/arrays in render:** +```typescript +// ❌ Don't create new objects on every render +function UserProfile({ user }: { user: User }) { + return +} + +// ✅ Good - Compute in loader or memoize +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + select: { firstName: true, lastName: true }, + }) + + return { + user: { + ...user, + fullName: `${user.firstName} ${user.lastName}`, + }, + } +} +``` + +### Bundle Size Optimization Strategies + +**✅ Good - Route-based code splitting:** +React Router automatically splits code by route. Leverage this: + +```typescript +// Heavy dependencies are automatically split by route +// app/routes/admin/dashboard.tsx +import { Chart } from 'chart.js' // Only loaded on /admin/dashboard route +``` + +**✅ Good - Dynamic imports for heavy components:** +```typescript +import { lazy, Suspense } from 'react' + +const HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx')) + +export default function Route() { + return ( + Loading...}> + + + ) +} +``` + +**✅ Good - Tree-shakeable imports:** +```typescript +// ✅ Tree-shakeable - only imports what you use +import { format } from 'date-fns/format' +import { addDays } from 'date-fns/addDays' + +// ❌ Avoid - imports entire library +import * as dateFns from 'date-fns' +``` + +### React 18+ Features for Performance + +**✅ Good - Use transitions for non-urgent updates:** +```typescript +import { useTransition } from 'react' +import { useNavigation } from 'react-router' + +function SearchInput() { + const [isPending, startTransition] = useTransition() + const navigation = useNavigation() + + function handleSearch(query: string) { + startTransition(() => { + // Update search results (non-urgent) + navigation.navigate(`/search?q=${query}`) + }) + } + + return ( + handleSearch(e.target.value)} + placeholder={isPending ? 'Searching...' : 'Search'} + /> + ) +} +``` + +## Common mistakes to avoid + +- ❌ **Fetching data in useEffect**: Use React Router loaders instead +- ❌ **Overusing useEffect**: Prefer event handlers, CSS, or ref callbacks +- ❌ **Premature memoization**: Only memoize when there's a measurable performance benefit +- ❌ **Barrel imports**: Import only what you need +- ❌ **Ignoring TypeScript types**: Use Route types for type safety +- ❌ **Not handling loading states**: Use React Router's navigation states +- ❌ **Large monolithic components**: Break components into smaller, focused pieces +- ❌ **Not using error boundaries**: Always add error boundaries to routes +- ❌ **Client-side routing when server-side works**: Prefer server-side data fetching +- ❌ **Data fetching waterfalls**: Use `Promise.all()` to fetch data in parallel +- ❌ **Fetching unnecessary data**: Only fetch what's needed for the initial render +- ❌ **Creating new objects in render**: Compute derived data in loaders or memoize +- ❌ **Not using React.memo for expensive lists**: Memoize list items for better performance +- ❌ **Not leveraging route-based code splitting**: React Router splits by route automatically + +## References + +- [React Router Documentation](https://reactrouter.com/) +- [React Documentation - You Might Not Need useEffect](https://react.dev/learn/you-might-not-need-an-effect) +- [Conform Documentation](https://conform.guide/) +- [Epic Stack Docs](https://www.epicweb.dev/docs) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- `app/routes/` - Example routes using these patterns +- `.cursor/rules/avoid-use-effect.mdc` - Epic Stack rule for avoiding useEffect diff --git a/docs/skills/epic-routing/SKILL.md b/docs/skills/epic-routing/SKILL.md new file mode 100644 index 000000000..3f7c19608 --- /dev/null +++ b/docs/skills/epic-routing/SKILL.md @@ -0,0 +1,425 @@ +--- +name: epic-routing +description: Guide on routing with React Router and react-router-auto-routes for Epic Stack +categories: + - routing + - react-router + - file-based-routing +--- + +# Epic Stack: Routing + +## When to use this skill + +Use this skill when you need to: +- Create new routes or pages in an Epic Stack application +- Implement nested layouts +- Configure resource routes (routes without UI) +- Work with route parameters and search params +- Understand Epic Stack's file-based routing conventions +- Implement loaders and actions in routes + +## Patterns and conventions + +### Routing Philosophy + +Following Epic Web principles: + +**Do as little as possible** - Keep your route structure simple. Don't create complex nested routes unless you actually need them. Start simple and add complexity only when there's a clear benefit. + +**Avoid over-engineering** - Don't create abstractions or complex route structures "just in case". Use the simplest structure that works for your current needs. + +**Example - Simple route structure:** +```typescript +// ✅ Good - Simple, straightforward route +// app/routes/users/$username.tsx +export async function loader({ params }: Route.LoaderArgs) { + const user = await prisma.user.findUnique({ + where: { username: params.username }, + select: { id: true, username: true, name: true }, + }) + return { user } +} + +export default function UserRoute({ loaderData }: Route.ComponentProps) { + return
    {loaderData.user.name}
    +} + +// ❌ Avoid - Over-engineered route structure +// app/routes/users/$username/_layout.tsx +// app/routes/users/$username/index.tsx +// app/routes/users/$username/_components/UserHeader.tsx +// app/routes/users/$username/_components/UserDetails.tsx +// Unnecessary complexity for a simple user page +``` + +**Example - Add complexity only when needed:** +```typescript +// ✅ Good - Add nested routes only when you actually need them +// If you have user notes, then nested routes make sense: +// app/routes/users/$username/notes/_layout.tsx +// app/routes/users/$username/notes/index.tsx +// app/routes/users/$username/notes/$noteId.tsx + +// ❌ Avoid - Creating nested routes "just in case" +// Don't create complex structures before you need them +``` + +### File-based routing with react-router-auto-routes + +Epic Stack uses `react-router-auto-routes` instead of React Router's standard convention. This enables better organization and code co-location. + +**Basic structure:** +``` +app/routes/ +├── _layout.tsx # Layout for child routes +├── index.tsx # Root route (/) +├── about.tsx # Route /about +└── users/ + ├── _layout.tsx # Layout for user routes + ├── index.tsx # Route /users + └── $username/ + └── index.tsx # Route /users/:username +``` + +**Configuration in `app/routes.ts`:** +```typescript +import { type RouteConfig } from '@react-router/dev/routes' +import { autoRoutes } from 'react-router-auto-routes' + +export default autoRoutes({ + ignoredRouteFiles: [ + '.*', + '**/*.css', + '**/*.test.{js,jsx,ts,tsx}', + '**/__*.*', + '**/*.server.*', // Co-located server utilities + '**/*.client.*', // Co-located client utilities + ], +}) satisfies RouteConfig +``` + +### Route Groups + +Route groups are folders that start with `_` and don't affect the URL but help organize related code. + +**Common examples:** +- `_auth/` - Authentication routes (login, signup, etc.) +- `_marketing/` - Marketing pages (home, about, etc.) +- `_seo/` - SEO routes (sitemap, robots.txt) + +**Example:** +``` +app/routes/ +├── _auth/ +│ ├── login.tsx # URL: /login +│ ├── signup.tsx # URL: /signup +│ └── forgot-password.tsx # URL: /forgot-password +└── _marketing/ + ├── index.tsx # URL: / + └── about.tsx # URL: /about +``` + +### Route Parameters + +Use `$` to indicate route parameters: + +**Syntax:** +- `$param.tsx` → `:param` in URL +- `$username.tsx` → `:username` in URL + +**Example route with parameter:** +```typescript +// app/routes/users/$username/index.tsx +export async function loader({ params }: Route.LoaderArgs) { + const username = params.username // Type-safe! + + const user = await prisma.user.findUnique({ + where: { username }, + }) + + return { user } +} +``` + +### Nested Layouts with `_layout.tsx` + +Use `_layout.tsx` to create shared layouts for child routes. + +**Example:** +```typescript +// app/routes/users/$username/notes/_layout.tsx +export async function loader({ params }: Route.LoaderArgs) { + const owner = await prisma.user.findFirst({ + where: { username: params.username }, + }) + return { owner } +} + +export default function NotesLayout({ loaderData }: Route.ComponentProps) { + return ( +
    +

    {loaderData.owner.name}'s Notes

    + {/* Child routes render here */} +
    + ) +} +``` + +Child routes (`$noteId.tsx`, `index.tsx`, etc.) will render where `` is. + +### Resource Routes (Routes without UI) + +Resource routes don't render UI; they only return data or perform actions. + +**Characteristics:** +- Don't export a `default` component +- Export `loader` or `action` or both +- Useful for APIs, downloads, webhooks, etc. + +**Example:** +```typescript +// app/routes/resources/healthcheck.tsx +export async function loader({ request }: Route.LoaderArgs) { + // Check application health + const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') + + try { + await Promise.all([ + prisma.user.count(), // Check DB + fetch(`${new URL(request.url).protocol}${host}`, { + method: 'HEAD', + headers: { 'X-Healthcheck': 'true' }, + }), + ]) + return new Response('OK') + } catch (error) { + return new Response('ERROR', { status: 500 }) + } +} +``` + +### Loaders and Actions + +**Loaders** - Load data before rendering (GET requests) +**Actions** - Handle data mutations (POST, PUT, DELETE) + +**Loader pattern:** +```typescript +export async function loader({ request, params }: Route.LoaderArgs) { + const userId = await requireUserId(request) + + const data = await prisma.something.findMany({ + where: { userId }, + }) + + return { data } +} + +export default function RouteComponent({ loaderData }: Route.ComponentProps) { + return
    {/* Use loaderData.data */}
    +} +``` + +**Action pattern:** +```typescript +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + const formData = await request.formData() + + // Validate and process data + await prisma.something.create({ + data: { /* ... */ }, + }) + + return redirect('/success') +} + +export default function RouteComponent() { + return ( +
    + {/* Form fields */} +
    + ) +} +``` + +### Search Params + +Access query parameters using `useSearchParams`: + +```typescript +import { useSearchParams } from 'react-router' + +export default function SearchPage() { + const [searchParams, setSearchParams] = useSearchParams() + const query = searchParams.get('q') || '' + const page = Number(searchParams.get('page') || '1') + + return ( +
    + setSearchParams({ q: e.target.value })} + /> + {/* Results */} +
    + ) +} +``` + +### Code Co-location + +Epic Stack encourages placing related code close to where it's used. + +**Typical structure:** +``` +app/routes/users/$username/notes/ +├── _layout.tsx # Layout with loader +├── index.tsx # Notes list +├── $noteId.tsx # Note view +├── $noteId_.edit.tsx # Edit note +├── +shared/ # Code shared between routes +│ └── note-editor.tsx # Shared editor +└── $noteId.server.ts # Server-side utilities +``` + +The `+` prefix indicates co-located modules that are not routes. + +### Naming Conventions + +- `_layout.tsx` - Layout for child routes +- `index.tsx` - Root route of the segment +- `$param.tsx` - Route parameter +- `$param_.action.tsx` - Route with parameter + action (using `_`) +- `[.]ext.tsx` - Resource route (e.g., `robots[.]txt.ts`) + +## Common examples + +### Example 1: Create a basic route with layout + +```typescript +// app/routes/products/_layout.tsx +export async function loader({ request }: Route.LoaderArgs) { + const categories = await prisma.category.findMany() + return { categories } +} + +export default function ProductsLayout({ loaderData }: Route.ComponentProps) { + return ( +
    + + +
    + ) +} + +// app/routes/products/index.tsx +export default function ProductsIndex() { + return
    Products list
    +} +``` + +### Example 2: Route with dynamic parameter + +```typescript +// app/routes/products/$slug.tsx +export async function loader({ params }: Route.LoaderArgs) { + const product = await prisma.product.findUnique({ + where: { slug: params.slug }, + }) + + if (!product) { + throw new Response('Not Found', { status: 404 }) + } + + return { product } +} + +export default function ProductPage({ loaderData }: Route.ComponentProps) { + return ( +
    +

    {loaderData.product.name}

    +

    {loaderData.product.description}

    +
    + ) +} + +export function ErrorBoundary() { + return ( + ( +

    Product "{params.slug}" not found

    + ), + }} + /> + ) +} +``` + +### Example 3: Resource route for download + +```typescript +// app/routes/resources/download-report.tsx +export async function loader({ request }: Route.LoaderArgs) { + const userId = await requireUserId(request) + + const report = await generateReport(userId) + + return new Response(report, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="report.pdf"', + }, + }) +} +``` + +### Example 4: Route with multiple nested parameters + +```typescript +// app/routes/users/$username/posts/$postId/comments/$commentId.tsx +export async function loader({ params }: Route.LoaderArgs) { + // params contains: { username, postId, commentId } + const comment = await prisma.comment.findUnique({ + where: { id: params.commentId }, + include: { + post: { + include: { author: true }, + }, + }, + }) + + return { comment } +} +``` + +## Common mistakes to avoid + +- ❌ **Over-engineering route structure**: Keep routes simple - don't create complex nested structures unless you actually need them +- ❌ **Creating abstractions prematurely**: Start with simple routes, add complexity only when there's a clear benefit +- ❌ **Using React Router's standard convention**: Epic Stack uses `react-router-auto-routes`, not the standard convention +- ❌ **Exporting default component in resource routes**: Resource routes should not export components +- ❌ **Not using nested layouts when needed**: Use `_layout.tsx` when you have shared UI, but don't create layouts unnecessarily +- ❌ **Forgetting `` in layouts**: Without ``, child routes won't render +- ❌ **Using incorrect names for parameters**: Should be `$param.tsx`, not `:param.tsx` or `[param].tsx` +- ❌ **Mixing route groups with URLs**: Groups (`_auth/`) don't appear in the URL +- ❌ **Not validating params**: Always validate that parameters exist before using them +- ❌ **Duplicating route logic**: Use layouts and shared components, but only when it reduces duplication + +## References + +- [Epic Stack Routing Docs](../epic-stack/docs/routing.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [React Router Auto Routes](https://github.com/kenn/react-router-auto-routes) +- `app/routes.ts` - Auto-routes configuration +- `app/routes/users/$username/notes/_layout.tsx` - Example of nested layout +- `app/routes/resources/healthcheck.tsx` - Example of resource route +- `app/routes/_auth/login.tsx` - Example of route in route group diff --git a/docs/skills/epic-security/SKILL.md b/docs/skills/epic-security/SKILL.md new file mode 100644 index 000000000..2402a9e7d --- /dev/null +++ b/docs/skills/epic-security/SKILL.md @@ -0,0 +1,553 @@ +--- +name: epic-security +description: Guide on security practices including CSP, rate limiting, and session security for Epic Stack +categories: + - security + - csp + - rate-limiting + - headers +--- + +# Epic Stack: Security + +## When to use this skill + +Use this skill when you need to: +- Configure Content Security Policy (CSP) +- Implement spam protection (honeypot) +- Configure rate limiting +- Manage session security +- Implement input validation +- Configure secure headers +- Manage secrets + +## Patterns and conventions + +### Security Philosophy + +Following Epic Web principles: + +**Design to fail fast and early** - Validate security constraints as early as possible. Check authentication, authorization, and input validation before processing requests. Fail immediately with clear error messages rather than allowing potentially malicious data to flow through the system. + +**Optimize for the debugging experience** - When security checks fail, provide clear, actionable error messages that help developers understand what went wrong. Log security events with enough context to debug issues without exposing sensitive information. + +**Example - Fail fast validation:** +```typescript +// ✅ Good - Validate security constraints early +export async function action({ request }: Route.ActionArgs) { + // 1. Authenticate immediately - fail fast if not authenticated + const userId = await requireUserId(request) + + // 2. Validate input early - fail fast if invalid + const formData = await request.formData() + const submission = await parseWithZod(formData, { + schema: NoteSchema, + }) + + if (submission.status !== 'success') { + return data({ result: submission.reply() }, { status: 400 }) + } + + // 3. Check permissions early - fail fast if unauthorized + await requireUserWithPermission(request, 'create:note:own') + + // Only proceed if all security checks pass + const { title, content } = submission.value + // ... create note +} + +// ❌ Avoid - Security checks scattered or delayed +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + // ... process data first + + // Security check at the end - too late! + const userId = await getUserId(request) + if (!userId) { + // Already processed potentially malicious data + return json({ error: 'Unauthorized' }, { status: 401 }) + } +} +``` + +**Example - Debugging-friendly error messages:** +```typescript +// ✅ Good - Clear error messages for debugging +export async function checkHoneypot(formData: FormData) { + try { + await honeypot.check(formData) + } catch (error) { + if (error instanceof SpamError) { + // Log with context for debugging + console.error('Honeypot triggered', { + timestamp: new Date().toISOString(), + userAgent: formData.get('user-agent'), + // Don't log sensitive data + }) + throw new Response('Form not submitted properly', { status: 400 }) + } + throw error + } +} + +// ❌ Avoid - Generic or unhelpful errors +export async function checkHoneypot(formData: FormData) { + try { + await honeypot.check(formData) + } catch (error) { + // No context, hard to debug + throw new Response('Error', { status: 400 }) + } +} +``` + +### Content Security Policy (CSP) + +Epic Stack uses CSP to prevent XSS and other attacks. + +**Configuration in `server/index.ts`:** +```typescript +import { helmet } from '@nichtsam/helmet/node-http' + +app.use((_, res, next) => { + helmet(res, { general: { referrerPolicy: false } }) + next() +}) +``` + +**Note:** By default, CSP is in `report-only` mode to avoid blocking resources during development. In production, remove `reportOnly: true` to enable it fully. + +### Honeypot Fields + +Epic Stack uses honeypot fields to protect against spam bots. + +**En formularios públicos:** +```typescript +import { HoneypotInputs } from 'remix-utils/honeypot/react' + +
    + {/* Always include in public forms */} + {/* Resto de campos */} + +``` + +**En el action (fail fast):** +```typescript +import { checkHoneypot } from '#app/utils/honeypot.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + // Check honeypot first - fail fast if spam detected + await checkHoneypot(formData) // Lanza error si es spam + + // Only proceed if honeypot check passes + // ... resto del código +} +``` + +**Configuration:** +```typescript +// app/utils/honeypot.server.ts +import { Honeypot, SpamError } from 'remix-utils/honeypot/server' + +export const honeypot = new Honeypot({ + validFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined, + encryptionSeed: process.env.HONEYPOT_SECRET, +}) + +export async function checkHoneypot(formData: FormData) { + try { + await honeypot.check(formData) + } catch (error) { + if (error instanceof SpamError) { + // Log for debugging (without sensitive data) + console.error('Honeypot triggered', { + timestamp: new Date().toISOString(), + }) + throw new Response('Form not submitted properly', { status: 400 }) + } + throw error + } +} +``` + +### Rate Limiting + +Epic Stack uses `express-rate-limit` para prevenir abuso. + +**Basic configuration:** +```typescript +// server/index.ts +import rateLimit from 'express-rate-limit' + + const rateLimitDefault = { + windowMs: 60 * 1000, // 1 minute + limit: 1000, // 1000 requests per minute + standardHeaders: true, + legacyHeaders: false, + validate: { trustProxy: false }, + keyGenerator: (req: express.Request) => { + return req.get('fly-client-ip') ?? `${req.ip}` + }, +} + +const generalRateLimit = rateLimit(rateLimitDefault) +``` + +**Different levels of rate limiting:** +```typescript +// Stricter rate limit for sensitive routes +const strongestRateLimit = rateLimit({ + ...rateLimitDefault, + limit: 10, // Only 10 requests per minute +}) + +// Strong rate limit for important actions +const strongRateLimit = rateLimit({ + ...rateLimitDefault, + limit: 100, // 100 requests per minute +}) +``` + +**Apply to specific routes:** +```typescript +app.use((req, res, next) => { + const strongPaths = [ + '/login', + '/signup', + '/verify', + '/admin', + '/reset-password', + ] + + if (req.method !== 'GET' && req.method !== 'HEAD') { + if (strongPaths.some((p) => req.path.includes(p))) { + return strongestRateLimit(req, res, next) + } + return strongRateLimit(req, res, next) + } + + return generalRateLimit(req, res, next) +}) +``` + +**Note:** In tests and development, rate limiting is effectively disabled to allow fast tests. + +### Session Security + +**Secure session configuration:** +```typescript +// app/utils/session.server.ts +export const authSessionStorage = createCookieSessionStorage({ + cookie: { + name: 'en_session', + sameSite: 'lax', // CSRF protection advised if changing to 'none' + path: '/', + httpOnly: true, // Prevents access from JavaScript + secrets: process.env.SESSION_SECRET.split(','), // Secret rotation + secure: process.env.NODE_ENV === 'production', // HTTPS only in production + }, +}) +``` + +**Security features:** +- `httpOnly: true` - Prevents access from JavaScript (XSS protection) +- `secure: true` - Only sends cookies over HTTPS in production +- `sameSite: 'lax'` - CSRF protection +- Secret rotation using array + +### Password Security + +**Hashing de passwords:** +```typescript +import bcrypt from 'bcryptjs' + +export async function getPasswordHash(password: string) { + const hash = await bcrypt.hash(password, 10) // 10 rounds + return hash +} + +export async function verifyUserPassword( + where: Pick | Pick, + password: string, +) { + const userWithPassword = await prisma.user.findUnique({ + where, + select: { id: true, password: { select: { hash: true } } }, + }) + + if (!userWithPassword || !userWithPassword.password) { + return null + } + + const isValid = await bcrypt.compare(password, userWithPassword.password.hash) + return isValid ? { id: userWithPassword.id } : null +} +``` + +**Check common passwords (Have I Been Pwned):** +```typescript +import { checkIsCommonPassword } from '#app/utils/auth.server.ts' + +const isCommonPassword = await checkIsCommonPassword(password) +if (isCommonPassword) { + ctx.addIssue({ + path: ['password'], + code: 'custom', + message: 'Password is too common', + }) +} +``` + +### Input Validation y Sanitization + +**Always validate inputs with Zod:** +```typescript +import { z } from 'zod' + +const UserSchema = z.object({ + email: z.string().email().min(3).max(100).transform(val => val.toLowerCase()), + username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), + password: z.string().min(6).max(72), // bcrypt limit +}) + +// Validate before using +const result = UserSchema.safeParse(data) +if (!result.success) { + return json({ errors: result.error.flatten() }, { status: 400 }) +} +``` + +**Sanitization:** +- Use `.transform()` from Zod to sanitize data +- Normalize emails to lowercase +- Normalize usernames to lowercase +- Clean whitespace + +### XSS Prevention + +React prevents XSS automatically by escaping all values. + +**Never use `dangerouslySetInnerHTML` with user data:** +```typescript +// ❌ NEVER do this with user data +
    + +// ✅ React escapa automáticamente +
    {userContent}
    +``` + +### Secure Headers + +Epic Stack uses Helmet for secure headers. + +**Configuration:** +```typescript +import { helmet } from '@nichtsam/helmet/node-http' + +app.use((_, res, next) => { + helmet(res, { general: { referrerPolicy: false } }) + next() +}) +``` + +**Included headers:** +- X-Content-Type-Options: nosniff +- X-Frame-Options: DENY +- X-XSS-Protection: 1; mode=block +- Referrer-Policy (configurable) + +### HTTPS Only + +**Redirect HTTP to HTTPS:** +```typescript +app.use((req, res, next) => { + if (req.method !== 'GET') return next() + const proto = req.get('X-Forwarded-Proto') + const host = getHost(req) + if (proto === 'http') { + res.set('X-Forwarded-Proto', 'https') + res.redirect(`https://${host}${req.originalUrl}`) + return + } + next() +}) +``` + +### Secrets Management + +**Variables de entorno:** +```bash +# .env +SESSION_SECRET=secret1,secret2,secret3 # Secret rotation +HONEYPOT_SECRET=your-honeypot-secret +DATABASE_URL=file:./data/db.sqlite +``` + +**En Fly.io:** +```bash +fly secrets set SESSION_SECRET="secret1,secret2,secret3" +fly secrets set HONEYPOT_SECRET="your-secret" +``` + +**Never commit secrets:** +- Use `.env.example` to document required variables +- `.env` is in `.gitignore` +- Use `fly secrets` for production + +### Validación de Session Expiration (Fail Fast) + +**Always verify expiration early:** +```typescript +export async function getUserId(request: Request) { + const authSession = await authSessionStorage.getSession( + request.headers.get('cookie'), + ) + const sessionId = authSession.get(sessionKey) + + // Fail fast - return null immediately if no session + if (!sessionId) return null + + // Verify expiration early - fail fast if expired + const session = await prisma.session.findUnique({ + select: { userId: true }, + where: { + id: sessionId, + expirationDate: { gt: new Date() }, // Verify expiration + }, + }) + + // Fail fast - destroy invalid session immediately + if (!session?.userId) { + throw redirect('/', { + headers: { + 'set-cookie': await authSessionStorage.destroySession(authSession), + }, + }) + } + return session.userId +} +``` + +## Common examples + +### Example 1: Public form with honeypot + +```typescript +// app/routes/_auth/signup.tsx +import { HoneypotInputs } from 'remix-utils/honeypot/react' +import { checkHoneypot } from '#app/utils/honeypot.server.ts' + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + + await checkHoneypot(formData) // Spam protection + + const submission = await parseWithZod(formData, { + schema: SignupSchema, + }) + + // ... rest of code +} + +export default function SignupRoute({ actionData }: Route.ComponentProps) { + return ( +
    + {/* Include in form */} + {/* Rest of fields */} + + ) +} +``` + +### Example 2: Custom rate limiting + +```typescript +// server/index.ts +const apiRateLimit = rateLimit({ + ...rateLimitDefault, + windowMs: 60 * 1000, + limit: 100, // 100 requests per minute for API + keyGenerator: (req) => { + const apiKey = req.get('X-API-Key') + return apiKey ?? req.get('fly-client-ip') ?? req.ip + }, +}) + +app.use('/api', apiRateLimit) +``` + +### Example 3: Strict input validation + +```typescript +// app/utils/user-validation.ts +import { z } from 'zod' + +export const EmailSchema = z + .string({ required_error: 'Email is required' }) + .email({ message: 'Email is invalid' }) + .min(3, { message: 'Email is too short' }) + .max(100, { message: 'Email is too long' }) + .transform((value) => value.toLowerCase().trim()) // Sanitization + +export const UsernameSchema = z + .string({ required_error: 'Username is required' }) + .min(3, { message: 'Username is too short' }) + .max(20, { message: 'Username is too long' }) + .regex(/^[a-zA-Z0-9_]+$/, { + message: 'Username can only include letters, numbers, and underscores', + }) + .transform((value) => value.toLowerCase().trim()) // Sanitization + +export const PasswordSchema = z + .string({ required_error: 'Password is required' }) + .min(6, { message: 'Password is too short' }) + .refine((val) => new TextEncoder().encode(val).length <= 72, { + message: 'Password is too long', // bcrypt limit + }) +``` + +### Example 4: Permission verification before actions + +```typescript +export async function action({ request }: Route.ActionArgs) { + const userId = await requireUserId(request) + + // Validate that user has permission + await requireUserWithPermission(request, 'delete:note:own') + + // Only after validating permissions + await prisma.note.delete({ where: { id: noteId } }) + + return redirect('/notes') +} +``` + +## Common mistakes to avoid + +- ❌ **Delayed security checks**: Validate authentication, authorization, and input as early as possible - fail fast +- ❌ **Generic error messages**: Provide clear, actionable error messages that help with debugging (without exposing sensitive data) +- ❌ **Forgetting honeypot in public forms**: Always include `HoneypotInputs` in forms accessible without authentication +- ❌ **Not validating session expiration**: Always verify `expirationDate` when getting sessions - check early +- ❌ **Using `dangerouslySetInnerHTML` with user data**: Never render user HTML without sanitizing +- ❌ **Not using rate limiting**: Protect sensitive routes with rate limiting +- ❌ **Secrets in code**: Never hardcode secrets, use environment variables +- ❌ **Not sanitizing inputs**: Always sanitize inputs with `.transform()` from Zod +- ❌ **Not validating common passwords**: Check passwords against Have I Been Pwned +- ❌ **Sessions without httpOnly**: Always use `httpOnly: true` in session cookies +- ❌ **Not using HTTPS in production**: Make sure to redirect HTTP to HTTPS +- ❌ **CSP too permissive**: Review and adjust CSP according to your needs +- ❌ **Not logging security events**: Log security failures with context for debugging (without sensitive data) + +## References + +- [Epic Stack Security Docs](../epic-stack/docs/security.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) +- `app/utils/honeypot.server.ts` - Honeypot implementation +- `server/index.ts` - Server security configuration +- `docs/decisions/033-honeypot.md` - Honeypot documentation +- `docs/decisions/025-rate-limiting.md` - Rate limiting documentation +- `docs/decisions/008-content-security-policy.md` - CSP documentation diff --git a/docs/skills/epic-testing/SKILL.md b/docs/skills/epic-testing/SKILL.md new file mode 100644 index 000000000..a39c643b8 --- /dev/null +++ b/docs/skills/epic-testing/SKILL.md @@ -0,0 +1,589 @@ +--- +name: epic-testing +description: Guide on testing with Vitest and Playwright for Epic Stack +categories: + - testing + - vitest + - playwright + - e2e +--- + +# Epic Stack: Testing + +## When to use this skill + +Use this skill when you need to: +- Write unit tests for utilities and components +- Create E2E tests with Playwright +- Test forms and validation +- Test routes and loaders +- Mock external services with MSW +- Test authentication and permissions +- Configure test database + +## Patterns and conventions + +### Testing Philosophy + +Following Epic Web principles: + +**Tests should resemble users** - Write tests that mirror how real users interact with your application. Test user workflows, not implementation details. If a user would click a button, your test should click that button. If a user would see an error message, your test should check for that specific message. + +**Make assertions specific** - Be explicit about what you're testing. Instead of vague assertions, use specific, meaningful checks that clearly communicate the expected behavior. This makes tests easier to understand and debug when they fail. + +**Example - Tests that resemble users:** +```typescript +// ✅ Good - Tests user workflow +test('User can sign up and create their first note', async ({ page, navigate }) => { + // User visits signup page + await navigate('/signup') + + // User fills out form like a real person would + await page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com') + await page.getByRole('textbox', { name: /username/i }).fill('newuser') + await page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123') + await page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123') + + // User submits form + await page.getByRole('button', { name: /sign up/i }).click() + + // User is redirected to onboarding + await expect(page).toHaveURL(/\/onboarding/) + + // User creates their first note + await navigate('/notes/new') + await page.getByRole('textbox', { name: /title/i }).fill('My First Note') + await page.getByRole('textbox', { name: /content/i }).fill('This is my first note!') + await page.getByRole('button', { name: /create/i }).click() + + // User sees their note + await expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible() + await expect(page.getByText('This is my first note!')).toBeVisible() +}) + +// ❌ Avoid - Testing implementation details +test('Signup form calls API endpoint', async ({ page }) => { + // This tests implementation, not user experience + const response = await page.request.post('/signup', { data: {...} }) + expect(response.status()).toBe(200) +}) +``` + +**Example - Specific assertions:** +```typescript +// ✅ Good - Specific assertions +test('Form shows specific validation errors', async ({ page, navigate }) => { + await navigate('/signup') + await page.getByRole('button', { name: /sign up/i }).click() + + // Specific error messages that users would see + await expect(page.getByText(/email is required/i)).toBeVisible() + await expect(page.getByText(/username must be at least 3 characters/i)).toBeVisible() + await expect(page.getByText(/password must be at least 6 characters/i)).toBeVisible() +}) + +// ❌ Avoid - Vague assertions +test('Form shows errors', async ({ page, navigate }) => { + await navigate('/signup') + await page.getByRole('button', { name: /sign up/i }).click() + + // Too vague - what errors? where? + expect(page.locator('.error')).toBeVisible() +}) +``` + +### Two Types of Tests + +Epic Stack uses two types of tests: + +1. **Unit Tests with Vitest** - Tests for individual components and utilities +2. **E2E Tests with Playwright** - End-to-end tests of the complete flow + +### Unit Tests with Vitest + +**Basic setup:** +```typescript +// app/utils/my-util.test.ts +import { describe, expect, it } from 'vitest' +import { myUtil } from './my-util.ts' + +describe('myUtil', () => { + it('should do something', () => { + expect(myUtil('input')).toBe('expected') + }) +}) +``` + +**Testing con DOM:** +```typescript +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MyComponent } from './my-component.tsx' + +describe('MyComponent', () => { + it('should render correctly', () => { + render() + expect(screen.getByText('Hello')).toBeInTheDocument() + }) +}) +``` + +### E2E Tests with Playwright + +**Basic setup:** +```typescript +// tests/e2e/my-feature.test.ts +import { expect, test } from '#tests/playwright-utils.ts' + +test('Users can do something', async ({ page, navigate, login }) => { + const user = await login() + await navigate('/my-page') + + // Interact with the page + await page.getByRole('button', { name: /Submit/i }).click() + + // Verificar resultado + await expect(page).toHaveURL('/success') +}) +``` + +### Login Fixture + +Epic Stack provides a `login` fixture for authenticated tests. + +**Use login fixture:** +```typescript +test('Protected route', async ({ page, navigate, login }) => { + const user = await login() // Creates user and session automatically + await navigate('/protected') + + // User is authenticated + await expect(page.getByText(`Welcome ${user.username}`)).toBeVisible() +}) +``` + +**Login with options:** +```typescript +const user = await login({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', +}) +``` + +**Note:** The user is automatically deleted when the test completes. + +### Insert User without Login + +To create user without authentication: + +```typescript +test('Public content', async ({ page, navigate, insertNewUser }) => { + const user = await insertNewUser({ + username: 'publicuser', + email: 'public@example.com', + }) + + await navigate(`/users/${user.username}`) + await expect(page.getByText(user.username)).toBeVisible() +}) +``` + +### Navigate Helper + +Use the `navigate` helper to navigate with type-safety: + +```typescript +// Type-safe navigation +await navigate('/users/:username/notes', { username: user.username }) +await navigate('/users/:username/notes/:noteId', { + username: user.username, + noteId: note.id, +}) + +// Also works with routes without parameters +await navigate('/login') +``` + +### Test Database + +Epic Stack uses a separate test database. + +**Automatic configuration:** +- The test database is configured automatically +- It's cleaned between tests +- Data created in tests is automatically deleted + +**Create data in tests:** +```typescript +import { prisma } from '#app/utils/db.server.ts' + +test('User can see notes', async ({ page, navigate, login }) => { + const user = await login() + + // Create note in database + const note = await prisma.note.create({ + data: { + title: 'Test Note', + content: 'Test Content', + ownerId: user.id, + }, + }) + + await navigate('/users/:username/notes/:noteId', { + username: user.username, + noteId: note.id, + }) + + await expect(page.getByText('Test Note')).toBeVisible() +}) +``` + +### MSW (Mock Service Worker) + +Epic Stack uses MSW to mock external services. + +**Mock example:** +```typescript +// tests/mocks/github.ts +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('https://api.github.com/user', () => { + return HttpResponse.json({ + id: '123', + login: 'testuser', + email: 'test@example.com', + }) + }), +] +``` + +**Use in tests:** +Mocks are automatically applied when `MOCKS=true` is configured. + +### Testing Forms + +**Test form:** +```typescript +test('User can submit form', async ({ page, navigate, login }) => { + const user = await login() + await navigate('/notes/new') + + // Fill form + await page.getByRole('textbox', { name: /title/i }).fill('New Note') + await page.getByRole('textbox', { name: /content/i }).fill('Note content') + + // Submit + await page.getByRole('button', { name: /submit/i }).click() + + // Verificar redirect + await expect(page).toHaveURL(new RegExp('/users/.*/notes/.*')) +}) +``` + +**Test validation:** +```typescript +test('Form shows validation errors', async ({ page, navigate }) => { + await navigate('/signup') + + // Submit sin llenar + await page.getByRole('button', { name: /submit/i }).click() + + // Verificar errores + await expect(page.getByText(/email is required/i)).toBeVisible() +}) +``` + +### Testing Loaders + +**Test loader:** +```typescript +// app/utils/my-util.test.ts +import { describe, expect, it } from 'vitest' +import { loader } from '../routes/my-route.ts' +import { prisma } from '../utils/db.server.ts' + +describe('loader', () => { + it('should load data', async () => { + // Create data + const user = await prisma.user.create({ + data: { + email: 'test@example.com', + username: 'testuser', + roles: { connect: { name: 'user' } }, + }, + }) + + // Mock request + const request = new Request('http://localhost/my-route') + + // Execute loader + const result = await loader({ request, params: {}, context: {} }) + + // Verify result + expect(result.data).toBeDefined() + }) +}) +``` + +### Testing Actions + +**Test action:** +```typescript +// tests/e2e/notes.test.ts +test('User can create note', async ({ page, navigate, login }) => { + const user = await login() + await navigate('/users/:username/notes', { username: user.username }) + + await page.getByRole('link', { name: /new note/i }).click() + + const formData = new FormData() + formData.set('title', 'Test Note') + formData.set('content', 'Test Content') + + await page.getByRole('textbox', { name: /title/i }).fill('Test Note') + await page.getByRole('textbox', { name: /content/i }).fill('Test Content') + await page.getByRole('button', { name: /submit/i }).click() + + // Verify that note was created + await expect(page.getByText('Test Note')).toBeVisible() +}) +``` + +### Testing Permissions + +**Test permissions:** +```typescript +test('Only owner can delete note', async ({ page, navigate, login, insertNewUser }) => { + const owner = await login() + const otherUser = await insertNewUser() + + const note = await prisma.note.create({ + data: { + title: 'Test Note', + content: 'Test', + ownerId: owner.id, + }, + }) + + // Login as other user + const session = await createSession(otherUser.id) + await page.context().addCookies([getCookie(session)]) + + await navigate('/users/:username/notes/:noteId', { + username: owner.username, + noteId: note.id, + }) + + // Verify that can't delete + await expect(page.getByRole('button', { name: /delete/i })).not.toBeVisible() +}) +``` + +### DB Helpers + +**Create user:** +```typescript +import { createUser } from '#tests/db-utils.ts' + +const userData = createUser() // Generates unique random data +``` + +**Create password:** +```typescript +import { createPassword } from '#tests/db-utils.ts' + +const password = createPassword('mypassword') // { hash: '...' } +``` + +### Wait For Helper + +To wait for async conditions: + +```typescript +import { waitFor } from '#tests/playwright-utils.ts' + +await waitFor(async () => { + const element = await page.getByText('Content loaded').first() + expect(element).toBeVisible() + return element +}, { timeout: 5000, errorMessage: 'Content never loaded' }) +``` + +### Testing GitHub OAuth + +**Prepare GitHub user:** +```typescript +test('User can login with GitHub', async ({ page, navigate, prepareGitHubUser }) => { + const ghUser = await prepareGitHubUser() + + await navigate('/login') + await page.getByRole('link', { name: /github/i }).click() + + // GitHub user is automatically prepared + await expect(page).toHaveURL('/onboarding/github') +}) +``` + +## Common examples + +### Example 1: Complete E2E test (resembling user workflow) + +```typescript +// tests/e2e/notes.test.ts +import { expect, test } from '#tests/playwright-utils.ts' +import { prisma } from '#app/utils/db.server.ts' +import { faker } from '@faker-js/faker' + +test('Users can create, edit, and delete notes', async ({ page, navigate, login }) => { + // User logs in (realistic workflow) + const user = await login() + await navigate('/users/:username/notes', { username: user.username }) + + // User creates a new note (clicking link, filling form, submitting) + await page.getByRole('link', { name: /new note/i }).click() + const newNote = { + title: faker.lorem.words(3), + content: faker.lorem.paragraphs(2), + } + await page.getByRole('textbox', { name: /title/i }).fill(newNote.title) + await page.getByRole('textbox', { name: /content/i }).fill(newNote.content) + await page.getByRole('button', { name: /submit/i }).click() + + // Specific assertions: user sees their note with correct title and content + await expect(page.getByRole('heading', { name: newNote.title })).toBeVisible() + await expect(page.getByText(newNote.content)).toBeVisible() + const noteUrl = page.url() + const noteId = noteUrl.split('/').pop() + + // User edits the note (clicking edit, updating fields, saving) + await page.getByRole('link', { name: /edit/i }).click() + const updatedNote = { + title: faker.lorem.words(3), + content: faker.lorem.paragraphs(2), + } + await page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title) + await page.getByRole('textbox', { name: /content/i }).fill(updatedNote.content) + await page.getByRole('button', { name: /submit/i }).click() + + // Specific assertions: user sees updated content + await expect(page.getByRole('heading', { name: updatedNote.title })).toBeVisible() + await expect(page.getByText(updatedNote.content)).toBeVisible() + + // User deletes the note (clicking delete button) + await page.getByRole('button', { name: /delete/i }).click() + + // Specific assertion: user is redirected back to notes list + await expect(page).toHaveURL(`/users/${user.username}/notes`) + await expect(page.getByText(updatedNote.title)).not.toBeVisible() +}) +``` + +### Example 2: Unit test for utility + +```typescript +// app/utils/misc.test.ts +import { describe, expect, it } from 'vitest' +import { cn } from './misc.tsx' + +describe('cn', () => { + it('should merge class names', () => { + expect(cn('foo', 'bar')).toBe('foo bar') + expect(cn('foo', undefined, 'bar')).toBe('foo bar') + expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz') + }) +}) +``` + +### Example 3: Form validation test + +```typescript +// tests/e2e/signup.test.ts +test('Signup form validation', async ({ page, navigate }) => { + await navigate('/signup') + + // Submit without filling + await page.getByRole('button', { name: /submit/i }).click() + + // Verify errors + await expect(page.getByText(/email is required/i)).toBeVisible() + + // Fill invalid email + await page.getByRole('textbox', { name: /email/i }).fill('invalid') + await page.getByRole('button', { name: /submit/i }).click() + + // Verify email error + await expect(page.getByText(/email is invalid/i)).toBeVisible() + + // Fill valid email + await page.getByRole('textbox', { name: /email/i }).fill('test@example.com') + await page.getByRole('button', { name: /submit/i }).click() + + // Verify redirect to onboarding + await expect(page).toHaveURL(/\/onboarding/) +}) +``` + +### Example 4: Permissions test + +```typescript +// tests/e2e/permissions.test.ts +test('Only admin can access admin routes', async ({ page, navigate, login, insertNewUser }) => { + // Test with normal user + const normalUser = await login() + + await navigate('/admin/users') + + // Should redirect or show error + await expect(page).toHaveURL('/') // Or verify error message + + // Test with admin + await page.context().clearCookies() + const admin = await insertNewUser() + await prisma.user.update({ + where: { id: admin.id }, + data: { + roles: { + connect: { name: 'admin' }, + }, + }, + }) + + // Login as admin + const adminSession = await createSession(admin.id) + await page.context().addCookies([getCookie(adminSession)]) + + await navigate('/admin/users') + + // Now should work + await expect(page.getByText('All Users')).toBeVisible() +}) +``` + +## Common mistakes to avoid + +- ❌ **Testing implementation details instead of user workflows**: Write tests that mirror how users actually use your app +- ❌ **Vague assertions**: Use specific, meaningful assertions that clearly communicate expected behavior +- ❌ **Not cleaning data after tests**: Epic Stack cleans automatically, but make sure not to depend on data between tests +- ❌ **Assuming execution order**: Tests must be independent +- ❌ **Not using fixtures**: Use `login`, `insertNewUser`, etc. instead of creating everything manually +- ❌ **Hardcoding data**: Use `faker` to generate unique data +- ❌ **Not waiting for elements**: Use `expect` with `toBeVisible()` instead of assuming it exists +- ❌ **Not using type-safe navigation**: Use `navigate` helper instead of `page.goto()` directly +- ❌ **Forgetting MSW in tests**: External services are automatically mocked when `MOCKS=true` +- ❌ **Not testing error cases**: Test both happy path and errors +- ❌ **Testing internal state instead of user-visible behavior**: Focus on what users see and do + +## References + +- [Epic Stack Testing Docs](../epic-stack/docs/testing.md) +- [Epic Web Principles](https://www.epicweb.dev/principles) +- [Vitest Documentation](https://vitest.dev/) +- [Playwright Documentation](https://playwright.dev/) +- [Testing Library](https://testing-library.com/) +- [MSW](https://mswjs.io/) +- `tests/playwright-utils.ts` - Playwright fixtures and helpers +- `tests/db-utils.ts` - DB helpers for tests +- `tests/e2e/` - E2E test examples +- `app/utils/*.test.ts` - Unit test examples diff --git a/docs/skills/epic-ui-guidelines/SKILL.md b/docs/skills/epic-ui-guidelines/SKILL.md new file mode 100644 index 000000000..60c332cfd --- /dev/null +++ b/docs/skills/epic-ui-guidelines/SKILL.md @@ -0,0 +1,667 @@ +--- +name: epic-ui-guidelines +description: Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack +categories: + - ui + - accessibility + - tailwind + - radix-ui +--- + +# Epic Stack: UI Guidelines + +## When to use this skill + +Use this skill when you need to: +- Create accessible UI components +- Follow Epic Stack design patterns +- Use Tailwind CSS effectively +- Implement semantic HTML +- Add ARIA attributes correctly +- Create responsive layouts +- Ensure proper form accessibility +- Follow Epic Stack's UI component conventions + +## Patterns and conventions + +### UI Philosophy + +Following Epic Web principles: + +**Software is built for people, by people** - Accessibility isn't about checking boxes or meeting standards. It's about creating software that works for real people with diverse needs, abilities, and contexts. Every UI decision should prioritize the human experience over technical convenience. + +Accessibility is not optional - it's how we ensure our software serves all users, not just some. When you make UI accessible, you're making it better for everyone: clearer labels help all users, keyboard navigation helps power users, and semantic HTML helps search engines. + +**Example - Human-centered approach:** +```typescript +// ✅ Good - Built for people +function NoteForm() { + return ( +
    + + + ) +} + +// ❌ Avoid - Technical convenience over user experience +function NoteForm() { + return ( +
    + {/* No label, no guidance, no accessibility */} +
    + ) +} +``` + +### Semantic HTML + +**✅ Good - Use semantic elements:** +```typescript +function UserCard({ user }: { user: User }) { + return ( +
    +
    +

    {user.name}

    +
    +

    {user.bio}

    +
    + +
    +
    + ) +} +``` + +**❌ Avoid - Generic divs:** +```typescript +// ❌ Don't use divs for everything +
    +
    {user.name}
    +
    {user.bio}
    +
    {formatDate(user.createdAt)}
    +
    +``` + +### Form Accessibility + +**✅ Good - Always use labels:** +```typescript +import { Field } from '#app/components/forms.tsx' + + +``` + +The `Field` component automatically: +- Associates labels with inputs using `htmlFor` and `id` +- Adds `aria-invalid` when there are errors +- Adds `aria-describedby` pointing to error messages +- Ensures proper error announcement + +**❌ Avoid - Unlabeled inputs:** +```typescript +// ❌ Don't forget labels + +``` + +### ARIA Attributes + +**✅ Good - Use ARIA appropriately:** +```typescript +// Epic Stack's Field component handles this automatically + +``` + +**✅ Good - ARIA for custom components:** +```typescript +function LoadingButton({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) { + return ( + + ) +} +``` + +### Using Radix UI Components + +Epic Stack uses Radix UI for accessible, unstyled components. + +**✅ Good - Use Radix primitives:** +```typescript +import * as Dialog from '@radix-ui/react-dialog' +import { Button } from '#app/components/ui/button.tsx' + +function MyDialog() { + return ( + + + + + + + + Dialog Title + Dialog description + + + + + + + ) +} +``` + +Radix components automatically handle: +- Keyboard navigation +- Focus management +- ARIA attributes +- Screen reader announcements + +### Tailwind CSS Patterns + +**✅ Good - Use Tailwind utility classes:** +```typescript +function Card({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ) +} +``` + +**✅ Good - Use Tailwind responsive utilities:** +```typescript +
    + {items.map(item => ( + {item.name} + ))} +
    +``` + +**✅ Good - Use Tailwind dark mode:** +```typescript +
    + {content} +
    +``` + +### Error Handling in Forms + +**✅ Good - Display errors accessibly:** +```typescript +import { Field, ErrorList } from '#app/components/forms.tsx' + + + + // Form-level errors +``` + +Errors are automatically: +- Associated with inputs via `aria-describedby` +- Announced to screen readers +- Visually distinct with error styling + +### Focus Management + +**✅ Good - Visible focus indicators:** +```typescript +// Tailwind's default focus:ring handles this + +``` + +**✅ Good - Focus on form errors:** +```typescript +import { useEffect, useRef } from 'react' + +function FormWithErrorFocus() { + const firstErrorRef = useRef(null) + + useEffect(() => { + if (actionData?.errors && firstErrorRef.current) { + firstErrorRef.current.focus() + } + }, [actionData?.errors]) + + return +} +``` + +### Keyboard Navigation + +**✅ Good - Keyboard accessible components:** +```typescript +// Radix components handle keyboard navigation automatically + + + + +// Custom components should support keyboard + +``` + +### Color Contrast + +**✅ Good - Use accessible color combinations:** +```typescript +// Use Tailwind's semantic colors that meet WCAG AA +
    // High contrast +
    // Accessible links +``` + +**❌ Avoid - Low contrast text:** +```typescript +// ❌ Don't use low contrast +
    // Very low contrast +``` + +### Responsive Design + +**✅ Good - Mobile-first approach:** +```typescript +
    + {/* Content */} +
    +``` + +**✅ Good - Responsive typography:** +```typescript +

    + Responsive Heading +

    +``` + +### Loading States + +**✅ Good - Accessible loading indicators:** +```typescript +import { useNavigation } from 'react-router' + +function SubmitButton() { + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + + return ( + + ) +} +``` + +### Icon Usage + +**✅ Good - Decorative icons:** +```typescript +import { Icon } from '#app/components/ui/icon.tsx' + + +``` + +**✅ Good - Semantic icons:** +```typescript + +``` + +### Skip Links + +**✅ Good - Add skip to main content:** +```typescript +// In your root layout + + Skip to main content + + +
    + {/* Main content */} +
    +``` + +### Progressive Enhancement + +**✅ Good - Forms work without JavaScript:** +```typescript +// Conform forms work without JavaScript +
    + + Submit + +``` + +Forms automatically: +- Submit via native HTML forms if JavaScript is disabled +- Validate server-side +- Show errors appropriately + +### Screen Reader Best Practices + +**✅ Good - Use semantic HTML first:** +```typescript +// ✅ Semantic HTML provides context automatically + +``` + +**✅ Good - Announce dynamic content:** +```typescript +import { useNavigation } from 'react-router' + +function SearchResults({ results }: { results: Result[] }) { + const navigation = useNavigation() + const isSearching = navigation.state === 'loading' + + return ( +
    + {isSearching ? 'Searching...' : `${results.length} results found`} +
    + ) +} +``` + +**✅ Good - Live regions for important updates:** +```typescript +function ToastContainer({ toasts }: { toasts: Toast[] }) { + return ( +
    + {toasts.map(toast => ( +
    + {toast.message} +
    + ))} +
    + ) +} +``` + +**ARIA live region options:** +- `aria-live="polite"` - For non-critical updates (search results, status messages) +- `aria-live="assertive"` - For critical updates (errors, confirmations) +- `aria-atomic="true"` - Screen reader reads entire region on update +- `aria-atomic="false"` - Screen reader reads only changed parts + +### Keyboard Navigation Patterns + +**✅ Good - Tab order follows visual order:** +```typescript +// Elements appear in logical tab order + +``` + +**✅ Good - Keyboard shortcuts:** +```typescript +import { useEffect } from 'react' + +function SearchDialog({ onClose }: { onClose: () => void }) { + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + onClose() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return {/* content */} +} +``` + +**✅ Good - Focus trap in modals:** +```typescript +// Radix Dialog automatically handles focus trap + + + {/* Focus is trapped inside dialog */} + Close + + +``` + +### Focus Management for React Router + +**✅ Good - Focus on route changes:** +```typescript +import { useEffect } from 'react' +import { useNavigation } from 'react-router' + +function RouteComponent() { + const navigation = useNavigation() + const mainRef = useRef(null) + + useEffect(() => { + if (navigation.state === 'idle' && mainRef.current) { + mainRef.current.focus() + } + }, [navigation.state]) + + return ( +
    + {/* Content */} +
    + ) +} +``` + +**✅ Good - Focus on errors:** +```typescript +import { useEffect, useRef } from 'react' + +function FormWithErrorFocus({ actionData }: Route.ComponentProps) { + const firstErrorRef = useRef(null) + + useEffect(() => { + if (actionData?.errors && firstErrorRef.current) { + // Focus first error field + firstErrorRef.current.focus() + // Announce error + firstErrorRef.current.setAttribute('aria-invalid', 'true') + } + }, [actionData?.errors]) + + return +} +``` + +### Typography and Readability + +**✅ Good - Readable text sizes:** +```typescript +// Use Tailwind's text size scale +

    Readable body text

    +

    Clear headings

    +``` + +**✅ Good - Sufficient line height:** +```typescript +// Tailwind defaults provide good line height +

    Comfortable reading

    +``` + +**❌ Avoid - Small or hard-to-read text:** +```typescript +// ❌ Don't use very small text +

    Hard to read

    +``` + +### Touch Target Sizes + +**✅ Good - Sufficient touch targets:** +```typescript +// Buttons should be at least 44x44px (touch target size) + +``` + +**✅ Good - Spacing between interactive elements:** +```typescript +
    + + +
    +``` + +### Internationalization (i18n) Considerations + +**✅ Good - Use semantic HTML for dates/times:** +```typescript + +``` + +**✅ Good - Use semantic HTML for numbers:** +```typescript +// Screen readers can pronounce numbers correctly +

    Total: {count}

    +``` + +**✅ Good - Language attributes:** +```typescript +// In root.tsx + + + {/* Content */} + + +``` + +### Dark Mode Accessibility + +**✅ Good - Maintain contrast in dark mode:** +```typescript +// Ensure sufficient contrast in both modes +
    + {content} +
    +``` + +**✅ Good - Respect user preference:** +```typescript +// Epic Stack automatically handles theme preference +// Use semantic colors that work in both modes + +``` + +### Animation and Motion + +**✅ Good - Respect reduced motion:** +```typescript +// Tailwind automatically respects prefers-reduced-motion +
    + {/* Animations disabled for users who prefer reduced motion */} +
    +``` + +**✅ Good - Use CSS for animations:** +```typescript +// ✅ CSS animations can be disabled via prefers-reduced-motion +
    + {/* Content */} +
    + +// ❌ JavaScript animations may not respect user preferences +``` + +## Common mistakes to avoid + +- ❌ **Treating accessibility as a checklist**: Accessibility is about serving real people, not just meeting standards +- ❌ **Missing form labels**: Always use `Field` component which includes labels - helps all users, not just screen reader users +- ❌ **Using divs for semantic elements**: Use `
    `, `
    `, `