{"owner":"epicweb-dev","repo":"epic-stack","hasSkills":true,"totalSkillsCount":13,"totalTokensCount":46846,"categories":["cursor-rule","root-instruction","anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":[".cursor/rules/avoid-use-effect.mdc","docs/routing.md","docs/skills/epic-auth/SKILL.md","docs/skills/epic-caching/SKILL.md","docs/skills/epic-database/SKILL.md","docs/skills/epic-deployment/SKILL.md","docs/skills/epic-forms/SKILL.md","docs/skills/epic-permissions/SKILL.md","docs/skills/epic-react-patterns/SKILL.md","docs/skills/epic-routing/SKILL.md","docs/skills/epic-security/SKILL.md","docs/skills/epic-testing/SKILL.md","docs/skills/epic-ui-guidelines/SKILL.md"],"skills":{".cursor/rules/avoid-use-effect.mdc":"---\ndescription: \nglobs: *.tsx,*.jsx\nalwaysApply: false\n---\n### Avoid useEffect\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use ref callbacks, event handlers with\n`flushSync`, css, `useSyncExternalStore`, etc.\n\n```tsx\n// This example was ripped from the docs:\n// ✅ Good\nfunction ProductPage({ product, addToCart }) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n\nuseEffect(() => {\n\tsetCount(count + 1)\n}, [count])\n\n// ❌ Avoid\nfunction ProductPage({ product, addToCart }) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\taddToCart(product)\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n```\n\nThere are a lot more examples in the docs. `useEffect` is not banned or\nanything. There are just better ways to handle most cases.\n\nHere's an example of a situation where `useEffect` is appropriate:\n\n```tsx\n// ✅ Good\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\n\t\t\t// do something based on escape key being pressed\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n","docs/routing.md":"# Routing\n\nThe Epic Stack uses file-based routing with React Router. However, it's not\nusing the built-in routing convention of React Router and instead is using\n[react-router-auto-routes](https://github.com/kenn/react-router-auto-routes)\nwhich is a special implementation of the React Router convention that adds a few\nfeatures. You'll find it configured for the application in the `app/routes.ts`\nfile at the root of the app.\n\nWe'll defer to the `react-router-auto-routes` documentation for specifics, but\nan important thing for you to know as you get used to this convention is you can\nalways run `npx react-router routes` from the root of the app and it will output\nthe routes of your application in a JSX-like output that will reveal the routes\nthat will be generated based on your current file structure. Here's an example\nof the Epic Stack routes at the time of this writing:\n\n```\napp/routes\n├── $.tsx\n├── me.tsx\n├── _auth\n│   ├── forgot-password.tsx\n│   ├── login.tsx\n│   ├── logout.tsx\n│   ├── reset-password.tsx\n│   ├── signup.tsx\n│   ├── verify.tsx\n│   ├── auth.$provider\n│   │   ├── callback.ts\n│   │   └── index.ts\n│   ├── onboarding\n│   │   ├── $provider.tsx\n│   │   └── index.tsx\n│   └── webauthn\n│       ├── authentication.ts\n│       └── registration.ts\n├── _marketing\n│   ├── about.tsx\n│   ├── index.tsx\n│   ├── privacy.tsx\n│   ├── support.tsx\n│   ├── tos.tsx\n│   └── +logos\n│       ├── logos.ts\n│       └── ...\n├── _seo\n│   ├── robots[.]txt.ts\n│   └── sitemap[.]xml.ts\n├── admin\n│   └── cache\n│       ├── index.tsx\n│       ├── lru.$cacheKey.ts\n│       ├── sqlite.$cacheKey.ts\n│       └── sqlite.tsx\n├── resources\n│   ├── download-user-data.tsx\n│   ├── healthcheck.tsx\n│   ├── images.tsx\n│   └── theme-switch.tsx\n├── settings\n│   └── profile\n│       ├── _layout.tsx\n│       ├── change-email.tsx\n│       ├── connections.tsx\n│       ├── index.tsx\n│       ├── passkeys.tsx\n│       ├── password.tsx\n│       ├── password_.create.tsx\n│       ├── photo.tsx\n│       └── two-factor\n│           ├── _layout.tsx\n│           ├── disable.tsx\n│           ├── index.tsx\n│           └── verify.tsx\n└── users\n    ├── index.tsx\n    └── $username\n        ├── index.tsx\n        └── notes\n            ├── $noteId.tsx\n            ├── $noteId_.edit.tsx\n            ├── _layout.tsx\n            ├── index.tsx\n            └── new.tsx\n\n17 directories, 72 files\n```\n\n```tsx\n<Routes>\n\t<Route file=\"root.tsx\">\n\t\t<Route path=\"*\" file=\"routes/$.tsx\" />\n\t\t<Route\n\t\t\tpath=\"auth/:provider/callback\"\n\t\t\tfile=\"routes/_auth/auth.$provider/callback.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"auth/:provider\"\n\t\t\tindex\n\t\t\tfile=\"routes/_auth/auth.$provider/index.ts\"\n\t\t/>\n\t\t<Route path=\"forgot-password\" file=\"routes/_auth/forgot-password.tsx\" />\n\t\t<Route path=\"login\" file=\"routes/_auth/login.tsx\" />\n\t\t<Route path=\"logout\" file=\"routes/_auth/logout.tsx\" />\n\t\t<Route\n\t\t\tpath=\"onboarding/:provider\"\n\t\t\tfile=\"routes/_auth/onboarding/$provider.tsx\"\n\t\t/>\n\t\t<Route path=\"onboarding\" index file=\"routes/_auth/onboarding/index.tsx\" />\n\t\t<Route path=\"reset-password\" file=\"routes/_auth/reset-password.tsx\" />\n\t\t<Route path=\"signup\" file=\"routes/_auth/signup.tsx\" />\n\t\t<Route path=\"verify\" file=\"routes/_auth/verify.tsx\" />\n\t\t<Route\n\t\t\tpath=\"webauthn/authentication\"\n\t\t\tfile=\"routes/_auth/webauthn/authentication.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"webauthn/registration\"\n\t\t\tfile=\"routes/_auth/webauthn/registration.ts\"\n\t\t/>\n\t\t<Route path=\"about\" file=\"routes/_marketing/about.tsx\" />\n\t\t<Route index file=\"routes/_marketing/index.tsx\" />\n\t\t<Route path=\"privacy\" file=\"routes/_marketing/privacy.tsx\" />\n\t\t<Route path=\"support\" file=\"routes/_marketing/support.tsx\" />\n\t\t<Route path=\"tos\" file=\"routes/_marketing/tos.tsx\" />\n\t\t<Route path=\"robots.txt\" file=\"routes/_seo/robots[.]txt.ts\" />\n\t\t<Route path=\"sitemap.xml\" file=\"routes/_seo/sitemap[.]xml.ts\" />\n\t\t<Route path=\"admin/cache\" index file=\"routes/admin/cache/index.tsx\" />\n\t\t<Route\n\t\t\tpath=\"admin/cache/lru/:cacheKey\"\n\t\t\tfile=\"routes/admin/cache/lru.$cacheKey.ts\"\n\t\t/>\n\t\t<Route path=\"admin/cache/sqlite\" file=\"routes/admin/cache/sqlite.tsx\">\n\t\t\t<Route path=\":cacheKey\" file=\"routes/admin/cache/sqlite.$cacheKey.ts\" />\n\t\t</Route>\n\t\t<Route path=\"me\" file=\"routes/me.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/download-user-data\"\n\t\t\tfile=\"routes/resources/download-user-data.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"resources/healthcheck\"\n\t\t\tfile=\"routes/resources/healthcheck.tsx\"\n\t\t/>\n\t\t<Route path=\"resources/images\" file=\"routes/resources/images.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/theme-switch\"\n\t\t\tfile=\"routes/resources/theme-switch.tsx\"\n\t\t/>\n\t\t<Route path=\"settings/profile\" file=\"routes/settings/profile/_layout.tsx\">\n\t\t\t<Route\n\t\t\t\tpath=\"change-email\"\n\t\t\t\tfile=\"routes/settings/profile/change-email.tsx\"\n\t\t\t/>\n\t\t\t<Route\n\t\t\t\tpath=\"connections\"\n\t\t\t\tfile=\"routes/settings/profile/connections.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/settings/profile/index.tsx\" />\n\t\t\t<Route path=\"passkeys\" file=\"routes/settings/profile/passkeys.tsx\" />\n\t\t\t<Route path=\"password\" file=\"routes/settings/profile/password.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"password/create\"\n\t\t\t\tfile=\"routes/settings/profile/password_.create.tsx\"\n\t\t\t/>\n\t\t\t<Route path=\"photo\" file=\"routes/settings/profile/photo.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"two-factor\"\n\t\t\t\tfile=\"routes/settings/profile/two-factor/_layout.tsx\"\n\t\t\t>\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"disable\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/disable.tsx\"\n\t\t\t\t/>\n\t\t\t\t<Route index file=\"routes/settings/profile/two-factor/index.tsx\" />\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"verify\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/verify.tsx\"\n\t\t\t\t/>\n\t\t\t</Route>\n\t\t</Route>\n\t\t<Route\n\t\t\tpath=\"users/:username\"\n\t\t\tindex\n\t\t\tfile=\"routes/users/$username/index.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"users/:username/notes\"\n\t\t\tfile=\"routes/users/$username/notes/_layout.tsx\"\n\t\t>\n\t\t\t<Route path=\":noteId\" file=\"routes/users/$username/notes/$noteId.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\":noteId/edit\"\n\t\t\t\tfile=\"routes/users/$username/notes/$noteId_.edit.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/users/$username/notes/index.tsx\" />\n\t\t\t<Route path=\"new\" file=\"routes/users/$username/notes/new.tsx\" />\n\t\t</Route>\n\t\t<Route path=\"users\" index file=\"routes/users/index.tsx\" />\n\t</Route>\n</Routes>\n```\n\nBasically, react-router-auto-routes allows us to get the best of both worlds:\n\n- Colocation of routes to the code they use\n- Organized folder structure to keep routes together as needed\n\nIf you're familiar with the React Router routing convention, just think of it\nthis way, react-router-auto-routes takes `+` prefix for colocated modules.\n","docs/skills/epic-auth/SKILL.md":"---\nname: epic-auth\ndescription:\n  Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack\ncategories:\n  - authentication\n  - sessions\n  - oauth\n  - security\n---\n\n# Epic Stack: Authentication\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement user authentication\n- Work with sessions and cookies\n- Configure OAuth providers (GitHub, Google, etc.)\n- Implement 2FA (Two-Factor Authentication)\n- Implement WebAuthn/Passkeys\n- Handle login, signup, logout flows\n- Manage email verification\n- Implement password reset\n\n## Patterns and conventions\n\n### Authentication Philosophy\n\nFollowing Epic Web principles:\n\n**Least privilege** - Users should only have access to what they need, when they\nneed it. Sessions should have minimal permissions and expire appropriately.\nDon't grant more access than necessary.\n\n**Design to fail fast and early** - Validate authentication and authorization as\nearly as possible. Check session validity immediately, verify permissions before\nprocessing requests, and return clear errors quickly.\n\n**Example - Least privilege in sessions:**\n\n```typescript\n// ✅ Good - Minimal session data, explicit permissions\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId, // Only store user ID, not full user data\n\t},\n})\n\n// Session only grants access to this specific user\n// Permissions checked separately when needed\n\n// ❌ Avoid - Storing too much in session\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t\tuserRole: 'admin', // Don't store roles in session\n\t\tpermissions: ['all'], // Don't store permissions in session\n\t},\n})\n// Roles and permissions should be checked from database, not session\n```\n\n**Example - Fail fast authentication:**\n\n```typescript\n// ✅ Good - Validate authentication early\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check authentication immediately - fail fast\n\tconst userId = await requireUserId(request)\n\n\t// Check permissions early - fail fast\n\tawait requireUserWithPermission(request, 'read:note:own')\n\n\t// Only proceed if authenticated and authorized\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: userId },\n\t})\n\n\treturn { notes }\n}\n\n// ❌ Avoid - Delayed authentication check\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Process request first...\n\tconst notes = await prisma.note.findMany()\n\n\t// Check authentication at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed request\n\t\tthrow redirect('/login')\n\t}\n}\n```\n\n### Cookie-based Sessions\n\nEpic Stack uses cookie-based sessions for authentication. Sessions are stored in\nthe database and identified by signed cookies.\n\n**Session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nimport { createCookieSessionStorage } from 'react-router'\n\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true,\n\t\tsecrets: process.env.SESSION_SECRET.split(','),\n\t\tsecure: process.env.NODE_ENV === 'production',\n\t},\n})\n```\n\n### Get current user\n\n**Server-side:**\n\n```typescript\nimport { getUserId, requireUserId } from '#app/utils/auth.server.ts'\n\n// Get userId or null if not authenticated\nconst userId = await getUserId(request)\n\n// Require authenticated user (redirects to /login if not)\nconst userId = await requireUserId(request)\nconst userId = await requireUserId(request, { redirectTo: '/custom-login' })\n\n// Require that user is NOT authenticated\nimport { requireAnonymous } from '#app/utils/auth.server.ts'\nawait requireAnonymous(request) // Redirects to / if authenticated\n```\n\n**Client-side:**\n\n```typescript\nimport { useOptionalUser, useUser } from '#app/utils/user.ts'\n\n// Get user or undefined if not authenticated\nconst user = useOptionalUser()\n\n// Get authenticated user (throws error if not)\nconst user = useUser()\n```\n\n### Login with Email/Password\n\n**Validation schema:**\n\n```typescript\nconst LoginSchema = z.object({\n\tusername: UsernameSchema,\n\tpassword: z.string().min(1, 'Password is required'),\n\tredirectTo: z.string().optional(),\n\tremember: z.boolean().optional(),\n})\n```\n\n**Login action (fail fast):**\n\n```typescript\nimport { login } from '#app/utils/auth.server.ts'\nimport { handleNewSession } from './login.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate input early - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\t// Authenticate early - fail fast if invalid\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\t// Return error immediately - don't process further\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Only create session if authentication succeeded\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Signup with Email/Password\n\n**Signup action:**\n\n```typescript\nimport { signup } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate form...\n\n\tconst session = await signup({\n\t\temail,\n\t\tusername,\n\t\tpassword,\n\t\tname,\n\t})\n\n\t// Handle session and redirect...\n}\n```\n\n### OAuth Providers (GitHub, Google, etc.)\n\nEpic Stack uses `remix-auth` for OAuth providers.\n\n**Configure provider (GitHub example):**\n\n```typescript\n// app/utils/providers/github.server.ts\nimport { GitHubStrategy } from 'remix-auth-github'\n\nexport class GitHubProvider implements AuthProvider {\n\tgetAuthStrategy() {\n\t\treturn new GitHubStrategy(\n\t\t\t{\n\t\t\t\tclientID: process.env.GITHUB_CLIENT_ID,\n\t\t\t\tclientSecret: process.env.GITHUB_CLIENT_SECRET,\n\t\t\t\tcallbackURL: '/auth/github/callback',\n\t\t\t},\n\t\t\tasync ({ profile }) => {\n\t\t\t\t// Return user profile\n\t\t\t\treturn {\n\t\t\t\t\tid: profile.id,\n\t\t\t\t\temail: profile.emails[0].value,\n\t\t\t\t\tusername: profile.displayName,\n\t\t\t\t\tname: profile.displayName,\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\t}\n}\n```\n\n**Callback handler:**\n\n```typescript\n// app/routes/_auth/auth.$provider/callback.ts\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst providerName = ProviderNameSchema.parse(params.provider)\n\tconst authResult = await authenticator.authenticate(providerName, request)\n\n\tif (!authResult.success) {\n\t\tthrow redirectWithToast('/login', {\n\t\t\ttitle: 'Auth Failed',\n\t\t\tdescription: `Error authenticating with ${providerName}`,\n\t\t\ttype: 'error',\n\t\t})\n\t}\n\n\tconst { data: profile } = authResult\n\n\t// Check if connection exists\n\tconst existingConnection = await prisma.connection.findUnique({\n\t\twhere: {\n\t\t\tproviderName_providerId: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t},\n\t\t},\n\t})\n\n\t// If exists, create session\n\tif (existingConnection) {\n\t\treturn makeSession({ request, userId: existingConnection.userId })\n\t}\n\n\t// If email exists, link account\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { email: profile.email.toLowerCase() },\n\t})\n\tif (user) {\n\t\tawait prisma.connection.create({\n\t\t\tdata: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t\tuserId: user.id,\n\t\t\t},\n\t\t})\n\t\treturn makeSession({ request, userId: user.id })\n\t}\n\n\t// New user, go to onboarding\n\t// ...\n}\n```\n\n### WebAuthn/Passkeys\n\nEpic Stack supports authentication with passkeys using WebAuthn.\n\n**Loader to generate options:**\n\n```typescript\n// app/routes/_auth/webauthn/authentication.ts\nimport { generateAuthenticationOptions } from '@simplewebauthn/server'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst config = getWebAuthnConfig(request)\n\tconst options = await generateAuthenticationOptions({\n\t\trpID: config.rpID,\n\t\tuserVerification: 'preferred',\n\t})\n\n\tconst cookieHeader = await passkeyCookie.serialize({\n\t\tchallenge: options.challenge,\n\t})\n\n\treturn Response.json(\n\t\t{ options },\n\t\t{\n\t\t\theaders: { 'Set-Cookie': cookieHeader },\n\t\t},\n\t)\n}\n```\n\n**Action to verify authentication:**\n\n```typescript\nimport { verifyAuthenticationResponse } from '@simplewebauthn/server'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst cookie = await passkeyCookie.parse(request.headers.get('Cookie'))\n\n\tif (!cookie?.challenge) {\n\t\tthrow new Error('Authentication challenge not found')\n\t}\n\n\tconst { authResponse } = await request.json()\n\tconst passkey = await prisma.passkey.findUnique({\n\t\twhere: { id: authResponse.id },\n\t\tinclude: { user: true },\n\t})\n\n\tconst verification = await verifyAuthenticationResponse({\n\t\tresponse: authResponse,\n\t\texpectedChallenge: cookie.challenge,\n\t\texpectedOrigin: config.origin,\n\t\texpectedRPID: config.rpID,\n\t\tcredential: {\n\t\t\tid: authResponse.id,\n\t\t\tpublicKey: passkey.publicKey,\n\t\t\tcounter: Number(passkey.counter),\n\t\t},\n\t})\n\n\tif (!verification.verified) {\n\t\tthrow new Error('Authentication verification failed')\n\t}\n\n\t// Actualizar counter\n\tawait prisma.passkey.update({\n\t\twhere: { id: passkey.id },\n\t\tdata: { counter: BigInt(verification.authenticationInfo.newCounter) },\n\t})\n\n\t// Create sesión\n\tconst session = await prisma.session.create({\n\t\tdata: {\n\t\t\texpirationDate: getSessionExpirationDate(),\n\t\t\tuserId: passkey.userId,\n\t\t},\n\t})\n\n\treturn handleNewSession({ request, session, remember: true })\n}\n```\n\n### Two-Factor Authentication (2FA) with TOTP\n\nEpic Stack uses TOTP (Time-based One-Time Password) para 2FA.\n\n**Check if user has 2FA:**\n\n```typescript\nconst verification = await prisma.verification.findUnique({\n\twhere: {\n\t\ttarget_type: {\n\t\t\ttarget: session.userId,\n\t\t\ttype: twoFAVerificationType,\n\t\t},\n\t},\n})\nconst userHasTwoFactor = Boolean(verification)\n```\n\n**Handle session with 2FA:**\n\n```typescript\nexport async function handleNewSession({\n\trequest,\n\tsession,\n\tredirectTo,\n\tremember,\n}: {\n\trequest: Request\n\tsession: { userId: string; id: string; expirationDate: Date }\n\tredirectTo?: string\n\tremember: boolean\n}) {\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: {\n\t\t\ttarget_type: {\n\t\t\t\ttarget: session.userId,\n\t\t\t\ttype: twoFAVerificationType,\n\t\t\t},\n\t\t},\n\t})\n\tconst userHasTwoFactor = Boolean(verification)\n\n\tif (userHasTwoFactor) {\n\t\t// Save unverified session\n\t\tconst verifySession = await verifySessionStorage.getSession()\n\t\tverifySession.set(unverifiedSessionIdKey, session.id)\n\t\tverifySession.set(rememberKey, remember)\n\n\t\t// Redirect to 2FA verification\n\t\tconst redirectUrl = getRedirectToUrl({\n\t\t\trequest,\n\t\t\ttype: twoFAVerificationType,\n\t\t\ttarget: session.userId,\n\t\t\tredirectTo,\n\t\t})\n\t\treturn redirect(redirectUrl.toString(), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),\n\t\t\t},\n\t\t})\n\t} else {\n\t\t// User without 2FA, create session directly\n\t\tconst authSession = await authSessionStorage.getSession(\n\t\t\trequest.headers.get('cookie'),\n\t\t)\n\t\tauthSession.set(sessionKey, session.id)\n\t\treturn redirect(safeRedirect(redirectTo), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.commitSession(authSession, {\n\t\t\t\t\texpires: remember ? session.expirationDate : undefined,\n\t\t\t\t}),\n\t\t\t},\n\t\t})\n\t}\n}\n```\n\n**Verify 2FA code:**\n\n```typescript\nimport { prepareTOTP, verifyTOTP } from '@epic-web/totp'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: VerifySchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { code } = submission.value\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: { target_type: { target, type } },\n\t\tselect: {\n\t\t\tsecret: true,\n\t\t\talgorithm: true,\n\t\t\tperiod: true,\n\t\t\tdigits: true,\n\t\t},\n\t})\n\n\tif (!verification) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst isValid = verifyTOTP({\n\t\totp: code,\n\t\tsecret: verification.secret,\n\t\talgorithm: verification.algorithm as any,\n\t\tperiod: verification.period,\n\t\tdigits: verification.digits,\n\t})\n\n\tif (!isValid) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid code'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Verify session and complete login\n\treturn handleVerification({ request, submission })\n}\n```\n\n### Email Verification\n\nEpic Stack uses TOTP codes sent via email for verification.\n\n**Prepare verification:**\n\n```typescript\nimport { prepareVerification } from './verify.server.ts'\n\nconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\tperiod: 10 * 60, // 10 minutes\n\trequest,\n\ttype: 'onboarding',\n\ttarget: email,\n})\n\n// Enviar email con código y URL\nawait sendEmail({\n\tto: email,\n\tsubject: 'Welcome!',\n\treact: <VerificationEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n})\n\nreturn redirect(redirectTo.toString())\n```\n\n**Verify code:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/signup')\n\t}\n\n\treturn { target, type }\n}\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code (similar to 2FA)\n\t// ...\n}\n```\n\n### Password Reset\n\n**Request reset:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ForgotPasswordSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { email } = submission.value\n\n\t// Prepare verification\n\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\t\tperiod: 10 * 60,\n\t\trequest,\n\t\ttype: 'reset-password',\n\t\ttarget: email,\n\t})\n\n\t// Enviar email con código y URL\n\tawait sendEmail({\n\t\tto: email,\n\t\tsubject: 'Reset your password',\n\t\treact: <ResetPasswordEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n\t})\n\n\treturn redirect(redirectTo.toString())\n}\n```\n\n**Reset password:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code first (similar to email verification)\n\t// Then reset password\n\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ResetPasswordSchema,\n\t})\n\n\t// Verify that code is valid\n\t// ...\n\n\tconst { password } = submission.value\n\n\tawait resetUserPassword({\n\t\tusername,\n\t\tpassword,\n\t})\n\n\t// Destroy verification session and redirect to login\n\t// ...\n}\n```\n\n### Logout\n\n```typescript\nimport { logout } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\treturn logout({ request, redirectTo: '/' })\n}\n```\n\n### Session Management\n\n**Create session:**\n\n```typescript\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t},\n\tselect: { id: true, expirationDate: true },\n})\n```\n\n**Session expiration:**\n\n```typescript\nexport const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport const getSessionExpirationDate = () =>\n\tnew Date(Date.now() + SESSION_EXPIRATION_TIME)\n```\n\n**Destroy session:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { id: sessionId } })\n```\n\n**Destroy all user sessions:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { userId } })\n```\n\n## Common examples\n\n### Example 1: Complete login\n\n```typescript\n// app/routes/_auth/login.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Example 2: Protect route\n\n```typescript\n// app/routes/protected.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function ProtectedRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Datos protegidos */}</div>\n}\n```\n\n### Example 3: Route only for unauthenticated users\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireAnonymous(request)\n\treturn null\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed authentication checks**: Validate authentication and\n  authorization as early as possible - fail fast\n- ❌ **Granting excessive privileges**: Follow least privilege - only grant\n  access to what's needed, when it's needed\n- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user\n  ID), check permissions from database\n- ❌ **Not verifying session on each request**: Always use `getUserId` or\n  `requireUserId` in protected loaders/actions\n- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating\n  session\n- ❌ **Not destroying expired sessions**: Sessions must be verified against\n  `expirationDate` - check early\n- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and\n  cookie creation\n- ❌ **Forgetting to handle `remember`**: Make sure to respect user preference\n- ❌ **Not validating OAuth callbacks**: Always validate that provider exists\n  and result is successful - fail fast\n- ❌ **Not linking OAuth accounts**: If email exists, link the account instead\n  of creating duplicate\n- ❌ **Not updating counter in passkeys**: Always update counter after\n  successful verification\n\n## References\n\n- [Epic Stack Authentication Docs](../epic-stack/docs/authentication.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Remix Auth](https://github.com/sergiodxa/remix-auth)\n- [SimpleWebAuthn](https://simplewebauthn.dev/)\n- `app/utils/auth.server.ts` - Authentication utilities\n- `app/utils/session.server.ts` - Gestión de sesiones\n- `app/routes/_auth/login.server.ts` - Helpers de login\n- `app/routes/_auth/auth.$provider/callback.ts` - OAuth callback\n- `app/routes/_auth/webauthn/` - WebAuthn implementation\n","docs/skills/epic-caching/SKILL.md":"---\nname: epic-caching\ndescription:\n  Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack\ncategories:\n  - caching\n  - performance\n  - optimization\n---\n\n# Epic Stack: Caching\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Cache results of expensive queries\n- Cache responses from external APIs\n- Optimize performance of data that doesn't change frequently\n- Implement stale-while-revalidate\n- Manage cache invalidation\n- Integrate cache with server timing\n\n## Patterns and conventions\n\n### Caching Philosophy\n\nFollowing Epic Web principles:\n\n**Weigh the cost-benefit of performance optimizations** - Caching adds\ncomplexity. Only add cache when there's a clear, measurable benefit. Don't cache\n\"just in case\" - cache when you have a real performance problem that caching\nsolves.\n\n**When NOT to use cache:**\n\n- Data that changes frequently (cache invalidation becomes a problem)\n- Data that's already fast to fetch (no measurable benefit)\n- Data that's only fetched once (no benefit from caching)\n- Simple queries that don't need optimization\n- When cache invalidation logic becomes more complex than the problem it solves\n\n**Example - Evaluating cost-benefit:**\n\n```typescript\n// ✅ Good - Cache expensive external API call\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Expensive: External API call, rate limits, network latency\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour - reasonable for external data\n\t})\n}\n\n// ❌ Avoid - Caching simple, fast database query\nexport async function getUser({ userId }: { userId: string }) {\n\t// This query is already fast - caching adds complexity without benefit\n\treturn await cachified({\n\t\tkey: `user:${userId}`,\n\t\tcache,\n\t\tgetFreshValue: async () => {\n\t\t\t// Simple query, already fast\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: { id: true, username: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5,\n\t})\n\t// Better: Just query directly without cache\n}\n```\n\n### Two Types of Cache\n\nEpic Stack provides two types of cache:\n\n1. **SQLite Cache** - Long-lived, replicated with LiteFS\n   - Persistent across restarts\n   - Replicated across all instances\n   - Ideal for data that changes infrequently\n\n2. **LRU Cache** - Short-lived, in-memory\n   - Cleared on restart\n   - Not replicated (only on current instance)\n   - Ideal for deduplication and temporary cache\n\n### Using cachified\n\nEpic Stack uses `@epic-web/cachified` as an abstraction for cache management.\n\n**Basic import:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\n```\n\n**Basic structure:**\n\n```typescript\nexport async function getCachedData({\n\ttimings,\n}: {\n\ttimings?: Timings\n} = {}) {\n\treturn await cachified({\n\t\tkey: 'my-cache-key',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Get fresh data\n\t\t\treturn await fetchDataFromAPI()\n\t\t},\n\t\tcheckValue: z.object({\n\t\t\t/* schema */\n\t\t}), // Validation with Zod\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days\n\t})\n}\n```\n\n### Cache Keys\n\n**Naming conventions:**\n\n- Use format: `entity:identifier:data`\n- Examples:\n  - `user:${userId}:profile`\n  - `note:${noteId}:full`\n  - `api:github:events`\n  - `tito:scheduled-events`\n\n**Avoid:**\n\n- Keys that are too long\n- Keys with special characters\n- Keys that don't clearly identify the content\n\n### TTL (Time To Live)\n\n**Define TTL:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours in milliseconds\n})\n```\n\n**Null TTL to never expire:**\n\n```typescript\nttl: null, // Never expires (not recommended unless necessary)\n```\n\n### Stale-While-Revalidate (SWR)\n\nSWR allows returning stale data while fresh data is fetched in the background.\n\n**Example:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours - after this it's considered stale\n\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days - up to here returns stale while revalidating\n})\n```\n\n**Behavior:**\n\n- **Less than 24h**: Returns cache, no request made\n- **24h - 30 days**: Returns stale cache immediately, updates in background\n- **More than 30 days**: Waits for fresh data before returning\n\n### Validation with Zod\n\nAlways validate cached data with Zod:\n\n```typescript\nimport { z } from 'zod'\n\nconst EventSchema = z.object({\n\tid: z.string(),\n\ttitle: z.string(),\n\tdate: z.string(),\n})\n\nexport async function getEvents({ timings }: { timings?: Timings } = {}) {\n\treturn await cachified({\n\t\tkey: 'events:all',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch('https://api.example.com/events')\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: EventSchema.array(), // Validates it's an array of events\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\nIf cached data doesn't pass validation, fresh data is fetched.\n\n### Server Timing Integration\n\nIntegrate cache with server timing for monitoring:\n\n```typescript\nimport { type Timings } from '#app/utils/timing.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\tconst events = await getEvents({ timings })\n\n\t// Timings are automatically added to headers\n\treturn json(\n\t\t{ events },\n\t\t{\n\t\t\theaders: combineServerTimings(timings),\n\t\t},\n\t)\n}\n```\n\n### Cache Invalidation\n\n**Invalidate by key:**\n\n```typescript\nimport { cache } from '#app/utils/cache.server.ts'\n\nawait cache.delete('user:123:profile')\n```\n\n**Invalidate multiple keys:**\n\n```typescript\n// Search and delete matching keys\nimport { searchCacheKeys } from '#app/utils/cache.server.ts'\n\nconst keys = await searchCacheKeys('user:123', 100)\nawait Promise.all(keys.map((key) => cache.delete(key)))\n```\n\n**Invalidate entire SQLite cache:**\n\n```typescript\n// Use admin dashboard or\nawait cache.clear() // If available\n```\n\n### Using LRU Cache\n\nFor temporary data, use LRU cache directly:\n\n```typescript\nimport { lru } from '#app/utils/cache.server.ts'\n\n// LRU cache is useful for:\n// - Request deduplication\n// - Very temporary cache (< 5 minutes)\n// - Data that doesn't need to persist\n\nconst cachedValue = lru.get('temp-key')\nif (!cachedValue) {\n\tconst freshValue = await computeExpensiveValue()\n\tlru.set('temp-key', freshValue, { ttl: 1000 * 60 * 5 }) // 5 minutes\n\treturn freshValue\n}\nreturn cachedValue\n```\n\n### Multi-Region Cache\n\nWith LiteFS, SQLite cache is automatically replicated:\n\n**Behavior:**\n\n- Only the primary instance writes to cache\n- Replicas can read from cache\n- Writes are automatically synchronized\n\n**Best practices:**\n\n- Don't assume all writes are immediate\n- Use `ensurePrimary()` if you need to guarantee writes\n\n```typescript\nimport { ensurePrimary } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tawait ensurePrimary() // Ensure we're on primary instance\n\n\t// Invalidate cache\n\tawait cache.delete('my-key')\n\n\t// ...\n}\n```\n\n### Error Handling\n\n**Handle errors in getFreshValue:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: async () => {\n\t\ttry {\n\t\t\treturn await fetchData()\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to fetch fresh data:', error)\n\t\t\tthrow error // Re-throw so cachified handles it\n\t\t}\n\t},\n\t// If getFreshValue fails and there's stale cache, it returns it\n\tfallbackToCache: true, // Default: true\n})\n```\n\n### Cache Admin Dashboard\n\nEpic Stack includes a dashboard to manage cache:\n\n**Route:** `/admin/cache`\n\n**Features:**\n\n- View all cache keys\n- Search keys\n- View details of a key\n- Delete keys\n- Clear entire cache\n\n## Common examples\n\n### Example 1: Cache external API response\n\n```typescript\n// app/utils/api.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\nimport { z } from 'zod'\n\nconst GitHubEventSchema = z.object({\n\tid: z.string(),\n\ttype: z.string(),\n\tactor: z.object({\n\t\tlogin: z.string(),\n\t}),\n\tcreated_at: z.string(),\n})\n\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`GitHub API error: ${response.statusText}`)\n\t\t\t}\n\t\t\tconst data = await response.json()\n\t\t\treturn data\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\n### Example 2: Cache Prisma query\n\n```typescript\n// app/utils/user.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { z } from 'zod'\n\nconst UserStatsSchema = z.object({\n\ttotalNotes: z.number(),\n\ttotalLikes: z.number(),\n\tjoinDate: z.string(),\n})\n\nexport async function getUserStats({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `user:${userId}:stats`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst [totalNotes, totalLikes, user] = await Promise.all([\n\t\t\t\tprisma.note.count({ where: { ownerId: userId } }),\n\t\t\t\tprisma.like.count({ where: { userId } }),\n\t\t\t\tprisma.user.findUnique({\n\t\t\t\t\twhere: { id: userId },\n\t\t\t\t\tselect: { createdAt: true },\n\t\t\t\t}),\n\t\t\t])\n\n\t\t\treturn {\n\t\t\t\ttotalNotes,\n\t\t\t\ttotalLikes,\n\t\t\t\tjoinDate: user?.createdAt.toISOString() ?? '',\n\t\t\t}\n\t\t},\n\t\tcheckValue: UserStatsSchema,\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t\tstaleWhileRevalidate: 1000 * 60 * 60, // 1 hour\n\t})\n}\n```\n\n### Example 3: Invalidate cache after mutation\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// ... validate and create note\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle,\n\t\t\tcontent,\n\t\t\townerId: userId,\n\t\t},\n\t\tinclude: { owner: true },\n\t})\n\n\t// Invalidate related cache\n\tawait Promise.all([\n\t\tcache.delete(`user:${userId}:notes`),\n\t\tcache.delete(`user:${userId}:stats`),\n\t\tcache.delete(`note:${note.id}:full`),\n\t])\n\n\treturn redirect(`/users/${note.owner.username}/notes/${note.id}`)\n}\n```\n\n### Example 4: Cache with dependencies\n\n```typescript\nexport async function getUserWithNotes({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\tconst user = await cachified({\n\t\tkey: `user:${userId}:profile`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\tusername: true,\n\t\t\t\t\tname: true,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t\tcheckValue: z\n\t\t\t.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tusername: z.string(),\n\t\t\t\tname: z.string().nullable(),\n\t\t\t})\n\t\t\t.nullable(),\n\t\tttl: 1000 * 60 * 30, // 30 minutes\n\t})\n\n\tconst notes = await cachified({\n\t\tkey: `user:${userId}:notes`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.note.findMany({\n\t\t\t\twhere: { ownerId: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\ttitle: true,\n\t\t\t\t\tupdatedAt: true,\n\t\t\t\t},\n\t\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\t})\n\t\t},\n\t\tcheckValue: z.array(\n\t\t\tz.object({\n\t\t\t\tid: z.string(),\n\t\t\t\ttitle: z.string(),\n\t\t\t\tupdatedAt: z.date(),\n\t\t\t}),\n\t\t),\n\t\tttl: 1000 * 60 * 10, // 10 minutes\n\t})\n\n\treturn { user, notes }\n}\n```\n\n### Example 5: Use LRU for deduplication\n\n```typescript\n// Avoid multiple simultaneous requests to the same URL\nconst requestCache = new Map<string, Promise<any>>()\n\nexport async function fetchWithDedup(url: string) {\n\tif (requestCache.has(url)) {\n\t\treturn requestCache.get(url)\n\t}\n\n\tconst promise = fetch(url).then((res) => res.json())\n\trequestCache.set(url, promise)\n\n\t// Clean up after 1 second\n\tsetTimeout(() => {\n\t\trequestCache.delete(url)\n\t}, 1000)\n\n\treturn promise\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Caching without measuring benefit**: Only add cache when there's a clear,\n  measurable performance problem\n- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to\n  fetch - it adds complexity without benefit\n- ❌ **Caching frequently changing data**: Cache invalidation becomes more\n  complex than the problem it solves\n- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive\n  personal data\n- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely\n  necessary\n- ❌ **Not validating cached data**: Always use `checkValue` with Zod to\n  validate data\n- ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations\n- ❌ **Assuming cache always works**: Cache can fail, always handle errors\n- ❌ **Keys too long or ambiguous**: Use consistent and descriptive format\n- ❌ **Not using timings**: Integrate with server timing for monitoring\n- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when\n  appropriate\n- ❌ **Over-caching**: Too much caching makes the system harder to understand\n  and debug\n\n## References\n\n- [Epic Stack Caching Docs](../epic-stack/docs/caching.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [@epic-web/cachified](https://www.npmjs.com/package/@epic-web/cachified)\n- `app/utils/cache.server.ts` - Cache implementation\n- `app/routes/admin/cache/` - Admin dashboard\n- `app/utils/timing.server.ts` - Server timing utilities\n","docs/skills/epic-database/SKILL.md":"---\nname: epic-database\ndescription: Guide on Prisma, SQLite, and LiteFS for Epic Stack\ncategories:\n  - database\n  - prisma\n  - sqlite\n  - litefs\n---\n\n# Epic Stack: Database\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Design database schema with Prisma\n- Create migrations\n- Work with SQLite and LiteFS\n- Optimize queries and performance\n- Create seed scripts\n- Work with multi-region deployments\n- Manage backups and restores\n\n## Patterns and conventions\n\n### Database Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Only fetch the data you actually need. Use\n`select` to fetch specific fields instead of entire models. Avoid over-fetching\ndata \"just in case\" - fetch what you need, when you need it.\n\n**Pragmatism over purity** - Optimize queries when there's a measurable benefit,\nbut don't over-optimize prematurely. Simple, readable queries are often better\nthan complex optimized ones. Add indexes when queries are slow, not before.\n\n**Example - Fetch only what you need:**\n\n```typescript\n// ✅ Good - Fetch only needed fields\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only fetch what you actually use\n\t},\n})\n\n// ❌ Avoid - Fetching everything\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches all fields including password hash, email, etc.\n})\n```\n\n**Example - Pragmatic optimization:**\n\n```typescript\n// ✅ Good - Simple query first, optimize if needed\nconst notes = await prisma.note.findMany({\n\twhere: { ownerId: userId },\n\tselect: { id: true, title: true, updatedAt: true },\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n})\n\n// Only add indexes if this query is actually slow\n// Don't pre-optimize\n\n// ❌ Avoid - Over-optimizing before measuring\n// Adding complex indexes, joins, etc. before knowing if it's needed\n```\n\n### Prisma Schema\n\nEpic Stack uses Prisma with SQLite as the database.\n\n**Basic configuration:**\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"typedSql\"]\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n```\n\n**Basic model:**\n\n```prisma\nmodel User {\n  id        String   @id @default(cuid())\n  email     String   @unique\n  username  String   @unique\n  name      String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  notes     Note[]\n  roles     Role[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  title   String\n  content String\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n  @@index([ownerId, updatedAt])\n}\n```\n\n### CUID2 for IDs\n\nEpic Stack uses CUID2 to generate unique IDs.\n\n**Advantages:**\n\n- Globally unique\n- Sortable\n- Secure (no exposed information)\n- URL-friendly\n\n**Example:**\n\n```prisma\nmodel User {\n  id String @id @default(cuid()) // Automatically generates CUID2\n}\n```\n\n### Timestamps\n\n**Standard fields:**\n\n```prisma\nmodel User {\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt // Automatically updated\n}\n```\n\n### Relationships\n\n**One-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n}\n```\n\n**One-to-One:**\n\n```prisma\nmodel User {\n  id      String  @id @default(cuid())\n  image   UserImage?\n}\n\nmodel UserImage {\n  id        String @id @default(cuid())\n  user      User   @relation(fields: [userId], references: [id])\n  userId    String @unique\n}\n```\n\n**Many-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  roles Role[]\n}\n\nmodel Role {\n  id    String @id @default(cuid())\n  users User[]\n}\n```\n\n### Indexes\n\n**Create indexes:**\n\n```prisma\nmodel Note {\n  id      String @id @default(cuid())\n  ownerId String\n  updatedAt DateTime\n\n  @@index([ownerId])              // Simple index\n  @@index([ownerId, updatedAt])   // Composite index\n}\n```\n\n**Best practices:**\n\n- Index foreign keys\n- Index fields used in `where` frequently\n- Index fields used in `orderBy`\n- Use composite indexes for complex queries\n\n### Cascade Delete\n\n**Configure cascade:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id], onDelete: Cascade)\n  ownerId String\n}\n```\n\n**Options:**\n\n- `onDelete: Cascade` - Deletes children when parent is deleted\n- `onDelete: SetNull` - Sets to null when parent is deleted\n- `onDelete: Restrict` - Prevents deletion if there are children\n\n### Migrations\n\n**Create migration:**\n\n```bash\nnpx prisma migrate dev --name add_user_field\n```\n\n**Apply migrations in production:**\n\n```bash\nnpx prisma migrate deploy\n```\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`.\n\n**\"Widen then Narrow\" strategy for zero-downtime:**\n\n1. **Widen app** - App accepts A or B\n2. **Widen db** - DB provides A and B, app writes to both\n3. **Narrow app** - App only uses B\n4. **Narrow db** - DB only provides B\n\n**Example: Rename field `name` to `firstName` and `lastName`:**\n\n```prisma\n// Step 1: Widen app (accepts both)\nmodel User {\n  id        String @id @default(cuid())\n  name      String?  // Deprecated\n  firstName String?  // New\n  lastName  String?  // New\n}\n\n// Step 2: Widen db (migration copies data)\n// In SQL migration:\nALTER TABLE User ADD COLUMN firstName TEXT;\nALTER TABLE User ADD COLUMN lastName TEXT;\nUPDATE User SET firstName = name;\n\n// Step 3: Narrow app (only uses new fields)\n// Code only uses firstName and lastName\n\n// Step 4: Narrow db (removes old field)\nALTER TABLE User DROP COLUMN name;\n```\n\n### Prisma Client\n\n**Import Prisma Client:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n```\n\n**Basic query:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n})\n```\n\n**Specific select:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\temail: true,\n\t\tusername: true,\n\t\t// Don't include password or sensitive data\n\t},\n})\n```\n\n**Include relations:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tinclude: {\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t},\n\t\troles: true,\n\t},\n})\n```\n\n**Complex queries:**\n\n```typescript\nconst notes = await prisma.note.findMany({\n\twhere: {\n\t\townerId: userId,\n\t\ttitle: { contains: searchTerm },\n\t},\n\tselect: {\n\t\tid: true,\n\t\ttitle: true,\n\t\tupdatedAt: true,\n\t},\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n\tskip: (page - 1) * 20,\n})\n```\n\n### Transactions\n\n**Use transactions:**\n\n```typescript\nawait prisma.$transaction(async (tx) => {\n\tconst user = await tx.user.create({\n\t\tdata: {\n\t\t\temail,\n\t\t\tusername,\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tawait tx.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Welcome',\n\t\t\tcontent: 'Welcome to the app!',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\treturn user\n})\n```\n\n### SQLite con LiteFS\n\n**Multi-region with LiteFS:**\n\n- Only the primary instance can write\n- Replicas can only read\n- Writes are automatically replicated\n\n**Check primary instance:**\n\n```typescript\nimport { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Ensure we're on primary instance for writes\n\tawait ensurePrimary()\n\n\t// Now we can write safely\n\tawait prisma.user.create({\n\t\tdata: {\n\t\t\t/* ... */\n\t\t},\n\t})\n}\n```\n\n**Get instance information:**\n\n```typescript\nimport { getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nconst { currentIsPrimary, primaryInstance } = await getInstanceInfo()\n\nif (currentIsPrimary) {\n\t// Can write\n} else {\n\t// Read-only, redirect to primary if necessary\n}\n```\n\n### Seed Scripts\n\n**Create seed:**\n\n```typescript\n// prisma/seed.ts\nimport { prisma } from '#app/utils/db.server.ts'\n\nasync function seed() {\n\t// Create roles\n\tawait prisma.role.createMany({\n\t\tdata: [\n\t\t\t{ name: 'user', description: 'Standard user' },\n\t\t\t{ name: 'admin', description: 'Administrator' },\n\t\t],\n\t})\n\n\t// Create users\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n\nseed()\n\t.catch((e) => {\n\t\tconsole.error(e)\n\t\tprocess.exit(1)\n\t})\n\t.finally(async () => {\n\t\tawait prisma.$disconnect()\n\t})\n```\n\n**Run seed:**\n\n```bash\nnpx prisma db seed\n# Or directly:\nnpx tsx prisma/seed.ts\n```\n\n### Query Optimization\n\n**Guidelines (pragmatic approach):**\n\n- Use `select` to fetch only needed fields - do as little as possible\n- Use selective `include` - only include relations you actually use\n- Index fields used in `where` and `orderBy` - but only if queries are slow\n- Use composite indexes for complex queries - when you have a real performance\n  problem\n- Avoid `select: true` (fetches everything) - be explicit about what you need\n- Measure first, optimize second - don't pre-optimize\n\n**Optimized example (do as little as possible):**\n\n```typescript\n// ❌ Avoid: Fetches everything unnecessarily\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches password hash, email, all relations, etc.\n})\n\n// ✅ Good: Only needed fields - do as little as possible\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only what you actually use\n\t},\n})\n\n// ✅ Better: With selective relations (only if you need them)\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\ttake: 10, // Only fetch what you need\n\t\t},\n\t},\n})\n```\n\n### Prisma Query Logging\n\n**Configure logging:**\n\n```typescript\n// app/utils/db.server.ts\nconst client = new PrismaClient({\n\tlog: [\n\t\t{ level: 'query', emit: 'event' },\n\t\t{ level: 'error', emit: 'stdout' },\n\t\t{ level: 'warn', emit: 'stdout' },\n\t],\n})\n\nclient.$on('query', async (e) => {\n\tif (e.duration < 20) return // Only log slow queries\n\n\tconsole.info(`prisma:query - ${e.duration}ms - ${e.query}`)\n})\n```\n\n### Database URL\n\n**Development:**\n\n```bash\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**Production (Fly.io):**\n\n```bash\nDATABASE_URL=file:/litefs/data/sqlite.db\n```\n\n### Connecting to DB in Production\n\n**SSH to Fly instance:**\n\n```bash\nfly ssh console --app [YOUR_APP_NAME]\n```\n\n**Connect to DB CLI:**\n\n```bash\nfly ssh console -C database-cli --app [YOUR_APP_NAME]\n```\n\n**Prisma Studio:**\n\n```bash\n# Terminal 1: Start Prisma Studio\nfly ssh console -C \"npx prisma studio\" -s --app [YOUR_APP_NAME]\n\n# Terminal 2: Local proxy\nfly proxy 5556:5555 --app [YOUR_APP_NAME]\n\n# Open in browser\n# http://localhost:5556\n```\n\n## Common examples\n\n### Example 1: Create model with relations\n\n```prisma\nmodel Post {\n  id        String   @id @default(cuid())\n  title     String\n  content   String\n  published Boolean  @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)\n  authorId String\n\n  comments Comment[]\n  tags     Tag[]\n\n  @@index([authorId])\n  @@index([authorId, published])\n  @@index([published, updatedAt])\n}\n\nmodel Comment {\n  id      String @id @default(cuid())\n  content String\n\n  createdAt DateTime @default(now())\n\n  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)\n  postId String\n\n  author   User   @relation(fields: [authorId], references: [id])\n  authorId String\n\n  @@index([postId])\n  @@index([authorId])\n}\n```\n\n### Example 2: Complex query with pagination\n\n```typescript\nexport async function getPosts({\n\tuserId,\n\tpage = 1,\n\tperPage = 20,\n\tpublished,\n}: {\n\tuserId?: string\n\tpage?: number\n\tperPage?: number\n\tpublished?: boolean\n}) {\n\tconst where: Prisma.PostWhereInput = {}\n\n\tif (userId) {\n\t\twhere.authorId = userId\n\t}\n\tif (published !== undefined) {\n\t\twhere.published = published\n\t}\n\n\tconst [posts, total] = await Promise.all([\n\t\tprisma.post.findMany({\n\t\t\twhere,\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t\tupdatedAt: true,\n\t\t\t\tauthor: {\n\t\t\t\t\tselect: {\n\t\t\t\t\t\tid: true,\n\t\t\t\t\t\tusername: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\ttake: perPage,\n\t\t\tskip: (page - 1) * perPage,\n\t\t}),\n\t\tprisma.post.count({ where }),\n\t])\n\n\treturn {\n\t\tposts,\n\t\ttotal,\n\t\tpages: Math.ceil(total / perPage),\n\t}\n}\n```\n\n### Example 3: Transaction with multiple operations\n\n```typescript\nexport async function createPostWithTags({\n\tauthorId,\n\ttitle,\n\tcontent,\n\ttagNames,\n}: {\n\tauthorId: string\n\ttitle: string\n\tcontent: string\n\ttagNames: string[]\n}) {\n\treturn await prisma.$transaction(async (tx) => {\n\t\t// Create tags if they don't exist\n\t\tawait Promise.all(\n\t\t\ttagNames.map((name) =>\n\t\t\t\ttx.tag.upsert({\n\t\t\t\t\twhere: { name },\n\t\t\t\t\tupdate: {},\n\t\t\t\t\tcreate: { name },\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\t// Create post\n\t\tconst post = await tx.post.create({\n\t\t\tdata: {\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\tauthorId,\n\t\t\t\ttags: {\n\t\t\t\t\tconnect: tagNames.map((name) => ({ name })),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\n\t\treturn post\n\t})\n}\n```\n\n### Example 4: Seed with related data\n\n```typescript\nasync function seed() {\n\t// Create permissions\n\tconst permissions = await Promise.all([\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'create',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can create own notes',\n\t\t\t},\n\t\t}),\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'read',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can read own notes',\n\t\t\t},\n\t\t}),\n\t])\n\n\t// Create roles with permissions\n\tconst userRole = await prisma.role.create({\n\t\tdata: {\n\t\t\tname: 'user',\n\t\t\tdescription: 'Standard user',\n\t\t\tpermissions: {\n\t\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t\t},\n\t\t},\n\t})\n\n\t// Create user with role\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: {\n\t\t\t\tconnect: { id: userRole.id },\n\t\t\t},\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need -\n  do as little as possible\n- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add\n  indexes \"just in case\"\n- ❌ **Not using indexes when needed**: Index foreign keys and fields used in\n  frequent queries, but only if they're actually slow\n- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when\n  you need them\n- ❌ **Not using transactions for related operations**: Always use transactions\n  when multiple operations must be atomic\n- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in\n  production\n- ❌ **Breaking migrations without strategy**: Use \"widen then narrow\" for\n  zero-downtime\n- ❌ **Not validating data before inserting**: Always validate with Zod before\n  create/update\n- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when\n  parent is deleted\n- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or\n  others\n- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but\n  ensure in scripts\n- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries\n  over complex optimized ones unless there's a real problem\n\n## References\n\n- [Epic Stack Database Docs](../epic-stack/docs/database.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Prisma Documentation](https://www.prisma.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [SQLite Documentation](https://www.sqlite.org/docs.html)\n- `prisma/schema.prisma` - Complete schema\n- `prisma/seed.ts` - Seed example\n- `app/utils/db.server.ts` - Prisma Client setup\n- `app/utils/litefs.server.ts` - LiteFS utilities\n","docs/skills/epic-deployment/SKILL.md":"---\nname: epic-deployment\ndescription:\n  Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack\ncategories:\n  - deployment\n  - fly-io\n  - ci-cd\n  - docker\n---\n\n# Epic Stack: Deployment\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure deployment on Fly.io\n- Setup multi-region deployment\n- Configure CI/CD with GitHub Actions\n- Manage secrets in production\n- Configure healthchecks\n- Work with LiteFS and volumes\n- Local deployment with Docker\n\n## Patterns and conventions\n\n### Fly.io Configuration\n\nEpic Stack uses Fly.io for hosting with configuration in `fly.toml`.\n\n**Basic configuration:**\n\n```toml\n# fly.toml\napp = \"your-app-name\"\nprimary_region = \"sjc\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\n\n[build]\ndockerfile = \"/other/Dockerfile\"\nignorefile = \"/other/Dockerfile.dockerignore\"\n\n[mounts]\nsource = \"data\"\ndestination = \"/data\"\n```\n\n### Primary Region\n\n**Configure primary region:**\n\n```toml\nprimary_region = \"sjc\" # Change according to your location\n```\n\n**Important:** The primary region must be the same for:\n\n- `primary_region` en `fly.toml`\n- Region del volume `data`\n- `PRIMARY_REGION` en variables de entorno\n\n### LiteFS Configuration\n\n**Configuration in `other/litefs.yml`:**\n\n```yaml\nfuse:\n  dir: '${LITEFS_DIR}'\n\ndata:\n  dir: '/data/litefs'\n\nproxy:\n  addr: ':${INTERNAL_PORT}'\n  target: 'localhost:${PORT}'\n  db: '${DATABASE_FILENAME}'\n\nlease:\n  type: 'consul'\n  candidate: ${FLY_REGION == PRIMARY_REGION}\n  promote: true\n  advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'\n  consul:\n    url: '${FLY_CONSUL_URL}'\n    key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}'\n\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n  - cmd: sqlite3 $DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: sqlite3 $CACHE_DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: npx prisma generate --sql\n  - cmd: npm start\n```\n\n### Healthchecks\n\n**Configuration in `fly.toml`:**\n\n```toml\n[[services.http_checks]]\ninterval = \"10s\"\ngrace_period = \"5s\"\nmethod = \"get\"\npath = \"/resources/healthcheck\"\nprotocol = \"http\"\ntimeout = \"2s\"\ntls_skip_verify = false\n```\n\n**Healthcheck implementation:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Verify DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\tconsole.log('healthcheck ❌', { error })\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Environment Variables\n\n**Secrets in Fly.io:**\n\n```bash\n# Generate secrets\nfly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\nfly secrets set HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\n\n# List secrets\nfly secrets list --app [YOUR_APP_NAME]\n\n# Delete secret\nfly secrets unset SECRET_NAME --app [YOUR_APP_NAME]\n```\n\n**Common secrets:**\n\n- `SESSION_SECRET` - Secret for signing session cookies\n- `HONEYPOT_SECRET` - Secret for honeypot fields\n- `DATABASE_URL` - Automatically configured by LiteFS\n- `CACHE_DATABASE_PATH` - Automatically configured\n- `RESEND_API_KEY` - For sending emails (optional)\n- `TIGRIS_*` - For image storage (automatic)\n- `SENTRY_DSN` - For error monitoring (optional)\n\n### Volumes\n\n**Create volume:**\n\n```bash\nfly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]\n```\n\n**List volumes:**\n\n```bash\nfly volumes list --app [YOUR_APP_NAME]\n```\n\n**Expand volume:**\n\n```bash\nfly volumes extend <volume-id> --size 10 --app [YOUR_APP_NAME]\n```\n\n### Multi-Region Deployment\n\n**Deploy to multiple regions:**\n\n```bash\n# Deploy in primary region (more instances)\nfly scale count 2 --region sjc --app [YOUR_APP_NAME]\n\n# Deploy in secondary regions (read-only)\nfly scale count 1 --region ams --app [YOUR_APP_NAME]\nfly scale count 1 --region syd --app [YOUR_APP_NAME]\n```\n\n**Verify instances:**\n\n```bash\nfly status --app [YOUR_APP_NAME]\n# The ROLE column will show \"primary\" or \"replica\"\n```\n\n### Consul Setup\n\n**Attach Consul:**\n\n```bash\nfly consul attach --app [YOUR_APP_NAME]\n```\n\n**Consul manages:**\n\n- Which instance is primary\n- Automatic failover\n- Data replication\n\n### GitHub Actions CI/CD\n\n**Basic workflow:**\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n**Complete configuration:**\n\n- Deploy to `production` from `main` branch\n- Deploy to `staging` from `dev` branch\n- Tests before deploy (optional)\n\n### Deployable Commits\n\nFollowing Epic Web principles:\n\n**Deployable commits** - Every commit to the main branch should be deployable.\nThis means:\n\n- The code should be in a working state\n- Tests should pass\n- The application should build successfully\n- No \"WIP\" or \"TODO\" commits that break the build\n\n**Example - Deployable commit workflow:**\n\n```bash\n# ✅ Good - Each commit is deployable\ngit commit -m \"Add user profile page\"\n# This commit is complete, tested, and deployable\n\ngit commit -m \"Fix login redirect bug\"\n# This commit fixes a bug and is deployable\n\n# ❌ Avoid - Non-deployable commits\ngit commit -m \"WIP: working on feature\"\n# This commit might not work, not deployable\n\ngit commit -m \"Add feature (tests failing)\"\n# This commit breaks the build, not deployable\n```\n\n**Benefits:**\n\n- Easy rollback - any commit can be deployed\n- Continuous deployment - deploy any time\n- Clear history - each commit represents a working state\n- Faster recovery - can deploy any previous commit\n\n### Small and Short Lived Merge Requests\n\nFollowing Epic Web principles:\n\n**Small and short lived merge requests** - Keep PRs small and merge them\nquickly. Large PRs are hard to review, risky to merge, and slow down the team.\n\n**Guidelines:**\n\n- **Small PRs** - Focus on one feature or fix per PR\n- **Short-lived** - Merge within a day or two, not weeks\n- **Reviewable** - PRs should be reviewable in 30 minutes or less\n- **Independent** - Each PR should be independently deployable\n\n**Example - Small, focused PR:**\n\n```bash\n# ✅ Good - Small, focused PR\n# PR: \"Add email validation to signup form\"\n# - Only changes signup validation\n# - Includes tests\n# - Can be reviewed quickly\n# - Can be merged and deployed independently\n\n# ❌ Avoid - Large, complex PR\n# PR: \"Refactor authentication system and add 2FA and OAuth\"\n# - Too many changes at once\n# - Hard to review\n# - Risky to merge\n# - Takes days to review\n```\n\n**Benefits:**\n\n- Faster reviews - easier to understand and review\n- Lower risk - smaller changes are less risky\n- Faster feedback - get feedback sooner\n- Easier rollback - smaller changes are easier to revert\n- Better collaboration - team can work in parallel on different small PRs\n\n**When PRs get too large:**\n\n- Split into multiple smaller PRs\n- Use feature flags to merge incrementally\n- Break down into logical pieces\n\n### Tigris Object Storage\n\n**Create storage:**\n\n```bash\nfly storage create --app [YOUR_APP_NAME]\n```\n\n**This creates:**\n\n- Tigris bucket\n- Automatic environment variables:\n  - `TIGRIS_ENDPOINT`\n  - `TIGRIS_ACCESS_KEY_ID`\n  - `TIGRIS_SECRET_ACCESS_KEY`\n  - `TIGRIS_BUCKET_NAME`\n\n### Database Migrations\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`:\n\n```yaml\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n```\n\n**Note:** Only the primary instance runs migrations (`if-candidate: true`).\n\n### Database Backups\n\n**Create backup:**\n\n```bash\n# SSH to instance\nfly ssh console --app [YOUR_APP_NAME]\n\n# Create backup\nmkdir /backups\nlitefs export -name sqlite.db /backups/backup-$(date +%Y-%m-%d).db\nexit\n\n# Download backup\nfly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME]\n```\n\n**Restore backup:**\n\n```bash\n# Upload backup\nfly ssh sftp shell --app [YOUR_APP_NAME]\nput backup-2024-01-01.db\n# Ctrl+C to exit\n\n# SSH and restore\nfly ssh console --app [YOUR_APP_NAME]\nlitefs import -name sqlite.db /backup-2024-01-01.db\nexit\n```\n\n### Deployment Local\n\n**Deploy con Fly CLI:**\n\n```bash\nfly deploy\n```\n\n**Deploy con Docker:**\n\n```bash\n# Build\ndocker build -t epic-stack . -f other/Dockerfile \\\n  --build-arg COMMIT_SHA=$(git rev-parse --short HEAD)\n\n# Run\ndocker run -d \\\n  -p 8081:8081 \\\n  -e SESSION_SECRET='secret' \\\n  -e HONEYPOT_SECRET='secret' \\\n  -e FLY='false' \\\n  -v ~/litefs:/litefs \\\n  epic-stack\n```\n\n### Zero-Downtime Deploys\n\n**Strategy:**\n\n- Deploy to multiple instances\n- Automatic blue-green deployment\n- Healthchecks verify app is ready\n- Auto-rollback if healthcheck fails\n\n**Configuration:**\n\n```toml\n[experimental]\nauto_rollback = true\n```\n\n### Monitoring\n\n**View logs:**\n\n```bash\nfly logs --app [YOUR_APP_NAME]\n```\n\n**View metrics:**\n\n```bash\nfly dashboard --app [YOUR_APP_NAME]\n# Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring\n```\n\n**Sentry (opcional):**\n\n```bash\nfly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME]\n```\n\n## Common examples\n\n### Example 1: Complete initial setup\n\n```bash\n# 1. Create apps\nfly apps create my-app\nfly apps create my-app-staging\n\n# 2. Configure secrets\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  --app my-app\n\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  ALLOW_INDEXING=false \\\n  --app my-app-staging\n\n# 3. Create volumes\nfly volumes create data --region sjc --size 1 --app my-app\nfly volumes create data --region sjc --size 1 --app my-app-staging\n\n# 4. Attach Consul\nfly consul attach --app my-app\nfly consul attach --app my-app-staging\n\n# 5. Create storage\nfly storage create --app my-app\nfly storage create --app my-app-staging\n\n# 6. Deploy\nfly deploy --app my-app\n```\n\n### Example 2: Multi-region setup\n\n```bash\n# First region (primary) - 2 instances\nfly scale count 2 --region sjc --app my-app\n\n# Secondary regions - 1 instance each\nfly scale count 1 --region ams --app my-app\nfly scale count 1 --region syd --app my-app\n\n# Verify\nfly status --app my-app\n```\n\n### Example 3: GitHub Actions workflow\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy-production:\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\n  deploy-staging:\n    if: github.ref == 'refs/heads/dev'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app-staging\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Example 4: Deploy with migrations\n\n```bash\n# Create migration\nnpx prisma migrate dev --name add_field\n\n# Commit and push\ngit add .\ngit commit -m \"Add field\"\ngit push origin main\n\n# GitHub Actions automatically runs:\n# 1. Build\n# 2. Deploy\n# 3. litefs.yml runs: npx prisma migrate deploy (only on primary)\n```\n\n## Common mistakes to avoid\n\n- ❌ **Non-deployable commits**: Every commit to main should be deployable - no\n  WIP or broken commits\n- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are\n  hard to review and risky\n- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml`\n  matches the volume region\n- ❌ **Secrets not configured**: Configure all secrets before first deploy\n- ❌ **Volume not created**: Create the `data` volume before deploy\n- ❌ **Consul not attached**: Attach Consul before first deploy\n- ❌ **Migrations on replicas**: Only the primary instance should run migrations\n- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime\n  deploys\n- ❌ **Deploy breaking changes without strategy**: Use \"widen then narrow\" for\n  migrations\n- ❌ **Secrets in code**: Never commit secrets, use `fly secrets`\n- ❌ **Not making backups**: Make regular database backups\n- ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets\n\n## References\n\n- [Epic Stack Deployment Docs](../epic-stack/docs/deployment.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Fly.io Documentation](https://fly.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [Fly.io CLI Reference](https://fly.io/docs/flyctl/)\n- `fly.toml` - Fly.io configuration\n- `other/litefs.yml` - LiteFS configuration\n- `other/Dockerfile` - Deployment Dockerfile\n- `.github/workflows/deploy.yml` - CI/CD workflow\n\n### Preview Deployments (Inspired by Vercel Deploy Claimable)\n\nEpic Stack can implement preview deployments similar to Vercel's deploy\nclaimable pattern.\n\n**✅ Good - Preview deployments for pull requests:**\n\n```yaml\n# .github/workflows/preview-deploy.yml\nname: Preview Deploy\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  preview:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Deploy preview\n        run: |\n          # Create or reuse preview app\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps list | grep \"$PREVIEW_APP\" || flyctl apps create \"$PREVIEW_APP\"\n\n          # Deploy to preview app\n          flyctl deploy --app \"$PREVIEW_APP\" --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n      - name: Comment preview URL\n        uses: actions/github-script@v7\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: context.issue.number,\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `🚀 Preview deployment: https://$PREVIEW_APP.fly.dev`\n            })\n```\n\n**✅ Good - Auto-cleanup preview deployments:**\n\n```yaml\n# .github/workflows/cleanup-preview.yml\nname: Cleanup Preview\n\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Destroy preview app\n        run: |\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps destroy \"$PREVIEW_APP\" --yes\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Environment Detection\n\n**✅ Good - Detect deployment environment:**\n\n```typescript\n// app/utils/env.server.ts\nexport function getDeploymentEnv():\n\t| 'production'\n\t| 'staging'\n\t| 'preview'\n\t| 'development' {\n\tif (process.env.NODE_ENV === 'development') {\n\t\treturn 'development'\n\t}\n\n\t// Preview deployments\n\tif (process.env.FLY_APP_NAME?.includes('pr-')) {\n\t\treturn 'preview'\n\t}\n\n\t// Staging environment\n\tif (process.env.FLY_APP_NAME?.includes('staging')) {\n\t\treturn 'staging'\n\t}\n\n\t// Production\n\treturn 'production'\n}\n```\n\n**✅ Good - Environment-specific configuration:**\n\n```typescript\nconst env = getDeploymentEnv()\n\nexport const config = {\n\tproduction: env === 'production',\n\tstaging: env === 'staging',\n\tpreview: env === 'preview',\n\tdevelopment: env === 'development',\n\n\t// Preview deployments might have limited features\n\tfeatures: {\n\t\tanalytics: env === 'production',\n\t\tsentry: env !== 'development',\n\t\tindexing: env === 'production',\n\t},\n}\n```\n\n### Build Artifact Exclusion\n\n**✅ Good - Optimize Docker builds:**\n\n```dockerfile\n# other/Dockerfile\n# Multi-stage build for smaller image size\nFROM node:20-alpine AS base\nWORKDIR /app\n\n# Install dependencies\nFROM base AS deps\nCOPY package*.json ./\nRUN npm ci --only=production\n\n# Build application\nFROM base AS builder\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production image\nFROM base AS runner\nENV NODE_ENV=production\n\n# Copy only what's needed\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY --from=builder /app/build ./build\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/other ./other\nCOPY --from=builder /app/prisma ./prisma\nCOPY --from=builder /app/package.json ./\n\n# Exclude unnecessary files\n# node_modules/.cache, .git, etc. are already excluded via .dockerignore\n\nCMD [\"npm\", \"start\"]\n```\n\n**✅ Good - Docker ignore file:**\n\n```dockerignore\n# .dockerignore (in other/)\nnode_modules\n.git\n.env\n.env.*\n!.env.example\n*.log\n.DS_Store\ncoverage\n.vscode\n.idea\n*.swp\n*.swo\n*~\n.cache\ndist\nbuild\n```\n\n### Deployment Status and Monitoring\n\n**✅ Good - Deployment status tracking:**\n\n```typescript\n// app/routes/admin/deployment-status.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst deploymentInfo = {\n\t\tappName: process.env.FLY_APP_NAME,\n\t\tregion: process.env.FLY_REGION,\n\t\tenvironment: getDeploymentEnv(),\n\t\tcommitSha: process.env.COMMIT_SHA,\n\t\tdeployedAt: process.env.DEPLOYED_AT,\n\t}\n\n\treturn { deploymentInfo }\n}\n```\n\n### Rollback Strategies\n\n**✅ Good - Quick rollback with Fly.io:**\n\n```bash\n# List recent releases\nfly releases list --app my-app\n\n# Rollback to previous release\nfly releases rollback --app my-app\n```\n\n**✅ Good - Automated rollback on failure:**\n\n```toml\n# fly.toml\n[experimental]\n  auto_rollback = true\n  min_machines_running = 1\n```\n","docs/skills/epic-forms/SKILL.md":"---\nname: epic-forms\ndescription: Guide on forms with Conform and validation with Zod for Epic Stack\ncategories:\n  - forms\n  - conform\n  - zod\n  - validation\n---\n\n# Epic Stack: Forms\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create forms in an Epic Stack application\n- Implement form validation with Zod\n- Work with Conform for progressively enhanced forms\n- Handle file uploads\n- Implement honeypot fields for spam protection\n- Handle form errors\n- Work with complex forms (fieldsets, arrays)\n\n## Patterns and conventions\n\n### Validation Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Make validation rules clear and explicit\nusing Zod schemas. Every validation rule should be visible in the schema, not\nhidden in business logic. Error messages should be specific and helpful, telling\nusers exactly what went wrong and how to fix it.\n\n**Design to fail fast and early** - Validate input as early as possible, ideally\non the client side before submission, and always on the server side. Return\nclear, specific error messages immediately so users can fix issues without\nfrustration.\n\n**Example - Explicit validation:**\n\n```typescript\n// ✅ Good - Explicit validation with clear error messages\nconst SignupSchema = z.object({\n\temail: z\n\t\t.string({ required_error: 'Email is required' })\n\t\t.email({ message: 'Please enter a valid email address' })\n\t\t.min(3, { message: 'Email must be at least 3 characters' })\n\t\t.max(100, { message: 'Email must be less than 100 characters' })\n\t\t.transform((val) => val.toLowerCase().trim()),\n\tpassword: z\n\t\t.string({ required_error: 'Password is required' })\n\t\t.min(6, { message: 'Password must be at least 6 characters' })\n\t\t.max(72, { message: 'Password must be less than 72 characters' }),\n})\n\n// ❌ Avoid - Implicit validation\nconst SignupSchema = z.object({\n\temail: z.string().email(), // No clear error messages\n\tpassword: z.string().min(6), // Generic error\n})\n```\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate early and return specific errors immediately\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: 400 }, // Clear error status\n\t\t)\n\t}\n\n\t// Only proceed if validation passed\n\tconst { email, password } = submission.value\n\t// ... continue with signup\n}\n\n// ❌ Avoid - Delayed or unclear validation\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst email = formData.get('email')\n\tconst password = formData.get('password')\n\n\t// Validation scattered throughout the function\n\tif (!email) {\n\t\t// Generic error, not specific\n\t\treturn json({ error: 'Invalid' }, { status: 400 })\n\t}\n\t// ... more scattered validation\n}\n```\n\n### Basic setup with Conform\n\nEpic Stack uses [Conform](https://conform.guide/) to handle forms with\nprogressive enhancement.\n\n**Basic setup:**\n\n```typescript\nimport { getFormProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tconstraint: getZodConstraint(SignupSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Integration with Zod\n\nConform integrates seamlessly with Zod for validation.\n\n**Define schema:**\n\n```typescript\nimport { z } from 'zod'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: z.string().email('Invalid email'),\n\t\tpassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\tconfirmPassword: z.string(),\n\t})\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'Passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Validation in action (fail fast):**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - explicit and fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return explicit errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Only proceed if validation passed - submission.value is type-safe\n\tconst { email, password } = submission.value\n\t// ... process with validated data\n}\n```\n\n### Async validation\n\nFor validations that require querying the database:\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true, // Important: enable async validation\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// ...\n}\n```\n\n### Field Components\n\nEpic Stack provides pre-built field components:\n\n**Basic Field:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\n**TextareaField:**\n\n```typescript\nimport { TextareaField } from '#app/components/forms.tsx'\nimport { getTextareaProps } from '@conform-to/react'\n\n<TextareaField\n\tlabelProps={{\n\t\thtmlFor: fields.content.id,\n\t\tchildren: 'Content',\n\t}}\n\ttextareaProps={{\n\t\t...getTextareaProps(fields.content),\n\t\trows: 10,\n\t}}\n\terrors={fields.content.errors}\n/>\n```\n\n**CheckboxField:**\n\n```typescript\nimport { CheckboxField } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<CheckboxField\n\tlabelProps={{\n\t\thtmlFor: fields.remember.id,\n\t\tchildren: 'Remember me',\n\t}}\n\tbuttonProps={getInputProps(fields.remember, { type: 'checkbox' })}\n\terrors={fields.remember.errors}\n/>\n```\n\n**OTPField:**\n\n```typescript\nimport { OTPField } from '#app/components/forms.tsx'\n\n<OTPField\n\tlabelProps={{\n\t\thtmlFor: fields.code.id,\n\t\tchildren: 'Verification Code',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.code),\n\t\tmaxLength: 6,\n\t}}\n\terrors={fields.code.errors}\n/>\n```\n\n### Error Handling\n\n**Display field errors:**\n\n```typescript\n<Field\n\t// ... props\n\terrors={fields.email.errors} // Errores específicos del campo\n/>\n```\n\n**Display form errors:**\n\n```typescript\nimport { ErrorList } from '#app/components/forms.tsx'\n\n<ErrorList errors={form.errors} id={form.errorId} />\n```\n\n**Error structure:**\n\n- `fields.fieldName.errors` - Errors for a specific field\n- `form.errors` - General form errors (like `formErrors`)\n\n### Honeypot Fields\n\nEpic Stack includes spam protection with honeypot fields.\n\n**In the form:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Rest of fields */}\n</Form>\n```\n\n**In the action:**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Throws error if spam\n\n\t// ... rest of code\n}\n```\n\n### File Uploads\n\nFor forms with file uploads, use `encType=\"multipart/form-data\"`.\n\n**Schema for files:**\n\n```typescript\nconst MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB\n\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z\n\t\t.instanceof(File)\n\t\t.optional()\n\t\t.refine((file) => {\n\t\t\treturn !file || file.size <= MAX_UPLOAD_SIZE\n\t\t}, 'File must be less than 3MB'),\n\taltText: z.string().optional(),\n})\n\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**Form with file upload:**\n\n```typescript\n<Form\n\tmethod=\"POST\"\n\tencType=\"multipart/form-data\"\n\t{...getFormProps(form)}\n>\n\t{/* Fields */}\n</Form>\n```\n\n**Process files in action:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteEditorSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { images } = submission.value\n\n\t// Process files\n\tfor (const image of images ?? []) {\n\t\tif (image.file) {\n\t\t\t// Upload file, save to storage, etc.\n\t\t}\n\t}\n\n\t// ...\n}\n```\n\n### Fieldsets y Arrays\n\nFor forms with repetitive fields (like multiple images):\n\n**Schema:**\n\n```typescript\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z.instanceof(File).optional(),\n\taltText: z.string().optional(),\n})\n\nconst FormSchema = z.object({\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**In the component:**\n\n```typescript\nimport { FormProvider, getFieldsetProps } from '@conform-to/react'\n\nconst [form, fields] = useForm({\n\t// ...\n\tdefaultValue: {\n\t\timages: note?.images ?? [{}],\n\t},\n})\n\nconst imageList = fields.images.getFieldList()\n\nreturn (\n\t<FormProvider context={form.context}>\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{imageList.map((image, index) => {\n\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\treturn (\n\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</fieldset>\n\t\t\t\t)\n\t\t\t})}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t>\n\t\t\t\tAdd Image\n\t\t\t</button>\n\t\t</Form>\n\t</FormProvider>\n)\n```\n\n### StatusButton\n\nUse `StatusButton` to display submission status:\n\n```typescript\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\nimport { useIsPending } from '#app/utils/misc.tsx'\n\nconst isPending = useIsPending()\n\n<StatusButton\n\tclassName=\"w-full\"\n\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\ttype=\"submit\"\n\tdisabled={isPending}\n>\n\tSubmit\n</StatusButton>\n```\n\n### Progressive Enhancement\n\nForms work without JavaScript thanks to Conform:\n\n- Native HTML5 validation\n- Server-side validation\n- Enhancements with JavaScript when available\n\n**You don't need to do anything special** - Conform handles this automatically.\n\n### Reusable Schema Validation\n\nCreate reusable schemas in `app/utils/user-validation.ts`:\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase())\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long',\n\t})\n\nexport const PasswordAndConfirmPasswordSchema = z\n\t.object({ password: PasswordSchema, confirmPassword: PasswordSchema })\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'The passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Use in forms:**\n\n```typescript\nimport {\n\tEmailSchema,\n\tPasswordAndConfirmPasswordSchema,\n} from '#app/utils/user-validation.ts'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: EmailSchema,\n\t\tusername: UsernameSchema,\n\t})\n\t.and(PasswordAndConfirmPasswordSchema)\n```\n\n## Common examples\n\n### Example 1: Simple login form\n\n```typescript\n// app/routes/_auth/login.tsx\nimport { getFormProps, getInputProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\n\nconst LoginSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(1),\n})\n\nexport default function LoginRoute({ actionData }: Route.ComponentProps) {\n\tconst isPending = useIsPending()\n\n\tconst [form, fields] = useForm({\n\t\tid: 'login-form',\n\t\tconstraint: getZodConstraint(LoginSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: LoginSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t\t\t\tautoFocus: true,\n\t\t\t\t\tautoComplete: 'email',\n\t\t\t\t}}\n\t\t\t\terrors={fields.email.errors}\n\t\t\t/>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.password, { type: 'password' }),\n\t\t\t\t\tautoComplete: 'current-password',\n\t\t\t\t}}\n\t\t\t\terrors={fields.password.errors}\n\t\t\t/>\n\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t<StatusButton\n\t\t\t\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\t\t\t\ttype=\"submit\"\n\t\t\t>\n\t\t\t\tLogin\n\t\t\t</StatusButton>\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Form with async validation\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Procesar signup...\n}\n```\n\n### Example 3: Form with file upload\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n\nexport default function NewNoteRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'note-editor',\n\t\tconstraint: getZodConstraint(NoteEditorSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: NoteEditorSchema })\n\t\t},\n\t\tdefaultValue: {\n\t\t\timages: [{}],\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\tconst imageList = fields.images.getFieldList()\n\n\treturn (\n\t\t<FormProvider context={form.context}>\n\t\t\t<Form\n\t\t\t\tmethod=\"POST\"\n\t\t\t\tencType=\"multipart/form-data\"\n\t\t\t\t{...getFormProps(form)}\n\t\t\t>\n\t\t\t\t<Field\n\t\t\t\t\tlabelProps={{ children: 'Title' }}\n\t\t\t\t\tinputProps={{\n\t\t\t\t\t\tautoFocus: true,\n\t\t\t\t\t\t...getInputProps(fields.title, { type: 'text' }),\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.title.errors}\n\t\t\t\t/>\n\t\t\t\t<TextareaField\n\t\t\t\t\tlabelProps={{ children: 'Content' }}\n\t\t\t\t\ttextareaProps={{\n\t\t\t\t\t\t...getTextareaProps(fields.content),\n\t\t\t\t\t\trows: 10,\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.content.errors}\n\t\t\t\t/>\n\n\t\t\t\t{imageList.map((image, index) => {\n\t\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t\t\taccept=\"image/*\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t\t\tdisabled={imageList.length >= 5}\n\t\t\t\t>\n\t\t\t\t\tAdd Image\n\t\t\t\t</button>\n\n\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t\t<StatusButton type=\"submit\">Create Note</StatusButton>\n\t\t\t</Form>\n\t\t</FormProvider>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit validation**: Always use explicit Zod schemas with clear error\n  messages, not hidden validation logic\n- ❌ **Delayed validation**: Validate input immediately and fail fast - don't\n  wait until the end of the function\n- ❌ **Generic error messages**: Use specific, helpful error messages that tell\n  users exactly what's wrong\n- ❌ **Forgetting `async: true` in async validation**: Always include\n  `async: true` when using `superRefine` with async\n- ❌ **Not including `HoneypotInputs` in public forms**: Always include honeypot\n  in forms accessible without authentication\n- ❌ **Forgetting `encType=\"multipart/form-data\"`**: Required for file uploads\n- ❌ **Not validating file sizes**: Always validate size in the schema\n- ❌ **Not using `getZodConstraint`**: Required for native HTML5 validation\n- ❌ **Forgetting `lastResult` in `useForm`**: Required to display server errors\n- ❌ **Not using `shouldRevalidate: 'onBlur'`**: Improves UX by validating on\n  field blur\n- ❌ **Not using pre-built field components**: `Field`, `TextareaField`, etc.\n  already handle accessibility and errors\n\n## References\n\n- [Conform Documentation](https://conform.guide/)\n- [Zod Documentation](https://zod.dev/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/components/forms.tsx` - Field components\n- `app/routes/_auth/signup.tsx` - Complete signup example\n- `app/routes/_auth/onboarding/index.tsx` - Complex form example\n- `app/routes/users/$username/notes/+shared/note-editor.tsx` - File uploads\n  example\n- `app/utils/user-validation.ts` - Reusable schemas\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n","docs/skills/epic-permissions/SKILL.md":"---\nname: epic-permissions\ndescription: Guide on RBAC system and permissions for Epic Stack\ncategories:\n  - permissions\n  - rbac\n  - access-control\n---\n\n# Epic Stack: Permissions\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement role-based access control (RBAC)\n- Validate permissions on server-side or client-side\n- Create new permissions or roles\n- Restrict access to routes or actions\n- Implement granular permissions (`own` vs `any`)\n\n## Patterns and conventions\n\n### Permissions Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Always explicitly check permissions.\nDon't assume a user has access based on implicit rules or hidden logic. Every\npermission check should be visible and clear in the code.\n\n**Example - Explicit permission checks:**\n\n```typescript\n// ✅ Good - Explicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly check permission - clear and visible\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Permission check is explicit and obvious\n\tawait prisma.note.delete({ where: { id: noteId } })\n}\n\n// ❌ Avoid - Implicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst note = await prisma.note.findUnique({ where: { id: noteId } })\n\n\t// Implicit check - not clear what permission is being checked\n\tif (note.ownerId !== userId) {\n\t\tthrow new Response('Forbidden', { status: 403 })\n\t}\n\t// What permission does this represent? Not explicit\n}\n```\n\n**Example - Explicit permission strings:**\n\n```typescript\n// ✅ Good - Explicit permission string\nconst permission: PermissionString = 'delete:note:own'\n// Clear: action (delete), entity (note), access (own)\n\nawait requireUserWithPermission(request, permission)\n\n// ❌ Avoid - Implicit or unclear permissions\nconst canDelete = checkUserCanDelete(user, note)\n// What permission is this checking? Not explicit\n```\n\n### RBAC Model\n\nEpic Stack uses an RBAC (Role-Based Access Control) model where:\n\n- **Users** have **Roles**\n- **Roles** have **Permissions**\n- A user's permissions are the union of all permissions from their roles\n\n### Permission Structure\n\nPermissions follow the format: `action:entity:access`\n\n**Components:**\n\n- `action`: The allowed action (`create`, `read`, `update`, `delete`)\n- `entity`: The entity being acted upon (`user`, `note`, etc.)\n- `access`: The access level (`own`, `any`, `own,any`)\n\n**Examples:**\n\n- `create:note:own` - Can create own notes\n- `read:note:any` - Can read any note\n- `delete:user:any` - Can delete any user (admin)\n- `update:note:own` - Can update only own notes\n\n### Prisma Schema\n\n**Models:**\n\n```prisma\nmodel Permission {\n  id          String @id @default(cuid())\n  action      String // e.g. create, read, update, delete\n  entity      String // e.g. note, user, etc.\n  access      String // e.g. own or any\n  description String @default(\"\")\n\n  roles Role[]\n\n  @@unique([action, entity, access])\n}\n\nmodel Role {\n  id          String @id @default(cuid())\n  name        String @unique\n  description String @default(\"\")\n\n  users       User[]\n  permissions Permission[]\n}\n\nmodel User {\n  id    String @id @default(cuid())\n  // ...\n  roles Role[]\n}\n```\n\n### Validate Permissions Server-Side\n\n**Require specific permission:**\n\n```typescript\nimport { requireUserWithPermission } from '#app/utils/permissions.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserWithPermission(\n\t\trequest,\n\t\t'delete:note:own', // Throws 403 error if doesn't have permission\n\t)\n\n\t// User has the permission, continue...\n}\n```\n\n**Require specific role:**\n\n```typescript\nimport { requireUserWithRole } from '#app/utils/permissions.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserWithRole(request, 'admin')\n\n\t// User has admin role, continue...\n}\n```\n\n**Conditional permissions (own vs any) - explicit:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly determine ownership\n\tconst note = await prisma.note.findUnique({\n\t\twhere: { id: noteId },\n\t\tselect: { ownerId: true },\n\t})\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Explicitly check the appropriate permission based on ownership\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string\n\t)\n\n\t// Permission check is explicit and clear\n\t// Proceed with deletion...\n}\n```\n\n### Validate Permissions Client-Side\n\n**Check if user has permission:**\n\n```typescript\nimport { userHasPermission, useOptionalUser } from '#app/utils/user.ts'\n\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t{canDelete && (\n\t\t\t\t<button onClick={handleDelete}>Delete</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n**Check if user has role:**\n\n```typescript\nimport { userHasRole } from '#app/utils/user.ts'\n\nexport default function AdminRoute() {\n\tconst user = useOptionalUser()\n\tconst isAdmin = userHasRole(user, 'admin')\n\n\tif (!isAdmin) {\n\t\treturn <div>Access Denied</div>\n\t}\n\n\treturn <div>Admin Panel</div>\n}\n```\n\n### Create New Permissions\n\n**En Prisma Studio o seed:**\n\n```typescript\n// prisma/seed.ts\nawait prisma.permission.create({\n\tdata: {\n\t\taction: 'create',\n\t\tentity: 'post',\n\t\taccess: 'own',\n\t\tdescription: 'Can create their own posts',\n\t\troles: {\n\t\t\tconnect: { name: 'user' },\n\t\t},\n\t},\n})\n```\n\n**Permiso con múltiples niveles de acceso:**\n\n```typescript\nawait prisma.permission.createMany({\n\tdata: [\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own posts',\n\t\t},\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t],\n})\n```\n\n### Assign Roles to Users\n\n**When creating user:**\n\n```typescript\nconst user = await prisma.user.create({\n\tdata: {\n\t\temail,\n\t\tusername,\n\t\troles: {\n\t\t\tconnect: { name: 'user' }, // Assign 'user' role\n\t\t},\n\t},\n})\n```\n\n**Assign multiple roles:**\n\n```typescript\nawait prisma.user.update({\n\twhere: { id: userId },\n\tdata: {\n\t\troles: {\n\t\t\tconnect: [{ name: 'user' }, { name: 'moderator' }],\n\t\t},\n\t},\n})\n```\n\n### Permissions and Roles Seed\n\n**Seed example:**\n\n```typescript\n// prisma/seed.ts\n\n// Create permissions\nconst permissions = await Promise.all([\n\t// User permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'update',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can update own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can delete own notes',\n\t\t},\n\t}),\n\t// Admin permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'user',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can delete any user',\n\t\t},\n\t}),\n])\n\n// Create roles\nconst userRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'user',\n\t\tdescription: 'Standard user',\n\t\tpermissions: {\n\t\t\tconnect: permissions.slice(0, 4).map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n\nconst adminRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'admin',\n\t\tdescription: 'Administrator',\n\t\tpermissions: {\n\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n```\n\n### Permission Type\n\n**Type-safe permission strings:**\n\n```typescript\nimport { type PermissionString } from '#app/utils/user.ts'\n\n// Tipo: 'create:note:own' | 'read:note:own' | etc.\nconst permission: PermissionString = 'delete:note:own'\n```\n\n**Parsear permission string:**\n\n```typescript\nimport { parsePermissionString } from '#app/utils/user.ts'\n\nconst { action, entity, access } = parsePermissionString('delete:note:own')\n// action: 'delete'\n// entity: 'note'\n// access: ['own']\n```\n\n## Common examples\n\n### Example 1: Proteger action con permiso\n\n```typescript\n// app/routes/users/$username/notes/$noteId.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\tconst { noteId } = Object.fromEntries(formData)\n\n\tconst note = await prisma.note.findFirst({\n\t\tselect: { id: true, ownerId: true, owner: { select: { username: true } } },\n\t\twhere: { id: noteId },\n\t})\n\n\tif (!note) {\n\t\tthrow new Response('Not found', { status: 404 })\n\t}\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Validate permiso según si es propietario o no\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\tawait prisma.note.delete({ where: { id: note.id } })\n\n\treturn redirect(`/users/${note.owner.username}/notes`)\n}\n```\n\n### Example 2: Mostrar UI condicional basada en permisos\n\n```typescript\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\tconst canEdit = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'update:note:own' : 'update:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.note.title}</h1>\n\t\t\t<p>{loaderData.note.content}</p>\n\n\t\t\t{(canEdit || canDelete) && (\n\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t{canEdit && (\n\t\t\t\t\t\t<Link to=\"edit\">\n\t\t\t\t\t\t\t<Button>Edit</Button>\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t)}\n\t\t\t\t\t{canDelete && (\n\t\t\t\t\t\t<DeleteNoteButton noteId={loaderData.note.id} />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n### Example 3: Ruta solo para admin\n\n```typescript\n// app/routes/admin/users.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireUserWithRole(request, 'admin')\n\n\tconst users = await prisma.user.findMany({\n\t\tselect: {\n\t\t\tid: true,\n\t\t\temail: true,\n\t\t\tusername: true,\n\t\t},\n\t})\n\n\treturn { users }\n}\n\nexport default function AdminUsersRoute({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>All Users</h1>\n\t\t\t{loaderData.users.map(user => (\n\t\t\t\t<div key={user.id}>{user.username}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n### Example 4: Create new permission and assign it\n\n```typescript\n// Migración o seed\nasync function setupPostPermissions() {\n\t// Create post permissions\n\tconst createOwn = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own posts',\n\t\t},\n\t})\n\n\tconst readAny = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t})\n\n\t// Assign to user role\n\tawait prisma.role.update({\n\t\twhere: { name: 'user' },\n\t\tdata: {\n\t\t\tpermissions: {\n\t\t\t\tconnect: [{ id: createOwn.id }, { id: readAny.id }],\n\t\t\t},\n\t\t},\n\t})\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit permission checks**: Always explicitly check permissions - make\n  permission requirements visible in code\n- ❌ **Not validating permissions on server-side**: Always validate permissions\n  in action/loader, never trust client-side only\n- ❌ **Forgetting to verify `own` vs `any`**: Explicitly determine if user is\n  owner before validating permission\n- ❌ **Not using correct helpers**: Use `requireUserWithPermission` for\n  server-side and `userHasPermission` for client-side - explicit helpers\n- ❌ **Not creating unique permissions**: Use\n  `@@unique([action, entity, access])` in schema - explicit permission structure\n- ❌ **Assuming permissions instead of verifying**: Always verify explicitly,\n  even if you think user has the permission\n- ❌ **Not handling 403 errors**: Helpers throw errors that must be handled by\n  ErrorBoundary\n- ❌ **Not using types**: Use `PermissionString` type for type-safety - explicit\n  types\n- ❌ **Hidden permission logic**: Don't hide permission checks in utility\n  functions - make them explicit at the call site\n\n## References\n\n- [Epic Stack Permissions Docs](../epic-stack/docs/permissions.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [RBAC Explained](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac)\n- `app/utils/permissions.server.ts` - Server-side permission utilities\n- `app/utils/user.ts` - Client-side permission utilities\n- `prisma/schema.prisma` - Permission and Role models\n- `prisma/seed.ts` - Permission seed examples\n","docs/skills/epic-react-patterns/SKILL.md":"---\nname: epic-react-patterns\ndescription:\n  Guide on React patterns, performance optimization, and code quality for Epic\n  Stack\ncategories:\n  - react\n  - performance\n  - patterns\n  - optimization\n---\n\n# Epic Stack: React Patterns and Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write efficient React components in Epic Stack applications\n- Optimize performance and bundle size\n- Follow React Router patterns and conventions\n- Avoid common React anti-patterns\n- Implement proper code splitting\n- Optimize re-renders and data fetching\n- Use React hooks correctly\n\n## Philosophy\n\nFollowing Epic Web principles:\n\n- **Make it work, make it right, make it fast** - In that order. First make it\n  functional, then refactor for clarity, then optimize for performance.\n- **Pragmatism over purity** - Choose practical solutions that work well in your\n  context rather than theoretically perfect ones.\n- **Optimize for sustainable velocity** - Write code that's easy to maintain and\n  extend, not just fast to write initially.\n- **Do as little as possible** - Only add complexity when it provides real\n  value.\n\n## Patterns and conventions\n\n### Data Fetching in React Router\n\nEpic Stack uses React Router loaders for data fetching, not `useEffect`.\n\n**✅ Good - Use loaders:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n**❌ Avoid - Don't fetch in useEffect:**\n\n```typescript\n// ❌ Don't do this\nexport default function UserRoute({ params }: Route.ComponentProps) {\n\tconst [user, setUser] = useState(null)\n\n\tuseEffect(() => {\n\t\tfetch(`/api/users/${params.username}`)\n\t\t\t.then(res => res.json())\n\t\t\t.then(setUser)\n\t}, [params.username])\n\n\treturn user ? <div>{user.name}</div> : <div>Loading...</div>\n}\n```\n\n### Avoid useEffect for Side Effects\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use event handlers, CSS, ref callbacks, or\n`useSyncExternalStore`.\n\n**✅ Good - Use event handlers:**\n\n```typescript\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigate('/checkout')\n\t}\n\n\treturn (\n\t\t<div>\n\t\t\t<button onClick={handleBuyClick}>Buy Now</button>\n\t\t\t<button onClick={handleCheckoutClick}>Checkout</button>\n\t\t</div>\n\t)\n}\n```\n\n**❌ Avoid - Side effects in useEffect:**\n\n```typescript\n// ❌ Don't do this\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\t// ...\n}\n```\n\n**✅ Appropriate use of useEffect:**\n\n```typescript\n// ✅ Good - Event listeners are appropriate\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\t\t\t// handle escape key\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n\n### Code Splitting with React Router\n\nReact Router automatically code-splits by route. Use dynamic imports for heavy\ncomponents.\n\n**✅ Good - Dynamic imports:**\n\n```typescript\n// app/routes/admin/dashboard.tsx\nimport { lazy } from 'react'\n\nconst AdminChart = lazy(() => import('#app/components/admin/chart.tsx'))\n\nexport default function AdminDashboard() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading chart...</div>}>\n\t\t\t<AdminChart />\n\t\t</Suspense>\n\t)\n}\n```\n\n### Optimizing Re-renders\n\n**✅ Good - Memoize expensive computations:**\n\n```typescript\nimport { useMemo } from 'react'\n\nfunction UserList({ users }: { users: User[] }) {\n\tconst sortedUsers = useMemo(() => {\n\t\treturn [...users].sort((a, b) => a.name.localeCompare(b.name))\n\t}, [users])\n\n\treturn (\n\t\t<ul>\n\t\t\t{sortedUsers.map(user => (\n\t\t\t\t<li key={user.id}>{user.name}</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**✅ Good - Memoize callbacks:**\n\n```typescript\nimport { useCallback } from 'react'\n\nfunction NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) {\n\tconst handleSave = useCallback((note: Note) => {\n\t\tonSave(note)\n\t}, [onSave])\n\n\treturn <Editor onSave={handleSave} />\n}\n```\n\n**❌ Avoid - Unnecessary memoization:**\n\n```typescript\n// ❌ Don't memoize simple values\nconst count = useMemo(() => items.length, [items]) // Just use items.length directly\n\n// ❌ Don't memoize simple callbacks\nconst handleClick = useCallback(() => {\n\tconsole.log('clicked')\n}, []) // Just define the function normally if it doesn't need memoization\n```\n\n### Bundle Size Optimization\n\n**✅ Good - Import only what you need:**\n\n```typescript\n// ✅ Import specific functions\nimport { useSearchParams } from 'react-router'\nimport { parseWithZod } from '@conform-to/zod'\n```\n\n**❌ Avoid - Barrel imports:**\n\n```typescript\n// ❌ Don't import entire libraries if you only need one thing\nimport * as ReactRouter from 'react-router'\nimport * as Conform from '@conform-to/zod'\n```\n\n### Form Handling with Conform\n\n**✅ Good - Use Conform for forms:**\n\n```typescript\nimport { useForm, getFormProps } from '@conform-to/react'\nimport { parseWithZod } from '@conform-to/zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Component Composition\n\n**✅ Good - Compose components:**\n\n```typescript\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<Card>\n\t\t\t<UserHeader user={user} />\n\t\t\t<UserDetails user={user} />\n\t\t\t<UserActions userId={user.id} />\n\t\t</Card>\n\t)\n}\n```\n\n**❌ Avoid - Large monolithic components:**\n\n```typescript\n// ❌ Don't put everything in one component\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<div className=\"card\">\n\t\t\t<div className=\"header\">\n\t\t\t\t<img src={user.avatar} alt={user.name} />\n\t\t\t\t<h1>{user.name}</h1>\n\t\t\t</div>\n\t\t\t<div className=\"details\">\n\t\t\t\t<p>{user.email}</p>\n\t\t\t\t<p>{user.bio}</p>\n\t\t\t</div>\n\t\t\t<div className=\"actions\">\n\t\t\t\t<button>Edit</button>\n\t\t\t\t<button>Delete</button>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n```\n\n### Error Boundaries\n\n**✅ Good - Use error boundaries:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>User \"{params.username}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### TypeScript Guidelines\n\n**✅ Good - Type props explicitly:**\n\n```typescript\ninterface UserCardProps {\n\tuser: {\n\t\tid: string\n\t\tname: string\n\t\temail: string\n\t}\n\tonEdit?: (userId: string) => void\n}\n\nfunction UserCard({ user, onEdit }: UserCardProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h2>{user.name}</h2>\n\t\t\t<p>{user.email}</p>\n\t\t\t{onEdit && <button onClick={() => onEdit(user.id)}>Edit</button>}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Route types:**\n\n```typescript\nimport type { Route } from './+types/users.$username'\n\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params is type-safe!\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\t// loaderData is type-safe!\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n### Loading States\n\n**✅ Good - Use React Router's pending states:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction NoteForm() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<button type=\"submit\" disabled={isSubmitting}>\n\t\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t\t</button>\n\t\t</Form>\n\t)\n}\n```\n\n### Preventing Data Fetching Waterfalls\n\nReact Router loaders can prevent waterfalls by fetching data in parallel.\n\n**❌ Avoid - Sequential data fetching (waterfall):**\n\n```typescript\n// ❌ Don't do this - creates a waterfall\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\t// Second fetch waits for first to complete\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t})\n\treturn { user, notes }\n}\n```\n\n**✅ Good - Parallel data fetching:**\n\n```typescript\n// ✅ Fetch data in parallel\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\n\t// Fetch notes in parallel with user data\n\tconst [notes, stats] = await Promise.all([\n\t\tuser\n\t\t\t? prisma.note.findMany({\n\t\t\t\t\twhere: { ownerId: user.id },\n\t\t\t\t\tselect: { id: true, title: true, updatedAt: true },\n\t\t\t\t})\n\t\t\t: Promise.resolve([]),\n\t\tuser\n\t\t\t? prisma.note.count({ where: { ownerId: user.id } })\n\t\t\t: Promise.resolve(0),\n\t])\n\n\treturn { user, notes, stats }\n}\n```\n\n**✅ Good - Nested route parallel loading:**\n\n```typescript\n// Parent route loader\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\n// Child route loader runs in parallel\n// app/routes/users/$username/notes.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true },\n\t})\n\n\tif (!user) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t\tselect: { id: true, title: true, updatedAt: true },\n\t})\n\n\treturn { notes }\n}\n```\n\n### Server-Side Rendering (SSR) Performance\n\nReact Router provides SSR by default. Optimize by:\n\n**✅ Good - Selective data fetching:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Only fetch what's needed for initial render\n\tconst searchParams = new URL(request.url).searchParams\n\tconst page = Number(searchParams.get('page') || '1')\n\n\tconst [items, total] = await Promise.all([\n\t\tprisma.item.findMany({\n\t\t\ttake: 20,\n\t\t\tskip: (page - 1) * 20,\n\t\t\tselect: { id: true, title: true }, // Only needed fields\n\t\t}),\n\t\tprisma.item.count(),\n\t])\n\n\treturn { items, total, page }\n}\n```\n\n**✅ Good - Use caching for expensive operations:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\t// Cache expensive database queries\n\tconst stats = await cachified({\n\t\tkey: 'user-stats',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.aggregate({\n\t\t\t\t_count: { id: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t})\n\n\treturn { stats }\n}\n```\n\n### Rendering Performance\n\n**✅ Good - Use React.memo for expensive components:**\n\n```typescript\nimport { memo } from 'react'\n\nconst ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) {\n\t// Expensive rendering logic\n\treturn <Chart data={data} />\n})\n\n// Only re-renders when data changes\nexport default function Dashboard({ chartData }: { chartData: Data[] }) {\n\treturn <ExpensiveChart data={chartData} />\n}\n```\n\n**✅ Good - Optimize list rendering:**\n\n```typescript\nimport { memo } from 'react'\n\nconst UserItem = memo(function UserItem({ user }: { user: User }) {\n\treturn (\n\t\t<li>\n\t\t\t<h3>{user.name}</h3>\n\t\t\t<p>{user.email}</p>\n\t\t</li>\n\t)\n}, (prev, next) => prev.user.id === next.user.id)\n\nfunction UserList({ users }: { users: User[] }) {\n\treturn (\n\t\t<ul>\n\t\t\t{users.map(user => (\n\t\t\t\t<UserItem key={user.id} user={user} />\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**❌ Avoid - Creating new objects/arrays in render:**\n\n```typescript\n// ❌ Don't create new objects on every render\nfunction UserProfile({ user }: { user: User }) {\n\treturn <Card user={{ ...user, fullName: `${user.firstName} ${user.lastName}` }} />\n}\n\n// ✅ Good - Compute in loader or memoize\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { firstName: true, lastName: true },\n\t})\n\n\treturn {\n\t\tuser: {\n\t\t\t...user,\n\t\t\tfullName: `${user.firstName} ${user.lastName}`,\n\t\t},\n\t}\n}\n```\n\n### Bundle Size Optimization Strategies\n\n**✅ Good - Route-based code splitting:** React Router automatically splits code\nby route. Leverage this:\n\n```typescript\n// Heavy dependencies are automatically split by route\n// app/routes/admin/dashboard.tsx\nimport { Chart } from 'chart.js' // Only loaded on /admin/dashboard route\n```\n\n**✅ Good - Dynamic imports for heavy components:**\n\n```typescript\nimport { lazy, Suspense } from 'react'\n\nconst HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx'))\n\nexport default function Route() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading...</div>}>\n\t\t\t<HeavyComponent />\n\t\t</Suspense>\n\t)\n}\n```\n\n**✅ Good - Tree-shakeable imports:**\n\n```typescript\n// ✅ Tree-shakeable - only imports what you use\nimport { format } from 'date-fns/format'\nimport { addDays } from 'date-fns/addDays'\n\n// ❌ Avoid - imports entire library\nimport * as dateFns from 'date-fns'\n```\n\n### React 18+ Features for Performance\n\n**✅ Good - Use transitions for non-urgent updates:**\n\n```typescript\nimport { useTransition } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction SearchInput() {\n\tconst [isPending, startTransition] = useTransition()\n\tconst navigation = useNavigation()\n\n\tfunction handleSearch(query: string) {\n\t\tstartTransition(() => {\n\t\t\t// Update search results (non-urgent)\n\t\t\tnavigation.navigate(`/search?q=${query}`)\n\t\t})\n\t}\n\n\treturn (\n\t\t<input\n\t\t\tonChange={(e) => handleSearch(e.target.value)}\n\t\t\tplaceholder={isPending ? 'Searching...' : 'Search'}\n\t\t/>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching data in useEffect**: Use React Router loaders instead\n- ❌ **Overusing useEffect**: Prefer event handlers, CSS, or ref callbacks\n- ❌ **Premature memoization**: Only memoize when there's a measurable\n  performance benefit\n- ❌ **Barrel imports**: Import only what you need\n- ❌ **Ignoring TypeScript types**: Use Route types for type safety\n- ❌ **Not handling loading states**: Use React Router's navigation states\n- ❌ **Large monolithic components**: Break components into smaller, focused\n  pieces\n- ❌ **Not using error boundaries**: Always add error boundaries to routes\n- ❌ **Client-side routing when server-side works**: Prefer server-side data\n  fetching\n- ❌ **Data fetching waterfalls**: Use `Promise.all()` to fetch data in parallel\n- ❌ **Fetching unnecessary data**: Only fetch what's needed for the initial\n  render\n- ❌ **Creating new objects in render**: Compute derived data in loaders or\n  memoize\n- ❌ **Not using React.memo for expensive lists**: Memoize list items for better\n  performance\n- ❌ **Not leveraging route-based code splitting**: React Router splits by route\n  automatically\n\n## References\n\n- [React Router Documentation](https://reactrouter.com/)\n- [React Documentation - You Might Not Need useEffect](https://react.dev/learn/you-might-not-need-an-effect)\n- [Conform Documentation](https://conform.guide/)\n- [Epic Stack Docs](https://www.epicweb.dev/docs)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/routes/` - Example routes using these patterns\n- `.cursor/rules/avoid-use-effect.mdc` - Epic Stack rule for avoiding useEffect\n","docs/skills/epic-routing/SKILL.md":"---\nname: epic-routing\ndescription:\n  Guide on routing with React Router and react-router-auto-routes for Epic Stack\ncategories:\n  - routing\n  - react-router\n  - file-based-routing\n---\n\n# Epic Stack: Routing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create new routes or pages in an Epic Stack application\n- Implement nested layouts\n- Configure resource routes (routes without UI)\n- Work with route parameters and search params\n- Understand Epic Stack's file-based routing conventions\n- Implement loaders and actions in routes\n\n## Patterns and conventions\n\n### Routing Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Keep your route structure simple. Don't create\ncomplex nested routes unless you actually need them. Start simple and add\ncomplexity only when there's a clear benefit.\n\n**Avoid over-engineering** - Don't create abstractions or complex route\nstructures \"just in case\". Use the simplest structure that works for your\ncurrent needs.\n\n**Example - Simple route structure:**\n\n```typescript\n// ✅ Good - Simple, straightforward route\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n\n// ❌ Avoid - Over-engineered route structure\n// app/routes/users/$username/_layout.tsx\n// app/routes/users/$username/index.tsx\n// app/routes/users/$username/_components/UserHeader.tsx\n// app/routes/users/$username/_components/UserDetails.tsx\n// Unnecessary complexity for a simple user page\n```\n\n**Example - Add complexity only when needed:**\n\n```typescript\n// ✅ Good - Add nested routes only when you actually need them\n// If you have user notes, then nested routes make sense:\n// app/routes/users/$username/notes/_layout.tsx\n// app/routes/users/$username/notes/index.tsx\n// app/routes/users/$username/notes/$noteId.tsx\n\n// ❌ Avoid - Creating nested routes \"just in case\"\n// Don't create complex structures before you need them\n```\n\n### File-based routing with react-router-auto-routes\n\nEpic Stack uses `react-router-auto-routes` instead of React Router's standard\nconvention. This enables better organization and code co-location.\n\n**Basic structure:**\n\n```\napp/routes/\n├── _layout.tsx        # Layout for child routes\n├── index.tsx          # Root route (/)\n├── about.tsx          # Route /about\n└── users/\n    ├── _layout.tsx    # Layout for user routes\n    ├── index.tsx      # Route /users\n    └── $username/\n        └── index.tsx  # Route /users/:username\n```\n\n**Configuration in `app/routes.ts`:**\n\n```typescript\nimport { type RouteConfig } from '@react-router/dev/routes'\nimport { autoRoutes } from 'react-router-auto-routes'\n\nexport default autoRoutes({\n\tignoredRouteFiles: [\n\t\t'.*',\n\t\t'**/*.css',\n\t\t'**/*.test.{js,jsx,ts,tsx}',\n\t\t'**/__*.*',\n\t\t'**/*.server.*', // Co-located server utilities\n\t\t'**/*.client.*', // Co-located client utilities\n\t],\n}) satisfies RouteConfig\n```\n\n### Route Groups\n\nRoute groups are folders that start with `_` and don't affect the URL but help\norganize related code.\n\n**Common examples:**\n\n- `_auth/` - Authentication routes (login, signup, etc.)\n- `_marketing/` - Marketing pages (home, about, etc.)\n- `_seo/` - SEO routes (sitemap, robots.txt)\n\n**Example:**\n\n```\napp/routes/\n├── _auth/\n│   ├── login.tsx          # URL: /login\n│   ├── signup.tsx         # URL: /signup\n│   └── forgot-password.tsx # URL: /forgot-password\n└── _marketing/\n    ├── index.tsx          # URL: /\n    └── about.tsx          # URL: /about\n```\n\n### Route Parameters\n\nUse `$` to indicate route parameters:\n\n**Syntax:**\n\n- `$param.tsx` → `:param` in URL\n- `$username.tsx` → `:username` in URL\n\n**Example route with parameter:**\n\n```typescript\n// app/routes/users/$username/index.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst username = params.username // Type-safe!\n\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username },\n\t})\n\n\treturn { user }\n}\n```\n\n### Nested Layouts with `_layout.tsx`\n\nUse `_layout.tsx` to create shared layouts for child routes.\n\n**Example:**\n\n```typescript\n// app/routes/users/$username/notes/_layout.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst owner = await prisma.user.findFirst({\n\t\twhere: { username: params.username },\n\t})\n\treturn { owner }\n}\n\nexport default function NotesLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<main className=\"container\">\n\t\t\t<h1>{loaderData.owner.name}'s Notes</h1>\n\t\t\t<Outlet /> {/* Child routes render here */}\n\t\t</main>\n\t)\n}\n```\n\nChild routes (`$noteId.tsx`, `index.tsx`, etc.) will render where `<Outlet />`\nis.\n\n### Resource Routes (Routes without UI)\n\nResource routes don't render UI; they only return data or perform actions.\n\n**Characteristics:**\n\n- Don't export a `default` component\n- Export `loader` or `action` or both\n- Useful for APIs, downloads, webhooks, etc.\n\n**Example:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check application health\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Check DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Loaders and Actions\n\n**Loaders** - Load data before rendering (GET requests) **Actions** - Handle\ndata mutations (POST, PUT, DELETE)\n\n**Loader pattern:**\n\n```typescript\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function RouteComponent({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Use loaderData.data */}</div>\n}\n```\n\n**Action pattern:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// Validate and process data\n\tawait prisma.something.create({\n\t\tdata: { /* ... */ },\n\t})\n\n\treturn redirect('/success')\n}\n\nexport default function RouteComponent() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Search Params\n\nAccess query parameters using `useSearchParams`:\n\n```typescript\nimport { useSearchParams } from 'react-router'\n\nexport default function SearchPage() {\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\tconst query = searchParams.get('q') || ''\n\tconst page = Number(searchParams.get('page') || '1')\n\n\treturn (\n\t\t<div>\n\t\t\t<input\n\t\t\t\tvalue={query}\n\t\t\t\tonChange={(e) => setSearchParams({ q: e.target.value })}\n\t\t\t/>\n\t\t\t{/* Results */}\n\t\t</div>\n\t)\n}\n```\n\n### Code Co-location\n\nEpic Stack encourages placing related code close to where it's used.\n\n**Typical structure:**\n\n```\napp/routes/users/$username/notes/\n├── _layout.tsx              # Layout with loader\n├── index.tsx                # Notes list\n├── $noteId.tsx              # Note view\n├── $noteId_.edit.tsx        # Edit note\n├── +shared/                 # Code shared between routes\n│   └── note-editor.tsx      # Shared editor\n└── $noteId.server.ts        # Server-side utilities\n```\n\nThe `+` prefix indicates co-located modules that are not routes.\n\n### Naming Conventions\n\n- `_layout.tsx` - Layout for child routes\n- `index.tsx` - Root route of the segment\n- `$param.tsx` - Route parameter\n- `$param_.action.tsx` - Route with parameter + action (using `_`)\n- `[.]ext.tsx` - Resource route (e.g., `robots[.]txt.ts`)\n\n## Common examples\n\n### Example 1: Create a basic route with layout\n\n```typescript\n// app/routes/products/_layout.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst categories = await prisma.category.findMany()\n\treturn { categories }\n}\n\nexport default function ProductsLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<nav>\n\t\t\t\t{loaderData.categories.map(cat => (\n\t\t\t\t\t<Link key={cat.id} to={`/products/${cat.slug}`}>\n\t\t\t\t\t\t{cat.name}\n\t\t\t\t\t</Link>\n\t\t\t\t))}\n\t\t\t</nav>\n\t\t\t<Outlet />\n\t\t</div>\n\t)\n}\n\n// app/routes/products/index.tsx\nexport default function ProductsIndex() {\n\treturn <div>Products list</div>\n}\n```\n\n### Example 2: Route with dynamic parameter\n\n```typescript\n// app/routes/products/$slug.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst product = await prisma.product.findUnique({\n\t\twhere: { slug: params.slug },\n\t})\n\n\tif (!product) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\treturn { product }\n}\n\nexport default function ProductPage({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.product.name}</h1>\n\t\t\t<p>{loaderData.product.description}</p>\n\t\t</div>\n\t)\n}\n\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>Product \"{params.slug}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### Example 3: Resource route for download\n\n```typescript\n// app/routes/resources/download-report.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst report = await generateReport(userId)\n\n\treturn new Response(report, {\n\t\theaders: {\n\t\t\t'Content-Type': 'application/pdf',\n\t\t\t'Content-Disposition': 'attachment; filename=\"report.pdf\"',\n\t\t},\n\t})\n}\n```\n\n### Example 4: Route with multiple nested parameters\n\n```typescript\n// app/routes/users/$username/posts/$postId/comments/$commentId.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params contains: { username, postId, commentId }\n\tconst comment = await prisma.comment.findUnique({\n\t\twhere: { id: params.commentId },\n\t\tinclude: {\n\t\t\tpost: {\n\t\t\t\tinclude: { author: true },\n\t\t\t},\n\t\t},\n\t})\n\n\treturn { comment }\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Over-engineering route structure**: Keep routes simple - don't create\n  complex nested structures unless you actually need them\n- ❌ **Creating abstractions prematurely**: Start with simple routes, add\n  complexity only when there's a clear benefit\n- ❌ **Using React Router's standard convention**: Epic Stack uses\n  `react-router-auto-routes`, not the standard convention\n- ❌ **Exporting default component in resource routes**: Resource routes should\n  not export components\n- ❌ **Not using nested layouts when needed**: Use `_layout.tsx` when you have\n  shared UI, but don't create layouts unnecessarily\n- ❌ **Forgetting `<Outlet />` in layouts**: Without `<Outlet />`, child routes\n  won't render\n- ❌ **Using incorrect names for parameters**: Should be `$param.tsx`, not\n  `:param.tsx` or `[param].tsx`\n- ❌ **Mixing route groups with URLs**: Groups (`_auth/`) don't appear in the\n  URL\n- ❌ **Not validating params**: Always validate that parameters exist before\n  using them\n- ❌ **Duplicating route logic**: Use layouts and shared components, but only\n  when it reduces duplication\n\n## References\n\n- [Epic Stack Routing Docs](../epic-stack/docs/routing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [React Router Auto Routes](https://github.com/kenn/react-router-auto-routes)\n- `app/routes.ts` - Auto-routes configuration\n- `app/routes/users/$username/notes/_layout.tsx` - Example of nested layout\n- `app/routes/resources/healthcheck.tsx` - Example of resource route\n- `app/routes/_auth/login.tsx` - Example of route in route group\n","docs/skills/epic-security/SKILL.md":"---\nname: epic-security\ndescription:\n  Guide on security practices including CSP, rate limiting, and session security\n  for Epic Stack\ncategories:\n  - security\n  - csp\n  - rate-limiting\n  - headers\n---\n\n# Epic Stack: Security\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure Content Security Policy (CSP)\n- Implement spam protection (honeypot)\n- Configure rate limiting\n- Manage session security\n- Implement input validation\n- Configure secure headers\n- Manage secrets\n\n## Patterns and conventions\n\n### Security Philosophy\n\nFollowing Epic Web principles:\n\n**Design to fail fast and early** - Validate security constraints as early as\npossible. Check authentication, authorization, and input validation before\nprocessing requests. Fail immediately with clear error messages rather than\nallowing potentially malicious data to flow through the system.\n\n**Optimize for the debugging experience** - When security checks fail, provide\nclear, actionable error messages that help developers understand what went\nwrong. Log security events with enough context to debug issues without exposing\nsensitive information.\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate security constraints early\nexport async function action({ request }: Route.ActionArgs) {\n\t// 1. Authenticate immediately - fail fast if not authenticated\n\tconst userId = await requireUserId(request)\n\n\t// 2. Validate input early - fail fast if invalid\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\t// 3. Check permissions early - fail fast if unauthorized\n\tawait requireUserWithPermission(request, 'create:note:own')\n\n\t// Only proceed if all security checks pass\n\tconst { title, content } = submission.value\n\t// ... create note\n}\n\n// ❌ Avoid - Security checks scattered or delayed\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\t// ... process data first\n\n\t// Security check at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed potentially malicious data\n\t\treturn json({ error: 'Unauthorized' }, { status: 401 })\n\t}\n}\n```\n\n**Example - Debugging-friendly error messages:**\n\n```typescript\n// ✅ Good - Clear error messages for debugging\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log with context for debugging\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tuserAgent: formData.get('user-agent'),\n\t\t\t\t// Don't log sensitive data\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n\n// ❌ Avoid - Generic or unhelpful errors\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\t// No context, hard to debug\n\t\tthrow new Response('Error', { status: 400 })\n\t}\n}\n```\n\n### Content Security Policy (CSP)\n\nEpic Stack uses CSP to prevent XSS and other attacks.\n\n**Configuration in `server/index.ts`:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Note:** By default, CSP is in `report-only` mode to avoid blocking resources\nduring development. In production, remove `reportOnly: true` to enable it fully.\n\n### Honeypot Fields\n\nEpic Stack uses honeypot fields to protect against spam bots.\n\n**En formularios públicos:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Resto de campos */}\n</Form>\n```\n\n**En el action (fail fast):**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Check honeypot first - fail fast if spam detected\n\tawait checkHoneypot(formData) // Lanza error si es spam\n\n\t// Only proceed if honeypot check passes\n\t// ... resto del código\n}\n```\n\n**Configuration:**\n\n```typescript\n// app/utils/honeypot.server.ts\nimport { Honeypot, SpamError } from 'remix-utils/honeypot/server'\n\nexport const honeypot = new Honeypot({\n\tvalidFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined,\n\tencryptionSeed: process.env.HONEYPOT_SECRET,\n})\n\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log for debugging (without sensitive data)\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n```\n\n### Rate Limiting\n\nEpic Stack uses `express-rate-limit` para prevenir abuso.\n\n**Basic configuration:**\n\n```typescript\n// server/index.ts\nimport rateLimit from 'express-rate-limit'\n\nconst rateLimitDefault = {\n\twindowMs: 60 * 1000, // 1 minute\n\tlimit: 1000, // 1000 requests per minute\n\tstandardHeaders: true,\n\tlegacyHeaders: false,\n\tvalidate: { trustProxy: false },\n\tkeyGenerator: (req: express.Request) => {\n\t\treturn req.get('fly-client-ip') ?? `${req.ip}`\n\t},\n}\n\nconst generalRateLimit = rateLimit(rateLimitDefault)\n```\n\n**Different levels of rate limiting:**\n\n```typescript\n// Stricter rate limit for sensitive routes\nconst strongestRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 10, // Only 10 requests per minute\n})\n\n// Strong rate limit for important actions\nconst strongRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 100, // 100 requests per minute\n})\n```\n\n**Apply to specific routes:**\n\n```typescript\napp.use((req, res, next) => {\n\tconst strongPaths = [\n\t\t'/login',\n\t\t'/signup',\n\t\t'/verify',\n\t\t'/admin',\n\t\t'/reset-password',\n\t]\n\n\tif (req.method !== 'GET' && req.method !== 'HEAD') {\n\t\tif (strongPaths.some((p) => req.path.includes(p))) {\n\t\t\treturn strongestRateLimit(req, res, next)\n\t\t}\n\t\treturn strongRateLimit(req, res, next)\n\t}\n\n\treturn generalRateLimit(req, res, next)\n})\n```\n\n**Note:** In tests and development, rate limiting is effectively disabled to\nallow fast tests.\n\n### Session Security\n\n**Secure session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true, // Prevents access from JavaScript\n\t\tsecrets: process.env.SESSION_SECRET.split(','), // Secret rotation\n\t\tsecure: process.env.NODE_ENV === 'production', // HTTPS only in production\n\t},\n})\n```\n\n**Security features:**\n\n- `httpOnly: true` - Prevents access from JavaScript (XSS protection)\n- `secure: true` - Only sends cookies over HTTPS in production\n- `sameSite: 'lax'` - CSRF protection\n- Secret rotation using array\n\n### Password Security\n\n**Hashing de passwords:**\n\n```typescript\nimport bcrypt from 'bcryptjs'\n\nexport async function getPasswordHash(password: string) {\n\tconst hash = await bcrypt.hash(password, 10) // 10 rounds\n\treturn hash\n}\n\nexport async function verifyUserPassword(\n\twhere: Pick<User, 'username'> | Pick<User, 'id'>,\n\tpassword: string,\n) {\n\tconst userWithPassword = await prisma.user.findUnique({\n\t\twhere,\n\t\tselect: { id: true, password: { select: { hash: true } } },\n\t})\n\n\tif (!userWithPassword || !userWithPassword.password) {\n\t\treturn null\n\t}\n\n\tconst isValid = await bcrypt.compare(password, userWithPassword.password.hash)\n\treturn isValid ? { id: userWithPassword.id } : null\n}\n```\n\n**Check common passwords (Have I Been Pwned):**\n\n```typescript\nimport { checkIsCommonPassword } from '#app/utils/auth.server.ts'\n\nconst isCommonPassword = await checkIsCommonPassword(password)\nif (isCommonPassword) {\n\tctx.addIssue({\n\t\tpath: ['password'],\n\t\tcode: 'custom',\n\t\tmessage: 'Password is too common',\n\t})\n}\n```\n\n### Input Validation y Sanitization\n\n**Always validate inputs with Zod:**\n\n```typescript\nimport { z } from 'zod'\n\nconst UserSchema = z.object({\n\temail: z\n\t\t.string()\n\t\t.email()\n\t\t.min(3)\n\t\t.max(100)\n\t\t.transform((val) => val.toLowerCase()),\n\tusername: z\n\t\t.string()\n\t\t.min(3)\n\t\t.max(20)\n\t\t.regex(/^[a-zA-Z0-9_]+$/),\n\tpassword: z.string().min(6).max(72), // bcrypt limit\n})\n\n// Validate before using\nconst result = UserSchema.safeParse(data)\nif (!result.success) {\n\treturn json({ errors: result.error.flatten() }, { status: 400 })\n}\n```\n\n**Sanitization:**\n\n- Use `.transform()` from Zod to sanitize data\n- Normalize emails to lowercase\n- Normalize usernames to lowercase\n- Clean whitespace\n\n### XSS Prevention\n\nReact prevents XSS automatically by escaping all values.\n\n**Never use `dangerouslySetInnerHTML` with user data:**\n\n```typescript\n// ❌ NEVER do this with user data\n<div dangerouslySetInnerHTML={{ __html: userContent }} />\n\n// ✅ React escapa automáticamente\n<div>{userContent}</div>\n```\n\n### Secure Headers\n\nEpic Stack uses Helmet for secure headers.\n\n**Configuration:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Included headers:**\n\n- X-Content-Type-Options: nosniff\n- X-Frame-Options: DENY\n- X-XSS-Protection: 1; mode=block\n- Referrer-Policy (configurable)\n\n### HTTPS Only\n\n**Redirect HTTP to HTTPS:**\n\n```typescript\napp.use((req, res, next) => {\n\tif (req.method !== 'GET') return next()\n\tconst proto = req.get('X-Forwarded-Proto')\n\tconst host = getHost(req)\n\tif (proto === 'http') {\n\t\tres.set('X-Forwarded-Proto', 'https')\n\t\tres.redirect(`https://${host}${req.originalUrl}`)\n\t\treturn\n\t}\n\tnext()\n})\n```\n\n### Secrets Management\n\n**Variables de entorno:**\n\n```bash\n# .env\nSESSION_SECRET=secret1,secret2,secret3 # Secret rotation\nHONEYPOT_SECRET=your-honeypot-secret\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**En Fly.io:**\n\n```bash\nfly secrets set SESSION_SECRET=\"secret1,secret2,secret3\"\nfly secrets set HONEYPOT_SECRET=\"your-secret\"\n```\n\n**Never commit secrets:**\n\n- Use `.env.example` to document required variables\n- `.env` is in `.gitignore`\n- Use `fly secrets` for production\n\n### Validación de Session Expiration (Fail Fast)\n\n**Always verify expiration early:**\n\n```typescript\nexport async function getUserId(request: Request) {\n\tconst authSession = await authSessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst sessionId = authSession.get(sessionKey)\n\n\t// Fail fast - return null immediately if no session\n\tif (!sessionId) return null\n\n\t// Verify expiration early - fail fast if expired\n\tconst session = await prisma.session.findUnique({\n\t\tselect: { userId: true },\n\t\twhere: {\n\t\t\tid: sessionId,\n\t\t\texpirationDate: { gt: new Date() }, // Verify expiration\n\t\t},\n\t})\n\n\t// Fail fast - destroy invalid session immediately\n\tif (!session?.userId) {\n\t\tthrow redirect('/', {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.destroySession(authSession),\n\t\t\t},\n\t\t})\n\t}\n\treturn session.userId\n}\n```\n\n## Common examples\n\n### Example 1: Public form with honeypot\n\n```typescript\n// app/routes/_auth/signup.tsx\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Spam protection\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// ... rest of code\n}\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<HoneypotInputs /> {/* Include in form */}\n\t\t\t{/* Rest of fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Custom rate limiting\n\n```typescript\n// server/index.ts\nconst apiRateLimit = rateLimit({\n\t...rateLimitDefault,\n\twindowMs: 60 * 1000,\n\tlimit: 100, // 100 requests per minute for API\n\tkeyGenerator: (req) => {\n\t\tconst apiKey = req.get('X-API-Key')\n\t\treturn apiKey ?? req.get('fly-client-ip') ?? req.ip\n\t},\n})\n\napp.use('/api', apiRateLimit)\n```\n\n### Example 3: Strict input validation\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const UsernameSchema = z\n\t.string({ required_error: 'Username is required' })\n\t.min(3, { message: 'Username is too short' })\n\t.max(20, { message: 'Username is too long' })\n\t.regex(/^[a-zA-Z0-9_]+$/, {\n\t\tmessage: 'Username can only include letters, numbers, and underscores',\n\t})\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long', // bcrypt limit\n\t})\n```\n\n### Example 4: Permission verification before actions\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Validate that user has permission\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Only after validating permissions\n\tawait prisma.note.delete({ where: { id: noteId } })\n\n\treturn redirect('/notes')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed security checks**: Validate authentication, authorization, and\n  input as early as possible - fail fast\n- ❌ **Generic error messages**: Provide clear, actionable error messages that\n  help with debugging (without exposing sensitive data)\n- ❌ **Forgetting honeypot in public forms**: Always include `HoneypotInputs` in\n  forms accessible without authentication\n- ❌ **Not validating session expiration**: Always verify `expirationDate` when\n  getting sessions - check early\n- ❌ **Using `dangerouslySetInnerHTML` with user data**: Never render user HTML\n  without sanitizing\n- ❌ **Not using rate limiting**: Protect sensitive routes with rate limiting\n- ❌ **Secrets in code**: Never hardcode secrets, use environment variables\n- ❌ **Not sanitizing inputs**: Always sanitize inputs with `.transform()` from\n  Zod\n- ❌ **Not validating common passwords**: Check passwords against Have I Been\n  Pwned\n- ❌ **Sessions without httpOnly**: Always use `httpOnly: true` in session\n  cookies\n- ❌ **Not using HTTPS in production**: Make sure to redirect HTTP to HTTPS\n- ❌ **CSP too permissive**: Review and adjust CSP according to your needs\n- ❌ **Not logging security events**: Log security failures with context for\n  debugging (without sensitive data)\n\n## References\n\n- [Epic Stack Security Docs](../epic-stack/docs/security.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [OWASP Top 10](https://owasp.org/www-project-top-ten/)\n- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)\n- `app/utils/honeypot.server.ts` - Honeypot implementation\n- `server/index.ts` - Server security configuration\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n- `docs/decisions/025-rate-limiting.md` - Rate limiting documentation\n- `docs/decisions/008-content-security-policy.md` - CSP documentation\n","docs/skills/epic-testing/SKILL.md":"---\nname: epic-testing\ndescription: Guide on testing with Vitest and Playwright for Epic Stack\ncategories:\n  - testing\n  - vitest\n  - playwright\n  - e2e\n---\n\n# Epic Stack: Testing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write unit tests for utilities and components\n- Create E2E tests with Playwright\n- Test forms and validation\n- Test routes and loaders\n- Mock external services with MSW\n- Test authentication and permissions\n- Configure test database\n\n## Patterns and conventions\n\n### Testing Philosophy\n\nFollowing Epic Web principles:\n\n**Tests should resemble users** - Write tests that mirror how real users\ninteract with your application. Test user workflows, not implementation details.\nIf a user would click a button, your test should click that button. If a user\nwould see an error message, your test should check for that specific message.\n\n**Make assertions specific** - Be explicit about what you're testing. Instead of\nvague assertions, use specific, meaningful checks that clearly communicate the\nexpected behavior. This makes tests easier to understand and debug when they\nfail.\n\n**Example - Tests that resemble users:**\n\n```typescript\n// ✅ Good - Tests user workflow\ntest('User can sign up and create their first note', async ({ page, navigate }) => {\n\t// User visits signup page\n\tawait navigate('/signup')\n\n\t// User fills out form like a real person would\n\tawait page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com')\n\tawait page.getByRole('textbox', { name: /username/i }).fill('newuser')\n\tawait page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123')\n\tawait page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123')\n\n\t// User submits form\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// User is redirected to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n\n\t// User creates their first note\n\tawait navigate('/notes/new')\n\tawait page.getByRole('textbox', { name: /title/i }).fill('My First Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('This is my first note!')\n\tawait page.getByRole('button', { name: /create/i }).click()\n\n\t// User sees their note\n\tawait expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible()\n\tawait expect(page.getByText('This is my first note!')).toBeVisible()\n})\n\n// ❌ Avoid - Testing implementation details\ntest('Signup form calls API endpoint', async ({ page }) => {\n\t// This tests implementation, not user experience\n\tconst response = await page.request.post('/signup', { data: {...} })\n\texpect(response.status()).toBe(200)\n})\n```\n\n**Example - Specific assertions:**\n\n```typescript\n// ✅ Good - Specific assertions\ntest('Form shows specific validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Specific error messages that users would see\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/username must be at least 3 characters/i),\n\t).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/password must be at least 6 characters/i),\n\t).toBeVisible()\n})\n\n// ❌ Avoid - Vague assertions\ntest('Form shows errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Too vague - what errors? where?\n\texpect(page.locator('.error')).toBeVisible()\n})\n```\n\n### Two Types of Tests\n\nEpic Stack uses two types of tests:\n\n1. **Unit Tests with Vitest** - Tests for individual components and utilities\n2. **E2E Tests with Playwright** - End-to-end tests of the complete flow\n\n### Unit Tests with Vitest\n\n**Basic setup:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { myUtil } from './my-util.ts'\n\ndescribe('myUtil', () => {\n\tit('should do something', () => {\n\t\texpect(myUtil('input')).toBe('expected')\n\t})\n})\n```\n\n**Testing con DOM:**\n\n```typescript\nimport { describe, expect, it } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { MyComponent } from './my-component.tsx'\n\ndescribe('MyComponent', () => {\n\tit('should render correctly', () => {\n\t\trender(<MyComponent />)\n\t\texpect(screen.getByText('Hello')).toBeInTheDocument()\n\t})\n})\n```\n\n### E2E Tests with Playwright\n\n**Basic setup:**\n\n```typescript\n// tests/e2e/my-feature.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\n\ntest('Users can do something', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/my-page')\n\n\t// Interact with the page\n\tawait page.getByRole('button', { name: /Submit/i }).click()\n\n\t// Verificar resultado\n\tawait expect(page).toHaveURL('/success')\n})\n```\n\n### Login Fixture\n\nEpic Stack provides a `login` fixture for authenticated tests.\n\n**Use login fixture:**\n\n```typescript\ntest('Protected route', async ({ page, navigate, login }) => {\n\tconst user = await login() // Creates user and session automatically\n\tawait navigate('/protected')\n\n\t// User is authenticated\n\tawait expect(page.getByText(`Welcome ${user.username}`)).toBeVisible()\n})\n```\n\n**Login with options:**\n\n```typescript\nconst user = await login({\n\tusername: 'testuser',\n\temail: 'test@example.com',\n\tpassword: 'password123',\n})\n```\n\n**Note:** The user is automatically deleted when the test completes.\n\n### Insert User without Login\n\nTo create user without authentication:\n\n```typescript\ntest('Public content', async ({ page, navigate, insertNewUser }) => {\n\tconst user = await insertNewUser({\n\t\tusername: 'publicuser',\n\t\temail: 'public@example.com',\n\t})\n\n\tawait navigate(`/users/${user.username}`)\n\tawait expect(page.getByText(user.username)).toBeVisible()\n})\n```\n\n### Navigate Helper\n\nUse the `navigate` helper to navigate with type-safety:\n\n```typescript\n// Type-safe navigation\nawait navigate('/users/:username/notes', { username: user.username })\nawait navigate('/users/:username/notes/:noteId', {\n\tusername: user.username,\n\tnoteId: note.id,\n})\n\n// Also works with routes without parameters\nawait navigate('/login')\n```\n\n### Test Database\n\nEpic Stack uses a separate test database.\n\n**Automatic configuration:**\n\n- The test database is configured automatically\n- It's cleaned between tests\n- Data created in tests is automatically deleted\n\n**Create data in tests:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n\ntest('User can see notes', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\n\t// Create note in database\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test Content',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: user.username,\n\t\tnoteId: note.id,\n\t})\n\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### MSW (Mock Service Worker)\n\nEpic Stack uses MSW to mock external services.\n\n**Mock example:**\n\n```typescript\n// tests/mocks/github.ts\nimport { http, HttpResponse } from 'msw'\n\nexport const handlers = [\n\thttp.get('https://api.github.com/user', () => {\n\t\treturn HttpResponse.json({\n\t\t\tid: '123',\n\t\t\tlogin: 'testuser',\n\t\t\temail: 'test@example.com',\n\t\t})\n\t}),\n]\n```\n\n**Use in tests:** Mocks are automatically applied when `MOCKS=true` is\nconfigured.\n\n### Testing Forms\n\n**Test form:**\n\n```typescript\ntest('User can submit form', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/notes/new')\n\n\t// Fill form\n\tawait page.getByRole('textbox', { name: /title/i }).fill('New Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Note content')\n\n\t// Submit\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar redirect\n\tawait expect(page).toHaveURL(new RegExp('/users/.*/notes/.*'))\n})\n```\n\n**Test validation:**\n\n```typescript\ntest('Form shows validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit sin llenar\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar errores\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n})\n```\n\n### Testing Loaders\n\n**Test loader:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { loader } from '../routes/my-route.ts'\nimport { prisma } from '../utils/db.server.ts'\n\ndescribe('loader', () => {\n\tit('should load data', async () => {\n\t\t// Create data\n\t\tconst user = await prisma.user.create({\n\t\t\tdata: {\n\t\t\t\temail: 'test@example.com',\n\t\t\t\tusername: 'testuser',\n\t\t\t\troles: { connect: { name: 'user' } },\n\t\t\t},\n\t\t})\n\n\t\t// Mock request\n\t\tconst request = new Request('http://localhost/my-route')\n\n\t\t// Execute loader\n\t\tconst result = await loader({ request, params: {}, context: {} })\n\n\t\t// Verify result\n\t\texpect(result.data).toBeDefined()\n\t})\n})\n```\n\n### Testing Actions\n\n**Test action:**\n\n```typescript\n// tests/e2e/notes.test.ts\ntest('User can create note', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\n\tconst formData = new FormData()\n\tformData.set('title', 'Test Note')\n\tformData.set('content', 'Test Content')\n\n\tawait page.getByRole('textbox', { name: /title/i }).fill('Test Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Test Content')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify that note was created\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### Testing Permissions\n\n**Test permissions:**\n\n```typescript\ntest('Only owner can delete note', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\tconst owner = await login()\n\tconst otherUser = await insertNewUser()\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test',\n\t\t\townerId: owner.id,\n\t\t},\n\t})\n\n\t// Login as other user\n\tconst session = await createSession(otherUser.id)\n\tawait page.context().addCookies([getCookie(session)])\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: owner.username,\n\t\tnoteId: note.id,\n\t})\n\n\t// Verify that can't delete\n\tawait expect(page.getByRole('button', { name: /delete/i })).not.toBeVisible()\n})\n```\n\n### DB Helpers\n\n**Create user:**\n\n```typescript\nimport { createUser } from '#tests/db-utils.ts'\n\nconst userData = createUser() // Generates unique random data\n```\n\n**Create password:**\n\n```typescript\nimport { createPassword } from '#tests/db-utils.ts'\n\nconst password = createPassword('mypassword') // { hash: '...' }\n```\n\n### Wait For Helper\n\nTo wait for async conditions:\n\n```typescript\nimport { waitFor } from '#tests/playwright-utils.ts'\n\nawait waitFor(\n\tasync () => {\n\t\tconst element = await page.getByText('Content loaded').first()\n\t\texpect(element).toBeVisible()\n\t\treturn element\n\t},\n\t{ timeout: 5000, errorMessage: 'Content never loaded' },\n)\n```\n\n### Testing GitHub OAuth\n\n**Prepare GitHub user:**\n\n```typescript\ntest('User can login with GitHub', async ({\n\tpage,\n\tnavigate,\n\tprepareGitHubUser,\n}) => {\n\tconst ghUser = await prepareGitHubUser()\n\n\tawait navigate('/login')\n\tawait page.getByRole('link', { name: /github/i }).click()\n\n\t// GitHub user is automatically prepared\n\tawait expect(page).toHaveURL('/onboarding/github')\n})\n```\n\n## Common examples\n\n### Example 1: Complete E2E test (resembling user workflow)\n\n```typescript\n// tests/e2e/notes.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { faker } from '@faker-js/faker'\n\ntest('Users can create, edit, and delete notes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n}) => {\n\t// User logs in (realistic workflow)\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\t// User creates a new note (clicking link, filling form, submitting)\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\tconst newNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(newNote.title)\n\tawait page.getByRole('textbox', { name: /content/i }).fill(newNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees their note with correct title and content\n\tawait expect(page.getByRole('heading', { name: newNote.title })).toBeVisible()\n\tawait expect(page.getByText(newNote.content)).toBeVisible()\n\tconst noteUrl = page.url()\n\tconst noteId = noteUrl.split('/').pop()\n\n\t// User edits the note (clicking edit, updating fields, saving)\n\tawait page.getByRole('link', { name: /edit/i }).click()\n\tconst updatedNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title)\n\tawait page\n\t\t.getByRole('textbox', { name: /content/i })\n\t\t.fill(updatedNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees updated content\n\tawait expect(\n\t\tpage.getByRole('heading', { name: updatedNote.title }),\n\t).toBeVisible()\n\tawait expect(page.getByText(updatedNote.content)).toBeVisible()\n\n\t// User deletes the note (clicking delete button)\n\tawait page.getByRole('button', { name: /delete/i }).click()\n\n\t// Specific assertion: user is redirected back to notes list\n\tawait expect(page).toHaveURL(`/users/${user.username}/notes`)\n\tawait expect(page.getByText(updatedNote.title)).not.toBeVisible()\n})\n```\n\n### Example 2: Unit test for utility\n\n```typescript\n// app/utils/misc.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { cn } from './misc.tsx'\n\ndescribe('cn', () => {\n\tit('should merge class names', () => {\n\t\texpect(cn('foo', 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', undefined, 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')\n\t})\n})\n```\n\n### Example 3: Form validation test\n\n```typescript\n// tests/e2e/signup.test.ts\ntest('Signup form validation', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit without filling\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify errors\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\n\t// Fill invalid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('invalid')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify email error\n\tawait expect(page.getByText(/email is invalid/i)).toBeVisible()\n\n\t// Fill valid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('test@example.com')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify redirect to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n})\n```\n\n### Example 4: Permissions test\n\n```typescript\n// tests/e2e/permissions.test.ts\ntest('Only admin can access admin routes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\t// Test with normal user\n\tconst normalUser = await login()\n\n\tawait navigate('/admin/users')\n\n\t// Should redirect or show error\n\tawait expect(page).toHaveURL('/') // Or verify error message\n\n\t// Test with admin\n\tawait page.context().clearCookies()\n\tconst admin = await insertNewUser()\n\tawait prisma.user.update({\n\t\twhere: { id: admin.id },\n\t\tdata: {\n\t\t\troles: {\n\t\t\t\tconnect: { name: 'admin' },\n\t\t\t},\n\t\t},\n\t})\n\n\t// Login as admin\n\tconst adminSession = await createSession(admin.id)\n\tawait page.context().addCookies([getCookie(adminSession)])\n\n\tawait navigate('/admin/users')\n\n\t// Now should work\n\tawait expect(page.getByText('All Users')).toBeVisible()\n})\n```\n\n## Common mistakes to avoid\n\n- ❌ **Testing implementation details instead of user workflows**: Write tests\n  that mirror how users actually use your app\n- ❌ **Vague assertions**: Use specific, meaningful assertions that clearly\n  communicate expected behavior\n- ❌ **Not cleaning data after tests**: Epic Stack cleans automatically, but\n  make sure not to depend on data between tests\n- ❌ **Assuming execution order**: Tests must be independent\n- ❌ **Not using fixtures**: Use `login`, `insertNewUser`, etc. instead of\n  creating everything manually\n- ❌ **Hardcoding data**: Use `faker` to generate unique data\n- ❌ **Not waiting for elements**: Use `expect` with `toBeVisible()` instead of\n  assuming it exists\n- ❌ **Not using type-safe navigation**: Use `navigate` helper instead of\n  `page.goto()` directly\n- ❌ **Forgetting MSW in tests**: External services are automatically mocked\n  when `MOCKS=true`\n- ❌ **Not testing error cases**: Test both happy path and errors\n- ❌ **Testing internal state instead of user-visible behavior**: Focus on what\n  users see and do\n\n## References\n\n- [Epic Stack Testing Docs](../epic-stack/docs/testing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Vitest Documentation](https://vitest.dev/)\n- [Playwright Documentation](https://playwright.dev/)\n- [Testing Library](https://testing-library.com/)\n- [MSW](https://mswjs.io/)\n- `tests/playwright-utils.ts` - Playwright fixtures and helpers\n- `tests/db-utils.ts` - DB helpers for tests\n- `tests/e2e/` - E2E test examples\n- `app/utils/*.test.ts` - Unit test examples\n","docs/skills/epic-ui-guidelines/SKILL.md":"---\nname: epic-ui-guidelines\ndescription:\n  Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack\ncategories:\n  - ui\n  - accessibility\n  - tailwind\n  - radix-ui\n---\n\n# Epic Stack: UI Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create accessible UI components\n- Follow Epic Stack design patterns\n- Use Tailwind CSS effectively\n- Implement semantic HTML\n- Add ARIA attributes correctly\n- Create responsive layouts\n- Ensure proper form accessibility\n- Follow Epic Stack's UI component conventions\n\n## Patterns and conventions\n\n### UI Philosophy\n\nFollowing Epic Web principles:\n\n**Software is built for people, by people** - Accessibility isn't about checking\nboxes or meeting standards. It's about creating software that works for real\npeople with diverse needs, abilities, and contexts. Every UI decision should\nprioritize the human experience over technical convenience.\n\nAccessibility is not optional - it's how we ensure our software serves all\nusers, not just some. When you make UI accessible, you're making it better for\neveryone: clearer labels help all users, keyboard navigation helps power users,\nand semantic HTML helps search engines.\n\n**Example - Human-centered approach:**\n\n```typescript\n// ✅ Good - Built for people\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<Field\n\t\t\t\tlabelProps={{\n\t\t\t\t\thtmlFor: fields.title.id,\n\t\t\t\t\tchildren: 'Note Title', // Clear, human-readable label\n\t\t\t\t}}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.title),\n\t\t\t\t\tplaceholder: 'Enter a descriptive title', // Helpful guidance\n\t\t\t\t\tautoFocus: true, // Saves time for users\n\t\t\t\t}}\n\t\t\t\terrors={fields.title.errors} // Clear error messages\n\t\t\t/>\n\t\t</Form>\n\t)\n}\n\n// ❌ Avoid - Technical convenience over user experience\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<input name=\"title\" /> {/* No label, no guidance, no accessibility */}\n\t\t</Form>\n\t)\n}\n```\n\n### Semantic HTML\n\n**✅ Good - Use semantic elements:**\n\n```typescript\nfunction UserCard({ user }: { user: User }) {\n\treturn (\n\t\t<article>\n\t\t\t<header>\n\t\t\t\t<h2>{user.name}</h2>\n\t\t\t</header>\n\t\t\t<p>{user.bio}</p>\n\t\t\t<footer>\n\t\t\t\t<time dateTime={user.createdAt}>{formatDate(user.createdAt)}</time>\n\t\t\t</footer>\n\t\t</article>\n\t)\n}\n```\n\n**❌ Avoid - Generic divs:**\n\n```typescript\n// ❌ Don't use divs for everything\n<div>\n\t<div>{user.name}</div>\n\t<div>{user.bio}</div>\n\t<div>{formatDate(user.createdAt)}</div>\n</div>\n```\n\n### Form Accessibility\n\n**✅ Good - Always use labels:**\n\n```typescript\nimport { Field } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\nThe `Field` component automatically:\n\n- Associates labels with inputs using `htmlFor` and `id`\n- Adds `aria-invalid` when there are errors\n- Adds `aria-describedby` pointing to error messages\n- Ensures proper error announcement\n\n**❌ Avoid - Unlabeled inputs:**\n\n```typescript\n// ❌ Don't forget labels\n<input type=\"email\" name=\"email\" />\n```\n\n### ARIA Attributes\n\n**✅ Good - Use ARIA appropriately:**\n\n```typescript\n// Epic Stack's Field component handles this automatically\n<Field\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t// aria-invalid and aria-describedby are added automatically\n\t}}\n\terrors={fields.email.errors} // Error messages are linked via aria-describedby\n/>\n```\n\n**✅ Good - ARIA for custom components:**\n\n```typescript\nfunction LoadingButton({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) {\n\treturn (\n\t\t<button aria-busy={isLoading} disabled={isLoading}>\n\t\t\t{isLoading ? 'Loading...' : children}\n\t\t</button>\n\t)\n}\n```\n\n### Using Radix UI Components\n\nEpic Stack uses Radix UI for accessible, unstyled components.\n\n**✅ Good - Use Radix primitives:**\n\n```typescript\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button } from '#app/components/ui/button.tsx'\n\nfunction MyDialog() {\n\treturn (\n\t\t<Dialog.Root>\n\t\t\t<Dialog.Trigger asChild>\n\t\t\t\t<Button>Open Dialog</Button>\n\t\t\t</Dialog.Trigger>\n\t\t\t<Dialog.Portal>\n\t\t\t\t<Dialog.Overlay className=\"fixed inset-0 bg-black/50\" />\n\t\t\t\t<Dialog.Content className=\"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6\">\n\t\t\t\t\t<Dialog.Title>Dialog Title</Dialog.Title>\n\t\t\t\t\t<Dialog.Description>Dialog description</Dialog.Description>\n\t\t\t\t\t<Dialog.Close asChild>\n\t\t\t\t\t\t<Button>Close</Button>\n\t\t\t\t\t</Dialog.Close>\n\t\t\t\t</Dialog.Content>\n\t\t\t</Dialog.Portal>\n\t\t</Dialog.Root>\n\t)\n}\n```\n\nRadix components automatically handle:\n\n- Keyboard navigation\n- Focus management\n- ARIA attributes\n- Screen reader announcements\n\n### Tailwind CSS Patterns\n\n**✅ Good - Use Tailwind utility classes:**\n\n```typescript\nfunction Card({ children }: { children: React.ReactNode }) {\n\treturn (\n\t\t<div className=\"rounded-lg border border-gray-200 bg-white p-6 shadow-sm\">\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Tailwind responsive utilities:**\n\n```typescript\n<div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">\n\t{items.map(item => (\n\t\t<Card key={item.id}>{item.name}</Card>\n\t))}\n</div>\n```\n\n**✅ Good - Use Tailwind dark mode:**\n\n```typescript\n<div className=\"bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n### Error Handling in Forms\n\n**✅ Good - Display errors accessibly:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\tinputProps={getInputProps(fields.email, { type: 'email' })}\n\terrors={fields.email.errors} // Errors are displayed below input\n/>\n\n<ErrorList errors={form.errors} id={form.errorId} /> // Form-level errors\n```\n\nErrors are automatically:\n\n- Associated with inputs via `aria-describedby`\n- Announced to screen readers\n- Visually distinct with error styling\n\n### Focus Management\n\n**✅ Good - Visible focus indicators:**\n\n```typescript\n// Tailwind's default focus:ring handles this\n<button className=\"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Focus on form errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus() {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\tfirstErrorRef.current.focus()\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Keyboard Navigation\n\n**✅ Good - Keyboard accessible components:**\n\n```typescript\n// Radix components handle keyboard navigation automatically\n<Dialog.Trigger asChild>\n\t<Button>Open</Button>\n</Dialog.Trigger>\n\n// Custom components should support keyboard\n<button\n\tonKeyDown={(e) => {\n\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\thandleClick()\n\t\t}\n\t}}\n>\n\tCustom Button\n</button>\n```\n\n### Color Contrast\n\n**✅ Good - Use accessible color combinations:**\n\n```typescript\n// Use Tailwind's semantic colors that meet WCAG AA\n<div className=\"bg-white text-gray-900\"> // High contrast\n<div className=\"text-blue-600 hover:text-blue-700\"> // Accessible links\n```\n\n**❌ Avoid - Low contrast text:**\n\n```typescript\n// ❌ Don't use low contrast\n<div className=\"bg-gray-100 text-gray-200\"> // Very low contrast\n```\n\n### Responsive Design\n\n**✅ Good - Mobile-first approach:**\n\n```typescript\n<div className=\"\n\tflex flex-col gap-4\n\tmd:flex-row md:gap-8\n\tlg:gap-12\n\">\n\t{/* Content */}\n</div>\n```\n\n**✅ Good - Responsive typography:**\n\n```typescript\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">\n\tResponsive Heading\n</h1>\n```\n\n### Loading States\n\n**✅ Good - Accessible loading indicators:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SubmitButton() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"submit\"\n\t\t\tdisabled={isSubmitting}\n\t\t\taria-busy={isSubmitting}\n\t\t>\n\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t</button>\n\t)\n}\n```\n\n### Icon Usage\n\n**✅ Good - Decorative icons:**\n\n```typescript\nimport { Icon } from '#app/components/ui/icon.tsx'\n\n<button aria-label=\"Delete note\">\n\t<Icon name=\"trash\" />\n\t<span className=\"sr-only\">Delete note</span>\n</button>\n```\n\n**✅ Good - Semantic icons:**\n\n```typescript\n<button>\n\t<Icon name=\"check\" aria-hidden=\"true\" />\n\tSave\n</button>\n```\n\n### Skip Links\n\n**✅ Good - Add skip to main content:**\n\n```typescript\n// In your root layout\n<a href=\"#main-content\" className=\"sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white\">\n\tSkip to main content\n</a>\n\n<main id=\"main-content\">\n\t{/* Main content */}\n</main>\n```\n\n### Progressive Enhancement\n\n**✅ Good - Forms work without JavaScript:**\n\n```typescript\n// Conform forms work without JavaScript\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<Field {...props} />\n\t<StatusButton type=\"submit\">Submit</StatusButton>\n</Form>\n```\n\nForms automatically:\n\n- Submit via native HTML forms if JavaScript is disabled\n- Validate server-side\n- Show errors appropriately\n\n### Screen Reader Best Practices\n\n**✅ Good - Use semantic HTML first:**\n\n```typescript\n// ✅ Semantic HTML provides context automatically\n<nav aria-label=\"Main navigation\">\n\t<ul>\n\t\t<li><a href=\"/\">Home</a></li>\n\t\t<li><a href=\"/about\">About</a></li>\n\t</ul>\n</nav>\n```\n\n**✅ Good - Announce dynamic content:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SearchResults({ results }: { results: Result[] }) {\n\tconst navigation = useNavigation()\n\tconst isSearching = navigation.state === 'loading'\n\n\treturn (\n\t\t<div\n\t\t\trole=\"status\"\n\t\t\taria-live=\"polite\"\n\t\t\taria-atomic=\"true\"\n\t\t\tclassName=\"sr-only\"\n\t\t>\n\t\t\t{isSearching ? 'Searching...' : `${results.length} results found`}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Live regions for important updates:**\n\n```typescript\nfunction ToastContainer({ toasts }: { toasts: Toast[] }) {\n\treturn (\n\t\t<div aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n\t\t\t{toasts.map(toast => (\n\t\t\t\t<div key={toast.id} role=\"alert\">\n\t\t\t\t\t{toast.message}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n**ARIA live region options:**\n\n- `aria-live=\"polite\"` - For non-critical updates (search results, status\n  messages)\n- `aria-live=\"assertive\"` - For critical updates (errors, confirmations)\n- `aria-atomic=\"true\"` - Screen reader reads entire region on update\n- `aria-atomic=\"false\"` - Screen reader reads only changed parts\n\n### Keyboard Navigation Patterns\n\n**✅ Good - Tab order follows visual order:**\n\n```typescript\n// Elements appear in logical tab order\n<nav>\n\t<a href=\"/\">Home</a>\n\t<a href=\"/about\">About</a>\n\t<a href=\"/contact\">Contact</a>\n</nav>\n```\n\n**✅ Good - Keyboard shortcuts:**\n\n```typescript\nimport { useEffect } from 'react'\n\nfunction SearchDialog({ onClose }: { onClose: () => void }) {\n\tuseEffect(() => {\n\t\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tonClose()\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [onClose])\n\n\treturn <Dialog>{/* content */}</Dialog>\n}\n```\n\n**✅ Good - Focus trap in modals:**\n\n```typescript\n// Radix Dialog automatically handles focus trap\n<Dialog.Root>\n\t<Dialog.Content>\n\t\t{/* Focus is trapped inside dialog */}\n\t\t<Dialog.Close>Close</Dialog.Close>\n\t</Dialog.Content>\n</Dialog.Root>\n```\n\n### Focus Management for React Router\n\n**✅ Good - Focus on route changes:**\n\n```typescript\nimport { useEffect } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction RouteComponent() {\n\tconst navigation = useNavigation()\n\tconst mainRef = useRef<HTMLElement>(null)\n\n\tuseEffect(() => {\n\t\tif (navigation.state === 'idle' && mainRef.current) {\n\t\t\tmainRef.current.focus()\n\t\t}\n\t}, [navigation.state])\n\n\treturn (\n\t\t<main ref={mainRef} tabIndex={-1}>\n\t\t\t{/* Content */}\n\t\t</main>\n\t)\n}\n```\n\n**✅ Good - Focus on errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus({ actionData }: Route.ComponentProps) {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\t// Focus first error field\n\t\t\tfirstErrorRef.current.focus()\n\t\t\t// Announce error\n\t\t\tfirstErrorRef.current.setAttribute('aria-invalid', 'true')\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Typography and Readability\n\n**✅ Good - Readable text sizes:**\n\n```typescript\n// Use Tailwind's text size scale\n<p className=\"text-base md:text-lg\">Readable body text</p>\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">Clear headings</h1>\n```\n\n**✅ Good - Sufficient line height:**\n\n```typescript\n// Tailwind defaults provide good line height\n<p className=\"leading-relaxed\">Comfortable reading</p>\n```\n\n**❌ Avoid - Small or hard-to-read text:**\n\n```typescript\n// ❌ Don't use very small text\n<p className=\"text-xs\">Hard to read</p>\n```\n\n### Touch Target Sizes\n\n**✅ Good - Sufficient touch targets:**\n\n```typescript\n// Buttons should be at least 44x44px (touch target size)\n<button className=\"min-h-[44px] min-w-[44px] px-4 py-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Spacing between interactive elements:**\n\n```typescript\n<div className=\"flex gap-4\">\n\t<Button>Save</Button>\n\t<Button>Cancel</Button>\n</div>\n```\n\n### Internationalization (i18n) Considerations\n\n**✅ Good - Use semantic HTML for dates/times:**\n\n```typescript\n<time dateTime={note.createdAt.toISOString()}>\n\t{formatDate(note.createdAt)}\n</time>\n```\n\n**✅ Good - Use semantic HTML for numbers:**\n\n```typescript\n// Screen readers can pronounce numbers correctly\n<p>Total: <span aria-label={`${count} items`}>{count}</span></p>\n```\n\n**✅ Good - Language attributes:**\n\n```typescript\n// In root.tsx\n<html lang=\"en\">\n\t<body>\n\t\t{/* Content */}\n\t</body>\n</html>\n```\n\n### Dark Mode Accessibility\n\n**✅ Good - Maintain contrast in dark mode:**\n\n```typescript\n// Ensure sufficient contrast in both modes\n<div className=\"bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n**✅ Good - Respect user preference:**\n\n```typescript\n// Epic Stack automatically handles theme preference\n// Use semantic colors that work in both modes\n<button className=\"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600\">\n\tButton\n</button>\n```\n\n### Animation and Motion\n\n**✅ Good - Respect reduced motion:**\n\n```typescript\n// Tailwind automatically respects prefers-reduced-motion\n<div className=\"transition-transform duration-200 hover:scale-105 motion-reduce:transition-none\">\n\t{/* Animations disabled for users who prefer reduced motion */}\n</div>\n```\n\n**✅ Good - Use CSS for animations:**\n\n```typescript\n// ✅ CSS animations can be disabled via prefers-reduced-motion\n<div className=\"animate-fade-in\">\n\t{/* Content */}\n</div>\n\n// ❌ JavaScript animations may not respect user preferences\n```\n\n## Common mistakes to avoid\n\n- ❌ **Treating accessibility as a checklist**: Accessibility is about serving\n  real people, not just meeting standards\n- ❌ **Missing form labels**: Always use `Field` component which includes\n  labels - helps all users, not just screen reader users\n- ❌ **Using divs for semantic elements**: Use `<article>`, `<header>`, `<nav>`,\n  etc. - helps all users understand content structure\n- ❌ **Ignoring keyboard navigation**: Ensure all interactive elements are\n  keyboard accessible - helps power users and those who can't use a mouse\n- ❌ **Low color contrast**: Test color combinations for WCAG AA compliance -\n  helps users with visual impairments and in bright sunlight\n- ❌ **Missing ARIA attributes**: Use Epic Stack components which handle this\n  automatically\n- ❌ **Breaking focus management**: Let Radix components handle focus\n- ❌ **Not testing with screen readers**: Test with VoiceOver, NVDA, or JAWS -\n  understand how real users experience your UI\n- ❌ **Hiding content from screen readers**: Use `sr-only` instead of\n  `display: none` for screen reader only content\n- ❌ **Ignoring mobile users**: Always test on mobile devices - many users only\n  have mobile access\n- ❌ **Not using Tailwind's responsive utilities**: Use mobile-first responsive\n  design\n- ❌ **Not using live regions**: Use `aria-live` for dynamic content\n  announcements\n- ❌ **Small touch targets**: Ensure interactive elements are at least 44x44px -\n  helps users with motor impairments and on mobile\n- ❌ **Ignoring reduced motion**: Respect `prefers-reduced-motion` media query -\n  helps users with vestibular disorders\n- ❌ **Poor focus indicators**: Ensure focus is always visible - helps keyboard\n  users navigate\n- ❌ **Missing skip links**: Add skip to main content links for keyboard users\n\n## References\n\n- [Web Content Accessibility Guidelines (WCAG)](https://www.w3.org/WAI/WCAG21/quickref/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Radix UI Documentation](https://www.radix-ui.com/)\n- [Tailwind CSS Documentation](https://tailwindcss.com/)\n- [Epic Stack Forms Documentation](../epic-forms/SKILL.md)\n- `app/components/forms.tsx` - Accessible form components\n- `app/components/ui/` - Accessible UI components using Radix\n- `app/styles/tailwind.css` - Tailwind configuration\n"},"files":{".cursor/rules/avoid-use-effect.mdc":"---\ndescription: \nglobs: *.tsx,*.jsx\nalwaysApply: false\n---\n### Avoid useEffect\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use ref callbacks, event handlers with\n`flushSync`, css, `useSyncExternalStore`, etc.\n\n```tsx\n// This example was ripped from the docs:\n// ✅ Good\nfunction ProductPage({ product, addToCart }) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n\nuseEffect(() => {\n\tsetCount(count + 1)\n}, [count])\n\n// ❌ Avoid\nfunction ProductPage({ product, addToCart }) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\taddToCart(product)\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n```\n\nThere are a lot more examples in the docs. `useEffect` is not banned or\nanything. There are just better ways to handle most cases.\n\nHere's an example of a situation where `useEffect` is appropriate:\n\n```tsx\n// ✅ Good\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\n\t\t\t// do something based on escape key being pressed\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n","docs/routing.md":"# Routing\n\nThe Epic Stack uses file-based routing with React Router. However, it's not\nusing the built-in routing convention of React Router and instead is using\n[react-router-auto-routes](https://github.com/kenn/react-router-auto-routes)\nwhich is a special implementation of the React Router convention that adds a few\nfeatures. You'll find it configured for the application in the `app/routes.ts`\nfile at the root of the app.\n\nWe'll defer to the `react-router-auto-routes` documentation for specifics, but\nan important thing for you to know as you get used to this convention is you can\nalways run `npx react-router routes` from the root of the app and it will output\nthe routes of your application in a JSX-like output that will reveal the routes\nthat will be generated based on your current file structure. Here's an example\nof the Epic Stack routes at the time of this writing:\n\n```\napp/routes\n├── $.tsx\n├── me.tsx\n├── _auth\n│   ├── forgot-password.tsx\n│   ├── login.tsx\n│   ├── logout.tsx\n│   ├── reset-password.tsx\n│   ├── signup.tsx\n│   ├── verify.tsx\n│   ├── auth.$provider\n│   │   ├── callback.ts\n│   │   └── index.ts\n│   ├── onboarding\n│   │   ├── $provider.tsx\n│   │   └── index.tsx\n│   └── webauthn\n│       ├── authentication.ts\n│       └── registration.ts\n├── _marketing\n│   ├── about.tsx\n│   ├── index.tsx\n│   ├── privacy.tsx\n│   ├── support.tsx\n│   ├── tos.tsx\n│   └── +logos\n│       ├── logos.ts\n│       └── ...\n├── _seo\n│   ├── robots[.]txt.ts\n│   └── sitemap[.]xml.ts\n├── admin\n│   └── cache\n│       ├── index.tsx\n│       ├── lru.$cacheKey.ts\n│       ├── sqlite.$cacheKey.ts\n│       └── sqlite.tsx\n├── resources\n│   ├── download-user-data.tsx\n│   ├── healthcheck.tsx\n│   ├── images.tsx\n│   └── theme-switch.tsx\n├── settings\n│   └── profile\n│       ├── _layout.tsx\n│       ├── change-email.tsx\n│       ├── connections.tsx\n│       ├── index.tsx\n│       ├── passkeys.tsx\n│       ├── password.tsx\n│       ├── password_.create.tsx\n│       ├── photo.tsx\n│       └── two-factor\n│           ├── _layout.tsx\n│           ├── disable.tsx\n│           ├── index.tsx\n│           └── verify.tsx\n└── users\n    ├── index.tsx\n    └── $username\n        ├── index.tsx\n        └── notes\n            ├── $noteId.tsx\n            ├── $noteId_.edit.tsx\n            ├── _layout.tsx\n            ├── index.tsx\n            └── new.tsx\n\n17 directories, 72 files\n```\n\n```tsx\n<Routes>\n\t<Route file=\"root.tsx\">\n\t\t<Route path=\"*\" file=\"routes/$.tsx\" />\n\t\t<Route\n\t\t\tpath=\"auth/:provider/callback\"\n\t\t\tfile=\"routes/_auth/auth.$provider/callback.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"auth/:provider\"\n\t\t\tindex\n\t\t\tfile=\"routes/_auth/auth.$provider/index.ts\"\n\t\t/>\n\t\t<Route path=\"forgot-password\" file=\"routes/_auth/forgot-password.tsx\" />\n\t\t<Route path=\"login\" file=\"routes/_auth/login.tsx\" />\n\t\t<Route path=\"logout\" file=\"routes/_auth/logout.tsx\" />\n\t\t<Route\n\t\t\tpath=\"onboarding/:provider\"\n\t\t\tfile=\"routes/_auth/onboarding/$provider.tsx\"\n\t\t/>\n\t\t<Route path=\"onboarding\" index file=\"routes/_auth/onboarding/index.tsx\" />\n\t\t<Route path=\"reset-password\" file=\"routes/_auth/reset-password.tsx\" />\n\t\t<Route path=\"signup\" file=\"routes/_auth/signup.tsx\" />\n\t\t<Route path=\"verify\" file=\"routes/_auth/verify.tsx\" />\n\t\t<Route\n\t\t\tpath=\"webauthn/authentication\"\n\t\t\tfile=\"routes/_auth/webauthn/authentication.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"webauthn/registration\"\n\t\t\tfile=\"routes/_auth/webauthn/registration.ts\"\n\t\t/>\n\t\t<Route path=\"about\" file=\"routes/_marketing/about.tsx\" />\n\t\t<Route index file=\"routes/_marketing/index.tsx\" />\n\t\t<Route path=\"privacy\" file=\"routes/_marketing/privacy.tsx\" />\n\t\t<Route path=\"support\" file=\"routes/_marketing/support.tsx\" />\n\t\t<Route path=\"tos\" file=\"routes/_marketing/tos.tsx\" />\n\t\t<Route path=\"robots.txt\" file=\"routes/_seo/robots[.]txt.ts\" />\n\t\t<Route path=\"sitemap.xml\" file=\"routes/_seo/sitemap[.]xml.ts\" />\n\t\t<Route path=\"admin/cache\" index file=\"routes/admin/cache/index.tsx\" />\n\t\t<Route\n\t\t\tpath=\"admin/cache/lru/:cacheKey\"\n\t\t\tfile=\"routes/admin/cache/lru.$cacheKey.ts\"\n\t\t/>\n\t\t<Route path=\"admin/cache/sqlite\" file=\"routes/admin/cache/sqlite.tsx\">\n\t\t\t<Route path=\":cacheKey\" file=\"routes/admin/cache/sqlite.$cacheKey.ts\" />\n\t\t</Route>\n\t\t<Route path=\"me\" file=\"routes/me.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/download-user-data\"\n\t\t\tfile=\"routes/resources/download-user-data.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"resources/healthcheck\"\n\t\t\tfile=\"routes/resources/healthcheck.tsx\"\n\t\t/>\n\t\t<Route path=\"resources/images\" file=\"routes/resources/images.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/theme-switch\"\n\t\t\tfile=\"routes/resources/theme-switch.tsx\"\n\t\t/>\n\t\t<Route path=\"settings/profile\" file=\"routes/settings/profile/_layout.tsx\">\n\t\t\t<Route\n\t\t\t\tpath=\"change-email\"\n\t\t\t\tfile=\"routes/settings/profile/change-email.tsx\"\n\t\t\t/>\n\t\t\t<Route\n\t\t\t\tpath=\"connections\"\n\t\t\t\tfile=\"routes/settings/profile/connections.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/settings/profile/index.tsx\" />\n\t\t\t<Route path=\"passkeys\" file=\"routes/settings/profile/passkeys.tsx\" />\n\t\t\t<Route path=\"password\" file=\"routes/settings/profile/password.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"password/create\"\n\t\t\t\tfile=\"routes/settings/profile/password_.create.tsx\"\n\t\t\t/>\n\t\t\t<Route path=\"photo\" file=\"routes/settings/profile/photo.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"two-factor\"\n\t\t\t\tfile=\"routes/settings/profile/two-factor/_layout.tsx\"\n\t\t\t>\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"disable\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/disable.tsx\"\n\t\t\t\t/>\n\t\t\t\t<Route index file=\"routes/settings/profile/two-factor/index.tsx\" />\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"verify\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/verify.tsx\"\n\t\t\t\t/>\n\t\t\t</Route>\n\t\t</Route>\n\t\t<Route\n\t\t\tpath=\"users/:username\"\n\t\t\tindex\n\t\t\tfile=\"routes/users/$username/index.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"users/:username/notes\"\n\t\t\tfile=\"routes/users/$username/notes/_layout.tsx\"\n\t\t>\n\t\t\t<Route path=\":noteId\" file=\"routes/users/$username/notes/$noteId.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\":noteId/edit\"\n\t\t\t\tfile=\"routes/users/$username/notes/$noteId_.edit.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/users/$username/notes/index.tsx\" />\n\t\t\t<Route path=\"new\" file=\"routes/users/$username/notes/new.tsx\" />\n\t\t</Route>\n\t\t<Route path=\"users\" index file=\"routes/users/index.tsx\" />\n\t</Route>\n</Routes>\n```\n\nBasically, react-router-auto-routes allows us to get the best of both worlds:\n\n- Colocation of routes to the code they use\n- Organized folder structure to keep routes together as needed\n\nIf you're familiar with the React Router routing convention, just think of it\nthis way, react-router-auto-routes takes `+` prefix for colocated modules.\n","docs/skills/epic-auth/SKILL.md":"---\nname: epic-auth\ndescription:\n  Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack\ncategories:\n  - authentication\n  - sessions\n  - oauth\n  - security\n---\n\n# Epic Stack: Authentication\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement user authentication\n- Work with sessions and cookies\n- Configure OAuth providers (GitHub, Google, etc.)\n- Implement 2FA (Two-Factor Authentication)\n- Implement WebAuthn/Passkeys\n- Handle login, signup, logout flows\n- Manage email verification\n- Implement password reset\n\n## Patterns and conventions\n\n### Authentication Philosophy\n\nFollowing Epic Web principles:\n\n**Least privilege** - Users should only have access to what they need, when they\nneed it. Sessions should have minimal permissions and expire appropriately.\nDon't grant more access than necessary.\n\n**Design to fail fast and early** - Validate authentication and authorization as\nearly as possible. Check session validity immediately, verify permissions before\nprocessing requests, and return clear errors quickly.\n\n**Example - Least privilege in sessions:**\n\n```typescript\n// ✅ Good - Minimal session data, explicit permissions\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId, // Only store user ID, not full user data\n\t},\n})\n\n// Session only grants access to this specific user\n// Permissions checked separately when needed\n\n// ❌ Avoid - Storing too much in session\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t\tuserRole: 'admin', // Don't store roles in session\n\t\tpermissions: ['all'], // Don't store permissions in session\n\t},\n})\n// Roles and permissions should be checked from database, not session\n```\n\n**Example - Fail fast authentication:**\n\n```typescript\n// ✅ Good - Validate authentication early\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check authentication immediately - fail fast\n\tconst userId = await requireUserId(request)\n\n\t// Check permissions early - fail fast\n\tawait requireUserWithPermission(request, 'read:note:own')\n\n\t// Only proceed if authenticated and authorized\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: userId },\n\t})\n\n\treturn { notes }\n}\n\n// ❌ Avoid - Delayed authentication check\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Process request first...\n\tconst notes = await prisma.note.findMany()\n\n\t// Check authentication at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed request\n\t\tthrow redirect('/login')\n\t}\n}\n```\n\n### Cookie-based Sessions\n\nEpic Stack uses cookie-based sessions for authentication. Sessions are stored in\nthe database and identified by signed cookies.\n\n**Session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nimport { createCookieSessionStorage } from 'react-router'\n\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true,\n\t\tsecrets: process.env.SESSION_SECRET.split(','),\n\t\tsecure: process.env.NODE_ENV === 'production',\n\t},\n})\n```\n\n### Get current user\n\n**Server-side:**\n\n```typescript\nimport { getUserId, requireUserId } from '#app/utils/auth.server.ts'\n\n// Get userId or null if not authenticated\nconst userId = await getUserId(request)\n\n// Require authenticated user (redirects to /login if not)\nconst userId = await requireUserId(request)\nconst userId = await requireUserId(request, { redirectTo: '/custom-login' })\n\n// Require that user is NOT authenticated\nimport { requireAnonymous } from '#app/utils/auth.server.ts'\nawait requireAnonymous(request) // Redirects to / if authenticated\n```\n\n**Client-side:**\n\n```typescript\nimport { useOptionalUser, useUser } from '#app/utils/user.ts'\n\n// Get user or undefined if not authenticated\nconst user = useOptionalUser()\n\n// Get authenticated user (throws error if not)\nconst user = useUser()\n```\n\n### Login with Email/Password\n\n**Validation schema:**\n\n```typescript\nconst LoginSchema = z.object({\n\tusername: UsernameSchema,\n\tpassword: z.string().min(1, 'Password is required'),\n\tredirectTo: z.string().optional(),\n\tremember: z.boolean().optional(),\n})\n```\n\n**Login action (fail fast):**\n\n```typescript\nimport { login } from '#app/utils/auth.server.ts'\nimport { handleNewSession } from './login.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate input early - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\t// Authenticate early - fail fast if invalid\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\t// Return error immediately - don't process further\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Only create session if authentication succeeded\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Signup with Email/Password\n\n**Signup action:**\n\n```typescript\nimport { signup } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate form...\n\n\tconst session = await signup({\n\t\temail,\n\t\tusername,\n\t\tpassword,\n\t\tname,\n\t})\n\n\t// Handle session and redirect...\n}\n```\n\n### OAuth Providers (GitHub, Google, etc.)\n\nEpic Stack uses `remix-auth` for OAuth providers.\n\n**Configure provider (GitHub example):**\n\n```typescript\n// app/utils/providers/github.server.ts\nimport { GitHubStrategy } from 'remix-auth-github'\n\nexport class GitHubProvider implements AuthProvider {\n\tgetAuthStrategy() {\n\t\treturn new GitHubStrategy(\n\t\t\t{\n\t\t\t\tclientID: process.env.GITHUB_CLIENT_ID,\n\t\t\t\tclientSecret: process.env.GITHUB_CLIENT_SECRET,\n\t\t\t\tcallbackURL: '/auth/github/callback',\n\t\t\t},\n\t\t\tasync ({ profile }) => {\n\t\t\t\t// Return user profile\n\t\t\t\treturn {\n\t\t\t\t\tid: profile.id,\n\t\t\t\t\temail: profile.emails[0].value,\n\t\t\t\t\tusername: profile.displayName,\n\t\t\t\t\tname: profile.displayName,\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\t}\n}\n```\n\n**Callback handler:**\n\n```typescript\n// app/routes/_auth/auth.$provider/callback.ts\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst providerName = ProviderNameSchema.parse(params.provider)\n\tconst authResult = await authenticator.authenticate(providerName, request)\n\n\tif (!authResult.success) {\n\t\tthrow redirectWithToast('/login', {\n\t\t\ttitle: 'Auth Failed',\n\t\t\tdescription: `Error authenticating with ${providerName}`,\n\t\t\ttype: 'error',\n\t\t})\n\t}\n\n\tconst { data: profile } = authResult\n\n\t// Check if connection exists\n\tconst existingConnection = await prisma.connection.findUnique({\n\t\twhere: {\n\t\t\tproviderName_providerId: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t},\n\t\t},\n\t})\n\n\t// If exists, create session\n\tif (existingConnection) {\n\t\treturn makeSession({ request, userId: existingConnection.userId })\n\t}\n\n\t// If email exists, link account\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { email: profile.email.toLowerCase() },\n\t})\n\tif (user) {\n\t\tawait prisma.connection.create({\n\t\t\tdata: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t\tuserId: user.id,\n\t\t\t},\n\t\t})\n\t\treturn makeSession({ request, userId: user.id })\n\t}\n\n\t// New user, go to onboarding\n\t// ...\n}\n```\n\n### WebAuthn/Passkeys\n\nEpic Stack supports authentication with passkeys using WebAuthn.\n\n**Loader to generate options:**\n\n```typescript\n// app/routes/_auth/webauthn/authentication.ts\nimport { generateAuthenticationOptions } from '@simplewebauthn/server'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst config = getWebAuthnConfig(request)\n\tconst options = await generateAuthenticationOptions({\n\t\trpID: config.rpID,\n\t\tuserVerification: 'preferred',\n\t})\n\n\tconst cookieHeader = await passkeyCookie.serialize({\n\t\tchallenge: options.challenge,\n\t})\n\n\treturn Response.json(\n\t\t{ options },\n\t\t{\n\t\t\theaders: { 'Set-Cookie': cookieHeader },\n\t\t},\n\t)\n}\n```\n\n**Action to verify authentication:**\n\n```typescript\nimport { verifyAuthenticationResponse } from '@simplewebauthn/server'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst cookie = await passkeyCookie.parse(request.headers.get('Cookie'))\n\n\tif (!cookie?.challenge) {\n\t\tthrow new Error('Authentication challenge not found')\n\t}\n\n\tconst { authResponse } = await request.json()\n\tconst passkey = await prisma.passkey.findUnique({\n\t\twhere: { id: authResponse.id },\n\t\tinclude: { user: true },\n\t})\n\n\tconst verification = await verifyAuthenticationResponse({\n\t\tresponse: authResponse,\n\t\texpectedChallenge: cookie.challenge,\n\t\texpectedOrigin: config.origin,\n\t\texpectedRPID: config.rpID,\n\t\tcredential: {\n\t\t\tid: authResponse.id,\n\t\t\tpublicKey: passkey.publicKey,\n\t\t\tcounter: Number(passkey.counter),\n\t\t},\n\t})\n\n\tif (!verification.verified) {\n\t\tthrow new Error('Authentication verification failed')\n\t}\n\n\t// Actualizar counter\n\tawait prisma.passkey.update({\n\t\twhere: { id: passkey.id },\n\t\tdata: { counter: BigInt(verification.authenticationInfo.newCounter) },\n\t})\n\n\t// Create sesión\n\tconst session = await prisma.session.create({\n\t\tdata: {\n\t\t\texpirationDate: getSessionExpirationDate(),\n\t\t\tuserId: passkey.userId,\n\t\t},\n\t})\n\n\treturn handleNewSession({ request, session, remember: true })\n}\n```\n\n### Two-Factor Authentication (2FA) with TOTP\n\nEpic Stack uses TOTP (Time-based One-Time Password) para 2FA.\n\n**Check if user has 2FA:**\n\n```typescript\nconst verification = await prisma.verification.findUnique({\n\twhere: {\n\t\ttarget_type: {\n\t\t\ttarget: session.userId,\n\t\t\ttype: twoFAVerificationType,\n\t\t},\n\t},\n})\nconst userHasTwoFactor = Boolean(verification)\n```\n\n**Handle session with 2FA:**\n\n```typescript\nexport async function handleNewSession({\n\trequest,\n\tsession,\n\tredirectTo,\n\tremember,\n}: {\n\trequest: Request\n\tsession: { userId: string; id: string; expirationDate: Date }\n\tredirectTo?: string\n\tremember: boolean\n}) {\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: {\n\t\t\ttarget_type: {\n\t\t\t\ttarget: session.userId,\n\t\t\t\ttype: twoFAVerificationType,\n\t\t\t},\n\t\t},\n\t})\n\tconst userHasTwoFactor = Boolean(verification)\n\n\tif (userHasTwoFactor) {\n\t\t// Save unverified session\n\t\tconst verifySession = await verifySessionStorage.getSession()\n\t\tverifySession.set(unverifiedSessionIdKey, session.id)\n\t\tverifySession.set(rememberKey, remember)\n\n\t\t// Redirect to 2FA verification\n\t\tconst redirectUrl = getRedirectToUrl({\n\t\t\trequest,\n\t\t\ttype: twoFAVerificationType,\n\t\t\ttarget: session.userId,\n\t\t\tredirectTo,\n\t\t})\n\t\treturn redirect(redirectUrl.toString(), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),\n\t\t\t},\n\t\t})\n\t} else {\n\t\t// User without 2FA, create session directly\n\t\tconst authSession = await authSessionStorage.getSession(\n\t\t\trequest.headers.get('cookie'),\n\t\t)\n\t\tauthSession.set(sessionKey, session.id)\n\t\treturn redirect(safeRedirect(redirectTo), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.commitSession(authSession, {\n\t\t\t\t\texpires: remember ? session.expirationDate : undefined,\n\t\t\t\t}),\n\t\t\t},\n\t\t})\n\t}\n}\n```\n\n**Verify 2FA code:**\n\n```typescript\nimport { prepareTOTP, verifyTOTP } from '@epic-web/totp'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: VerifySchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { code } = submission.value\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: { target_type: { target, type } },\n\t\tselect: {\n\t\t\tsecret: true,\n\t\t\talgorithm: true,\n\t\t\tperiod: true,\n\t\t\tdigits: true,\n\t\t},\n\t})\n\n\tif (!verification) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst isValid = verifyTOTP({\n\t\totp: code,\n\t\tsecret: verification.secret,\n\t\talgorithm: verification.algorithm as any,\n\t\tperiod: verification.period,\n\t\tdigits: verification.digits,\n\t})\n\n\tif (!isValid) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid code'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Verify session and complete login\n\treturn handleVerification({ request, submission })\n}\n```\n\n### Email Verification\n\nEpic Stack uses TOTP codes sent via email for verification.\n\n**Prepare verification:**\n\n```typescript\nimport { prepareVerification } from './verify.server.ts'\n\nconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\tperiod: 10 * 60, // 10 minutes\n\trequest,\n\ttype: 'onboarding',\n\ttarget: email,\n})\n\n// Enviar email con código y URL\nawait sendEmail({\n\tto: email,\n\tsubject: 'Welcome!',\n\treact: <VerificationEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n})\n\nreturn redirect(redirectTo.toString())\n```\n\n**Verify code:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/signup')\n\t}\n\n\treturn { target, type }\n}\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code (similar to 2FA)\n\t// ...\n}\n```\n\n### Password Reset\n\n**Request reset:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ForgotPasswordSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { email } = submission.value\n\n\t// Prepare verification\n\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\t\tperiod: 10 * 60,\n\t\trequest,\n\t\ttype: 'reset-password',\n\t\ttarget: email,\n\t})\n\n\t// Enviar email con código y URL\n\tawait sendEmail({\n\t\tto: email,\n\t\tsubject: 'Reset your password',\n\t\treact: <ResetPasswordEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n\t})\n\n\treturn redirect(redirectTo.toString())\n}\n```\n\n**Reset password:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code first (similar to email verification)\n\t// Then reset password\n\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ResetPasswordSchema,\n\t})\n\n\t// Verify that code is valid\n\t// ...\n\n\tconst { password } = submission.value\n\n\tawait resetUserPassword({\n\t\tusername,\n\t\tpassword,\n\t})\n\n\t// Destroy verification session and redirect to login\n\t// ...\n}\n```\n\n### Logout\n\n```typescript\nimport { logout } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\treturn logout({ request, redirectTo: '/' })\n}\n```\n\n### Session Management\n\n**Create session:**\n\n```typescript\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t},\n\tselect: { id: true, expirationDate: true },\n})\n```\n\n**Session expiration:**\n\n```typescript\nexport const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport const getSessionExpirationDate = () =>\n\tnew Date(Date.now() + SESSION_EXPIRATION_TIME)\n```\n\n**Destroy session:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { id: sessionId } })\n```\n\n**Destroy all user sessions:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { userId } })\n```\n\n## Common examples\n\n### Example 1: Complete login\n\n```typescript\n// app/routes/_auth/login.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Example 2: Protect route\n\n```typescript\n// app/routes/protected.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function ProtectedRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Datos protegidos */}</div>\n}\n```\n\n### Example 3: Route only for unauthenticated users\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireAnonymous(request)\n\treturn null\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed authentication checks**: Validate authentication and\n  authorization as early as possible - fail fast\n- ❌ **Granting excessive privileges**: Follow least privilege - only grant\n  access to what's needed, when it's needed\n- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user\n  ID), check permissions from database\n- ❌ **Not verifying session on each request**: Always use `getUserId` or\n  `requireUserId` in protected loaders/actions\n- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating\n  session\n- ❌ **Not destroying expired sessions**: Sessions must be verified against\n  `expirationDate` - check early\n- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and\n  cookie creation\n- ❌ **Forgetting to handle `remember`**: Make sure to respect user preference\n- ❌ **Not validating OAuth callbacks**: Always validate that provider exists\n  and result is successful - fail fast\n- ❌ **Not linking OAuth accounts**: If email exists, link the account instead\n  of creating duplicate\n- ❌ **Not updating counter in passkeys**: Always update counter after\n  successful verification\n\n## References\n\n- [Epic Stack Authentication Docs](../epic-stack/docs/authentication.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Remix Auth](https://github.com/sergiodxa/remix-auth)\n- [SimpleWebAuthn](https://simplewebauthn.dev/)\n- `app/utils/auth.server.ts` - Authentication utilities\n- `app/utils/session.server.ts` - Gestión de sesiones\n- `app/routes/_auth/login.server.ts` - Helpers de login\n- `app/routes/_auth/auth.$provider/callback.ts` - OAuth callback\n- `app/routes/_auth/webauthn/` - WebAuthn implementation\n","docs/skills/epic-caching/SKILL.md":"---\nname: epic-caching\ndescription:\n  Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack\ncategories:\n  - caching\n  - performance\n  - optimization\n---\n\n# Epic Stack: Caching\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Cache results of expensive queries\n- Cache responses from external APIs\n- Optimize performance of data that doesn't change frequently\n- Implement stale-while-revalidate\n- Manage cache invalidation\n- Integrate cache with server timing\n\n## Patterns and conventions\n\n### Caching Philosophy\n\nFollowing Epic Web principles:\n\n**Weigh the cost-benefit of performance optimizations** - Caching adds\ncomplexity. Only add cache when there's a clear, measurable benefit. Don't cache\n\"just in case\" - cache when you have a real performance problem that caching\nsolves.\n\n**When NOT to use cache:**\n\n- Data that changes frequently (cache invalidation becomes a problem)\n- Data that's already fast to fetch (no measurable benefit)\n- Data that's only fetched once (no benefit from caching)\n- Simple queries that don't need optimization\n- When cache invalidation logic becomes more complex than the problem it solves\n\n**Example - Evaluating cost-benefit:**\n\n```typescript\n// ✅ Good - Cache expensive external API call\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Expensive: External API call, rate limits, network latency\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour - reasonable for external data\n\t})\n}\n\n// ❌ Avoid - Caching simple, fast database query\nexport async function getUser({ userId }: { userId: string }) {\n\t// This query is already fast - caching adds complexity without benefit\n\treturn await cachified({\n\t\tkey: `user:${userId}`,\n\t\tcache,\n\t\tgetFreshValue: async () => {\n\t\t\t// Simple query, already fast\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: { id: true, username: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5,\n\t})\n\t// Better: Just query directly without cache\n}\n```\n\n### Two Types of Cache\n\nEpic Stack provides two types of cache:\n\n1. **SQLite Cache** - Long-lived, replicated with LiteFS\n   - Persistent across restarts\n   - Replicated across all instances\n   - Ideal for data that changes infrequently\n\n2. **LRU Cache** - Short-lived, in-memory\n   - Cleared on restart\n   - Not replicated (only on current instance)\n   - Ideal for deduplication and temporary cache\n\n### Using cachified\n\nEpic Stack uses `@epic-web/cachified` as an abstraction for cache management.\n\n**Basic import:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\n```\n\n**Basic structure:**\n\n```typescript\nexport async function getCachedData({\n\ttimings,\n}: {\n\ttimings?: Timings\n} = {}) {\n\treturn await cachified({\n\t\tkey: 'my-cache-key',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Get fresh data\n\t\t\treturn await fetchDataFromAPI()\n\t\t},\n\t\tcheckValue: z.object({\n\t\t\t/* schema */\n\t\t}), // Validation with Zod\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days\n\t})\n}\n```\n\n### Cache Keys\n\n**Naming conventions:**\n\n- Use format: `entity:identifier:data`\n- Examples:\n  - `user:${userId}:profile`\n  - `note:${noteId}:full`\n  - `api:github:events`\n  - `tito:scheduled-events`\n\n**Avoid:**\n\n- Keys that are too long\n- Keys with special characters\n- Keys that don't clearly identify the content\n\n### TTL (Time To Live)\n\n**Define TTL:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours in milliseconds\n})\n```\n\n**Null TTL to never expire:**\n\n```typescript\nttl: null, // Never expires (not recommended unless necessary)\n```\n\n### Stale-While-Revalidate (SWR)\n\nSWR allows returning stale data while fresh data is fetched in the background.\n\n**Example:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours - after this it's considered stale\n\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days - up to here returns stale while revalidating\n})\n```\n\n**Behavior:**\n\n- **Less than 24h**: Returns cache, no request made\n- **24h - 30 days**: Returns stale cache immediately, updates in background\n- **More than 30 days**: Waits for fresh data before returning\n\n### Validation with Zod\n\nAlways validate cached data with Zod:\n\n```typescript\nimport { z } from 'zod'\n\nconst EventSchema = z.object({\n\tid: z.string(),\n\ttitle: z.string(),\n\tdate: z.string(),\n})\n\nexport async function getEvents({ timings }: { timings?: Timings } = {}) {\n\treturn await cachified({\n\t\tkey: 'events:all',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch('https://api.example.com/events')\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: EventSchema.array(), // Validates it's an array of events\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\nIf cached data doesn't pass validation, fresh data is fetched.\n\n### Server Timing Integration\n\nIntegrate cache with server timing for monitoring:\n\n```typescript\nimport { type Timings } from '#app/utils/timing.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\tconst events = await getEvents({ timings })\n\n\t// Timings are automatically added to headers\n\treturn json(\n\t\t{ events },\n\t\t{\n\t\t\theaders: combineServerTimings(timings),\n\t\t},\n\t)\n}\n```\n\n### Cache Invalidation\n\n**Invalidate by key:**\n\n```typescript\nimport { cache } from '#app/utils/cache.server.ts'\n\nawait cache.delete('user:123:profile')\n```\n\n**Invalidate multiple keys:**\n\n```typescript\n// Search and delete matching keys\nimport { searchCacheKeys } from '#app/utils/cache.server.ts'\n\nconst keys = await searchCacheKeys('user:123', 100)\nawait Promise.all(keys.map((key) => cache.delete(key)))\n```\n\n**Invalidate entire SQLite cache:**\n\n```typescript\n// Use admin dashboard or\nawait cache.clear() // If available\n```\n\n### Using LRU Cache\n\nFor temporary data, use LRU cache directly:\n\n```typescript\nimport { lru } from '#app/utils/cache.server.ts'\n\n// LRU cache is useful for:\n// - Request deduplication\n// - Very temporary cache (< 5 minutes)\n// - Data that doesn't need to persist\n\nconst cachedValue = lru.get('temp-key')\nif (!cachedValue) {\n\tconst freshValue = await computeExpensiveValue()\n\tlru.set('temp-key', freshValue, { ttl: 1000 * 60 * 5 }) // 5 minutes\n\treturn freshValue\n}\nreturn cachedValue\n```\n\n### Multi-Region Cache\n\nWith LiteFS, SQLite cache is automatically replicated:\n\n**Behavior:**\n\n- Only the primary instance writes to cache\n- Replicas can read from cache\n- Writes are automatically synchronized\n\n**Best practices:**\n\n- Don't assume all writes are immediate\n- Use `ensurePrimary()` if you need to guarantee writes\n\n```typescript\nimport { ensurePrimary } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tawait ensurePrimary() // Ensure we're on primary instance\n\n\t// Invalidate cache\n\tawait cache.delete('my-key')\n\n\t// ...\n}\n```\n\n### Error Handling\n\n**Handle errors in getFreshValue:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: async () => {\n\t\ttry {\n\t\t\treturn await fetchData()\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to fetch fresh data:', error)\n\t\t\tthrow error // Re-throw so cachified handles it\n\t\t}\n\t},\n\t// If getFreshValue fails and there's stale cache, it returns it\n\tfallbackToCache: true, // Default: true\n})\n```\n\n### Cache Admin Dashboard\n\nEpic Stack includes a dashboard to manage cache:\n\n**Route:** `/admin/cache`\n\n**Features:**\n\n- View all cache keys\n- Search keys\n- View details of a key\n- Delete keys\n- Clear entire cache\n\n## Common examples\n\n### Example 1: Cache external API response\n\n```typescript\n// app/utils/api.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\nimport { z } from 'zod'\n\nconst GitHubEventSchema = z.object({\n\tid: z.string(),\n\ttype: z.string(),\n\tactor: z.object({\n\t\tlogin: z.string(),\n\t}),\n\tcreated_at: z.string(),\n})\n\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`GitHub API error: ${response.statusText}`)\n\t\t\t}\n\t\t\tconst data = await response.json()\n\t\t\treturn data\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\n### Example 2: Cache Prisma query\n\n```typescript\n// app/utils/user.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { z } from 'zod'\n\nconst UserStatsSchema = z.object({\n\ttotalNotes: z.number(),\n\ttotalLikes: z.number(),\n\tjoinDate: z.string(),\n})\n\nexport async function getUserStats({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `user:${userId}:stats`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst [totalNotes, totalLikes, user] = await Promise.all([\n\t\t\t\tprisma.note.count({ where: { ownerId: userId } }),\n\t\t\t\tprisma.like.count({ where: { userId } }),\n\t\t\t\tprisma.user.findUnique({\n\t\t\t\t\twhere: { id: userId },\n\t\t\t\t\tselect: { createdAt: true },\n\t\t\t\t}),\n\t\t\t])\n\n\t\t\treturn {\n\t\t\t\ttotalNotes,\n\t\t\t\ttotalLikes,\n\t\t\t\tjoinDate: user?.createdAt.toISOString() ?? '',\n\t\t\t}\n\t\t},\n\t\tcheckValue: UserStatsSchema,\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t\tstaleWhileRevalidate: 1000 * 60 * 60, // 1 hour\n\t})\n}\n```\n\n### Example 3: Invalidate cache after mutation\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// ... validate and create note\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle,\n\t\t\tcontent,\n\t\t\townerId: userId,\n\t\t},\n\t\tinclude: { owner: true },\n\t})\n\n\t// Invalidate related cache\n\tawait Promise.all([\n\t\tcache.delete(`user:${userId}:notes`),\n\t\tcache.delete(`user:${userId}:stats`),\n\t\tcache.delete(`note:${note.id}:full`),\n\t])\n\n\treturn redirect(`/users/${note.owner.username}/notes/${note.id}`)\n}\n```\n\n### Example 4: Cache with dependencies\n\n```typescript\nexport async function getUserWithNotes({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\tconst user = await cachified({\n\t\tkey: `user:${userId}:profile`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\tusername: true,\n\t\t\t\t\tname: true,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t\tcheckValue: z\n\t\t\t.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tusername: z.string(),\n\t\t\t\tname: z.string().nullable(),\n\t\t\t})\n\t\t\t.nullable(),\n\t\tttl: 1000 * 60 * 30, // 30 minutes\n\t})\n\n\tconst notes = await cachified({\n\t\tkey: `user:${userId}:notes`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.note.findMany({\n\t\t\t\twhere: { ownerId: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\ttitle: true,\n\t\t\t\t\tupdatedAt: true,\n\t\t\t\t},\n\t\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\t})\n\t\t},\n\t\tcheckValue: z.array(\n\t\t\tz.object({\n\t\t\t\tid: z.string(),\n\t\t\t\ttitle: z.string(),\n\t\t\t\tupdatedAt: z.date(),\n\t\t\t}),\n\t\t),\n\t\tttl: 1000 * 60 * 10, // 10 minutes\n\t})\n\n\treturn { user, notes }\n}\n```\n\n### Example 5: Use LRU for deduplication\n\n```typescript\n// Avoid multiple simultaneous requests to the same URL\nconst requestCache = new Map<string, Promise<any>>()\n\nexport async function fetchWithDedup(url: string) {\n\tif (requestCache.has(url)) {\n\t\treturn requestCache.get(url)\n\t}\n\n\tconst promise = fetch(url).then((res) => res.json())\n\trequestCache.set(url, promise)\n\n\t// Clean up after 1 second\n\tsetTimeout(() => {\n\t\trequestCache.delete(url)\n\t}, 1000)\n\n\treturn promise\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Caching without measuring benefit**: Only add cache when there's a clear,\n  measurable performance problem\n- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to\n  fetch - it adds complexity without benefit\n- ❌ **Caching frequently changing data**: Cache invalidation becomes more\n  complex than the problem it solves\n- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive\n  personal data\n- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely\n  necessary\n- ❌ **Not validating cached data**: Always use `checkValue` with Zod to\n  validate data\n- ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations\n- ❌ **Assuming cache always works**: Cache can fail, always handle errors\n- ❌ **Keys too long or ambiguous**: Use consistent and descriptive format\n- ❌ **Not using timings**: Integrate with server timing for monitoring\n- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when\n  appropriate\n- ❌ **Over-caching**: Too much caching makes the system harder to understand\n  and debug\n\n## References\n\n- [Epic Stack Caching Docs](../epic-stack/docs/caching.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [@epic-web/cachified](https://www.npmjs.com/package/@epic-web/cachified)\n- `app/utils/cache.server.ts` - Cache implementation\n- `app/routes/admin/cache/` - Admin dashboard\n- `app/utils/timing.server.ts` - Server timing utilities\n","docs/skills/epic-database/SKILL.md":"---\nname: epic-database\ndescription: Guide on Prisma, SQLite, and LiteFS for Epic Stack\ncategories:\n  - database\n  - prisma\n  - sqlite\n  - litefs\n---\n\n# Epic Stack: Database\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Design database schema with Prisma\n- Create migrations\n- Work with SQLite and LiteFS\n- Optimize queries and performance\n- Create seed scripts\n- Work with multi-region deployments\n- Manage backups and restores\n\n## Patterns and conventions\n\n### Database Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Only fetch the data you actually need. Use\n`select` to fetch specific fields instead of entire models. Avoid over-fetching\ndata \"just in case\" - fetch what you need, when you need it.\n\n**Pragmatism over purity** - Optimize queries when there's a measurable benefit,\nbut don't over-optimize prematurely. Simple, readable queries are often better\nthan complex optimized ones. Add indexes when queries are slow, not before.\n\n**Example - Fetch only what you need:**\n\n```typescript\n// ✅ Good - Fetch only needed fields\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only fetch what you actually use\n\t},\n})\n\n// ❌ Avoid - Fetching everything\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches all fields including password hash, email, etc.\n})\n```\n\n**Example - Pragmatic optimization:**\n\n```typescript\n// ✅ Good - Simple query first, optimize if needed\nconst notes = await prisma.note.findMany({\n\twhere: { ownerId: userId },\n\tselect: { id: true, title: true, updatedAt: true },\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n})\n\n// Only add indexes if this query is actually slow\n// Don't pre-optimize\n\n// ❌ Avoid - Over-optimizing before measuring\n// Adding complex indexes, joins, etc. before knowing if it's needed\n```\n\n### Prisma Schema\n\nEpic Stack uses Prisma with SQLite as the database.\n\n**Basic configuration:**\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"typedSql\"]\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n```\n\n**Basic model:**\n\n```prisma\nmodel User {\n  id        String   @id @default(cuid())\n  email     String   @unique\n  username  String   @unique\n  name      String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  notes     Note[]\n  roles     Role[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  title   String\n  content String\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n  @@index([ownerId, updatedAt])\n}\n```\n\n### CUID2 for IDs\n\nEpic Stack uses CUID2 to generate unique IDs.\n\n**Advantages:**\n\n- Globally unique\n- Sortable\n- Secure (no exposed information)\n- URL-friendly\n\n**Example:**\n\n```prisma\nmodel User {\n  id String @id @default(cuid()) // Automatically generates CUID2\n}\n```\n\n### Timestamps\n\n**Standard fields:**\n\n```prisma\nmodel User {\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt // Automatically updated\n}\n```\n\n### Relationships\n\n**One-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n}\n```\n\n**One-to-One:**\n\n```prisma\nmodel User {\n  id      String  @id @default(cuid())\n  image   UserImage?\n}\n\nmodel UserImage {\n  id        String @id @default(cuid())\n  user      User   @relation(fields: [userId], references: [id])\n  userId    String @unique\n}\n```\n\n**Many-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  roles Role[]\n}\n\nmodel Role {\n  id    String @id @default(cuid())\n  users User[]\n}\n```\n\n### Indexes\n\n**Create indexes:**\n\n```prisma\nmodel Note {\n  id      String @id @default(cuid())\n  ownerId String\n  updatedAt DateTime\n\n  @@index([ownerId])              // Simple index\n  @@index([ownerId, updatedAt])   // Composite index\n}\n```\n\n**Best practices:**\n\n- Index foreign keys\n- Index fields used in `where` frequently\n- Index fields used in `orderBy`\n- Use composite indexes for complex queries\n\n### Cascade Delete\n\n**Configure cascade:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id], onDelete: Cascade)\n  ownerId String\n}\n```\n\n**Options:**\n\n- `onDelete: Cascade` - Deletes children when parent is deleted\n- `onDelete: SetNull` - Sets to null when parent is deleted\n- `onDelete: Restrict` - Prevents deletion if there are children\n\n### Migrations\n\n**Create migration:**\n\n```bash\nnpx prisma migrate dev --name add_user_field\n```\n\n**Apply migrations in production:**\n\n```bash\nnpx prisma migrate deploy\n```\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`.\n\n**\"Widen then Narrow\" strategy for zero-downtime:**\n\n1. **Widen app** - App accepts A or B\n2. **Widen db** - DB provides A and B, app writes to both\n3. **Narrow app** - App only uses B\n4. **Narrow db** - DB only provides B\n\n**Example: Rename field `name` to `firstName` and `lastName`:**\n\n```prisma\n// Step 1: Widen app (accepts both)\nmodel User {\n  id        String @id @default(cuid())\n  name      String?  // Deprecated\n  firstName String?  // New\n  lastName  String?  // New\n}\n\n// Step 2: Widen db (migration copies data)\n// In SQL migration:\nALTER TABLE User ADD COLUMN firstName TEXT;\nALTER TABLE User ADD COLUMN lastName TEXT;\nUPDATE User SET firstName = name;\n\n// Step 3: Narrow app (only uses new fields)\n// Code only uses firstName and lastName\n\n// Step 4: Narrow db (removes old field)\nALTER TABLE User DROP COLUMN name;\n```\n\n### Prisma Client\n\n**Import Prisma Client:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n```\n\n**Basic query:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n})\n```\n\n**Specific select:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\temail: true,\n\t\tusername: true,\n\t\t// Don't include password or sensitive data\n\t},\n})\n```\n\n**Include relations:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tinclude: {\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t},\n\t\troles: true,\n\t},\n})\n```\n\n**Complex queries:**\n\n```typescript\nconst notes = await prisma.note.findMany({\n\twhere: {\n\t\townerId: userId,\n\t\ttitle: { contains: searchTerm },\n\t},\n\tselect: {\n\t\tid: true,\n\t\ttitle: true,\n\t\tupdatedAt: true,\n\t},\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n\tskip: (page - 1) * 20,\n})\n```\n\n### Transactions\n\n**Use transactions:**\n\n```typescript\nawait prisma.$transaction(async (tx) => {\n\tconst user = await tx.user.create({\n\t\tdata: {\n\t\t\temail,\n\t\t\tusername,\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tawait tx.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Welcome',\n\t\t\tcontent: 'Welcome to the app!',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\treturn user\n})\n```\n\n### SQLite con LiteFS\n\n**Multi-region with LiteFS:**\n\n- Only the primary instance can write\n- Replicas can only read\n- Writes are automatically replicated\n\n**Check primary instance:**\n\n```typescript\nimport { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Ensure we're on primary instance for writes\n\tawait ensurePrimary()\n\n\t// Now we can write safely\n\tawait prisma.user.create({\n\t\tdata: {\n\t\t\t/* ... */\n\t\t},\n\t})\n}\n```\n\n**Get instance information:**\n\n```typescript\nimport { getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nconst { currentIsPrimary, primaryInstance } = await getInstanceInfo()\n\nif (currentIsPrimary) {\n\t// Can write\n} else {\n\t// Read-only, redirect to primary if necessary\n}\n```\n\n### Seed Scripts\n\n**Create seed:**\n\n```typescript\n// prisma/seed.ts\nimport { prisma } from '#app/utils/db.server.ts'\n\nasync function seed() {\n\t// Create roles\n\tawait prisma.role.createMany({\n\t\tdata: [\n\t\t\t{ name: 'user', description: 'Standard user' },\n\t\t\t{ name: 'admin', description: 'Administrator' },\n\t\t],\n\t})\n\n\t// Create users\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n\nseed()\n\t.catch((e) => {\n\t\tconsole.error(e)\n\t\tprocess.exit(1)\n\t})\n\t.finally(async () => {\n\t\tawait prisma.$disconnect()\n\t})\n```\n\n**Run seed:**\n\n```bash\nnpx prisma db seed\n# Or directly:\nnpx tsx prisma/seed.ts\n```\n\n### Query Optimization\n\n**Guidelines (pragmatic approach):**\n\n- Use `select` to fetch only needed fields - do as little as possible\n- Use selective `include` - only include relations you actually use\n- Index fields used in `where` and `orderBy` - but only if queries are slow\n- Use composite indexes for complex queries - when you have a real performance\n  problem\n- Avoid `select: true` (fetches everything) - be explicit about what you need\n- Measure first, optimize second - don't pre-optimize\n\n**Optimized example (do as little as possible):**\n\n```typescript\n// ❌ Avoid: Fetches everything unnecessarily\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches password hash, email, all relations, etc.\n})\n\n// ✅ Good: Only needed fields - do as little as possible\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only what you actually use\n\t},\n})\n\n// ✅ Better: With selective relations (only if you need them)\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\ttake: 10, // Only fetch what you need\n\t\t},\n\t},\n})\n```\n\n### Prisma Query Logging\n\n**Configure logging:**\n\n```typescript\n// app/utils/db.server.ts\nconst client = new PrismaClient({\n\tlog: [\n\t\t{ level: 'query', emit: 'event' },\n\t\t{ level: 'error', emit: 'stdout' },\n\t\t{ level: 'warn', emit: 'stdout' },\n\t],\n})\n\nclient.$on('query', async (e) => {\n\tif (e.duration < 20) return // Only log slow queries\n\n\tconsole.info(`prisma:query - ${e.duration}ms - ${e.query}`)\n})\n```\n\n### Database URL\n\n**Development:**\n\n```bash\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**Production (Fly.io):**\n\n```bash\nDATABASE_URL=file:/litefs/data/sqlite.db\n```\n\n### Connecting to DB in Production\n\n**SSH to Fly instance:**\n\n```bash\nfly ssh console --app [YOUR_APP_NAME]\n```\n\n**Connect to DB CLI:**\n\n```bash\nfly ssh console -C database-cli --app [YOUR_APP_NAME]\n```\n\n**Prisma Studio:**\n\n```bash\n# Terminal 1: Start Prisma Studio\nfly ssh console -C \"npx prisma studio\" -s --app [YOUR_APP_NAME]\n\n# Terminal 2: Local proxy\nfly proxy 5556:5555 --app [YOUR_APP_NAME]\n\n# Open in browser\n# http://localhost:5556\n```\n\n## Common examples\n\n### Example 1: Create model with relations\n\n```prisma\nmodel Post {\n  id        String   @id @default(cuid())\n  title     String\n  content   String\n  published Boolean  @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)\n  authorId String\n\n  comments Comment[]\n  tags     Tag[]\n\n  @@index([authorId])\n  @@index([authorId, published])\n  @@index([published, updatedAt])\n}\n\nmodel Comment {\n  id      String @id @default(cuid())\n  content String\n\n  createdAt DateTime @default(now())\n\n  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)\n  postId String\n\n  author   User   @relation(fields: [authorId], references: [id])\n  authorId String\n\n  @@index([postId])\n  @@index([authorId])\n}\n```\n\n### Example 2: Complex query with pagination\n\n```typescript\nexport async function getPosts({\n\tuserId,\n\tpage = 1,\n\tperPage = 20,\n\tpublished,\n}: {\n\tuserId?: string\n\tpage?: number\n\tperPage?: number\n\tpublished?: boolean\n}) {\n\tconst where: Prisma.PostWhereInput = {}\n\n\tif (userId) {\n\t\twhere.authorId = userId\n\t}\n\tif (published !== undefined) {\n\t\twhere.published = published\n\t}\n\n\tconst [posts, total] = await Promise.all([\n\t\tprisma.post.findMany({\n\t\t\twhere,\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t\tupdatedAt: true,\n\t\t\t\tauthor: {\n\t\t\t\t\tselect: {\n\t\t\t\t\t\tid: true,\n\t\t\t\t\t\tusername: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\ttake: perPage,\n\t\t\tskip: (page - 1) * perPage,\n\t\t}),\n\t\tprisma.post.count({ where }),\n\t])\n\n\treturn {\n\t\tposts,\n\t\ttotal,\n\t\tpages: Math.ceil(total / perPage),\n\t}\n}\n```\n\n### Example 3: Transaction with multiple operations\n\n```typescript\nexport async function createPostWithTags({\n\tauthorId,\n\ttitle,\n\tcontent,\n\ttagNames,\n}: {\n\tauthorId: string\n\ttitle: string\n\tcontent: string\n\ttagNames: string[]\n}) {\n\treturn await prisma.$transaction(async (tx) => {\n\t\t// Create tags if they don't exist\n\t\tawait Promise.all(\n\t\t\ttagNames.map((name) =>\n\t\t\t\ttx.tag.upsert({\n\t\t\t\t\twhere: { name },\n\t\t\t\t\tupdate: {},\n\t\t\t\t\tcreate: { name },\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\t// Create post\n\t\tconst post = await tx.post.create({\n\t\t\tdata: {\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\tauthorId,\n\t\t\t\ttags: {\n\t\t\t\t\tconnect: tagNames.map((name) => ({ name })),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\n\t\treturn post\n\t})\n}\n```\n\n### Example 4: Seed with related data\n\n```typescript\nasync function seed() {\n\t// Create permissions\n\tconst permissions = await Promise.all([\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'create',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can create own notes',\n\t\t\t},\n\t\t}),\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'read',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can read own notes',\n\t\t\t},\n\t\t}),\n\t])\n\n\t// Create roles with permissions\n\tconst userRole = await prisma.role.create({\n\t\tdata: {\n\t\t\tname: 'user',\n\t\t\tdescription: 'Standard user',\n\t\t\tpermissions: {\n\t\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t\t},\n\t\t},\n\t})\n\n\t// Create user with role\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: {\n\t\t\t\tconnect: { id: userRole.id },\n\t\t\t},\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need -\n  do as little as possible\n- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add\n  indexes \"just in case\"\n- ❌ **Not using indexes when needed**: Index foreign keys and fields used in\n  frequent queries, but only if they're actually slow\n- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when\n  you need them\n- ❌ **Not using transactions for related operations**: Always use transactions\n  when multiple operations must be atomic\n- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in\n  production\n- ❌ **Breaking migrations without strategy**: Use \"widen then narrow\" for\n  zero-downtime\n- ❌ **Not validating data before inserting**: Always validate with Zod before\n  create/update\n- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when\n  parent is deleted\n- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or\n  others\n- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but\n  ensure in scripts\n- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries\n  over complex optimized ones unless there's a real problem\n\n## References\n\n- [Epic Stack Database Docs](../epic-stack/docs/database.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Prisma Documentation](https://www.prisma.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [SQLite Documentation](https://www.sqlite.org/docs.html)\n- `prisma/schema.prisma` - Complete schema\n- `prisma/seed.ts` - Seed example\n- `app/utils/db.server.ts` - Prisma Client setup\n- `app/utils/litefs.server.ts` - LiteFS utilities\n","docs/skills/epic-deployment/SKILL.md":"---\nname: epic-deployment\ndescription:\n  Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack\ncategories:\n  - deployment\n  - fly-io\n  - ci-cd\n  - docker\n---\n\n# Epic Stack: Deployment\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure deployment on Fly.io\n- Setup multi-region deployment\n- Configure CI/CD with GitHub Actions\n- Manage secrets in production\n- Configure healthchecks\n- Work with LiteFS and volumes\n- Local deployment with Docker\n\n## Patterns and conventions\n\n### Fly.io Configuration\n\nEpic Stack uses Fly.io for hosting with configuration in `fly.toml`.\n\n**Basic configuration:**\n\n```toml\n# fly.toml\napp = \"your-app-name\"\nprimary_region = \"sjc\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\n\n[build]\ndockerfile = \"/other/Dockerfile\"\nignorefile = \"/other/Dockerfile.dockerignore\"\n\n[mounts]\nsource = \"data\"\ndestination = \"/data\"\n```\n\n### Primary Region\n\n**Configure primary region:**\n\n```toml\nprimary_region = \"sjc\" # Change according to your location\n```\n\n**Important:** The primary region must be the same for:\n\n- `primary_region` en `fly.toml`\n- Region del volume `data`\n- `PRIMARY_REGION` en variables de entorno\n\n### LiteFS Configuration\n\n**Configuration in `other/litefs.yml`:**\n\n```yaml\nfuse:\n  dir: '${LITEFS_DIR}'\n\ndata:\n  dir: '/data/litefs'\n\nproxy:\n  addr: ':${INTERNAL_PORT}'\n  target: 'localhost:${PORT}'\n  db: '${DATABASE_FILENAME}'\n\nlease:\n  type: 'consul'\n  candidate: ${FLY_REGION == PRIMARY_REGION}\n  promote: true\n  advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'\n  consul:\n    url: '${FLY_CONSUL_URL}'\n    key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}'\n\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n  - cmd: sqlite3 $DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: sqlite3 $CACHE_DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: npx prisma generate --sql\n  - cmd: npm start\n```\n\n### Healthchecks\n\n**Configuration in `fly.toml`:**\n\n```toml\n[[services.http_checks]]\ninterval = \"10s\"\ngrace_period = \"5s\"\nmethod = \"get\"\npath = \"/resources/healthcheck\"\nprotocol = \"http\"\ntimeout = \"2s\"\ntls_skip_verify = false\n```\n\n**Healthcheck implementation:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Verify DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\tconsole.log('healthcheck ❌', { error })\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Environment Variables\n\n**Secrets in Fly.io:**\n\n```bash\n# Generate secrets\nfly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\nfly secrets set HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\n\n# List secrets\nfly secrets list --app [YOUR_APP_NAME]\n\n# Delete secret\nfly secrets unset SECRET_NAME --app [YOUR_APP_NAME]\n```\n\n**Common secrets:**\n\n- `SESSION_SECRET` - Secret for signing session cookies\n- `HONEYPOT_SECRET` - Secret for honeypot fields\n- `DATABASE_URL` - Automatically configured by LiteFS\n- `CACHE_DATABASE_PATH` - Automatically configured\n- `RESEND_API_KEY` - For sending emails (optional)\n- `TIGRIS_*` - For image storage (automatic)\n- `SENTRY_DSN` - For error monitoring (optional)\n\n### Volumes\n\n**Create volume:**\n\n```bash\nfly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]\n```\n\n**List volumes:**\n\n```bash\nfly volumes list --app [YOUR_APP_NAME]\n```\n\n**Expand volume:**\n\n```bash\nfly volumes extend <volume-id> --size 10 --app [YOUR_APP_NAME]\n```\n\n### Multi-Region Deployment\n\n**Deploy to multiple regions:**\n\n```bash\n# Deploy in primary region (more instances)\nfly scale count 2 --region sjc --app [YOUR_APP_NAME]\n\n# Deploy in secondary regions (read-only)\nfly scale count 1 --region ams --app [YOUR_APP_NAME]\nfly scale count 1 --region syd --app [YOUR_APP_NAME]\n```\n\n**Verify instances:**\n\n```bash\nfly status --app [YOUR_APP_NAME]\n# The ROLE column will show \"primary\" or \"replica\"\n```\n\n### Consul Setup\n\n**Attach Consul:**\n\n```bash\nfly consul attach --app [YOUR_APP_NAME]\n```\n\n**Consul manages:**\n\n- Which instance is primary\n- Automatic failover\n- Data replication\n\n### GitHub Actions CI/CD\n\n**Basic workflow:**\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n**Complete configuration:**\n\n- Deploy to `production` from `main` branch\n- Deploy to `staging` from `dev` branch\n- Tests before deploy (optional)\n\n### Deployable Commits\n\nFollowing Epic Web principles:\n\n**Deployable commits** - Every commit to the main branch should be deployable.\nThis means:\n\n- The code should be in a working state\n- Tests should pass\n- The application should build successfully\n- No \"WIP\" or \"TODO\" commits that break the build\n\n**Example - Deployable commit workflow:**\n\n```bash\n# ✅ Good - Each commit is deployable\ngit commit -m \"Add user profile page\"\n# This commit is complete, tested, and deployable\n\ngit commit -m \"Fix login redirect bug\"\n# This commit fixes a bug and is deployable\n\n# ❌ Avoid - Non-deployable commits\ngit commit -m \"WIP: working on feature\"\n# This commit might not work, not deployable\n\ngit commit -m \"Add feature (tests failing)\"\n# This commit breaks the build, not deployable\n```\n\n**Benefits:**\n\n- Easy rollback - any commit can be deployed\n- Continuous deployment - deploy any time\n- Clear history - each commit represents a working state\n- Faster recovery - can deploy any previous commit\n\n### Small and Short Lived Merge Requests\n\nFollowing Epic Web principles:\n\n**Small and short lived merge requests** - Keep PRs small and merge them\nquickly. Large PRs are hard to review, risky to merge, and slow down the team.\n\n**Guidelines:**\n\n- **Small PRs** - Focus on one feature or fix per PR\n- **Short-lived** - Merge within a day or two, not weeks\n- **Reviewable** - PRs should be reviewable in 30 minutes or less\n- **Independent** - Each PR should be independently deployable\n\n**Example - Small, focused PR:**\n\n```bash\n# ✅ Good - Small, focused PR\n# PR: \"Add email validation to signup form\"\n# - Only changes signup validation\n# - Includes tests\n# - Can be reviewed quickly\n# - Can be merged and deployed independently\n\n# ❌ Avoid - Large, complex PR\n# PR: \"Refactor authentication system and add 2FA and OAuth\"\n# - Too many changes at once\n# - Hard to review\n# - Risky to merge\n# - Takes days to review\n```\n\n**Benefits:**\n\n- Faster reviews - easier to understand and review\n- Lower risk - smaller changes are less risky\n- Faster feedback - get feedback sooner\n- Easier rollback - smaller changes are easier to revert\n- Better collaboration - team can work in parallel on different small PRs\n\n**When PRs get too large:**\n\n- Split into multiple smaller PRs\n- Use feature flags to merge incrementally\n- Break down into logical pieces\n\n### Tigris Object Storage\n\n**Create storage:**\n\n```bash\nfly storage create --app [YOUR_APP_NAME]\n```\n\n**This creates:**\n\n- Tigris bucket\n- Automatic environment variables:\n  - `TIGRIS_ENDPOINT`\n  - `TIGRIS_ACCESS_KEY_ID`\n  - `TIGRIS_SECRET_ACCESS_KEY`\n  - `TIGRIS_BUCKET_NAME`\n\n### Database Migrations\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`:\n\n```yaml\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n```\n\n**Note:** Only the primary instance runs migrations (`if-candidate: true`).\n\n### Database Backups\n\n**Create backup:**\n\n```bash\n# SSH to instance\nfly ssh console --app [YOUR_APP_NAME]\n\n# Create backup\nmkdir /backups\nlitefs export -name sqlite.db /backups/backup-$(date +%Y-%m-%d).db\nexit\n\n# Download backup\nfly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME]\n```\n\n**Restore backup:**\n\n```bash\n# Upload backup\nfly ssh sftp shell --app [YOUR_APP_NAME]\nput backup-2024-01-01.db\n# Ctrl+C to exit\n\n# SSH and restore\nfly ssh console --app [YOUR_APP_NAME]\nlitefs import -name sqlite.db /backup-2024-01-01.db\nexit\n```\n\n### Deployment Local\n\n**Deploy con Fly CLI:**\n\n```bash\nfly deploy\n```\n\n**Deploy con Docker:**\n\n```bash\n# Build\ndocker build -t epic-stack . -f other/Dockerfile \\\n  --build-arg COMMIT_SHA=$(git rev-parse --short HEAD)\n\n# Run\ndocker run -d \\\n  -p 8081:8081 \\\n  -e SESSION_SECRET='secret' \\\n  -e HONEYPOT_SECRET='secret' \\\n  -e FLY='false' \\\n  -v ~/litefs:/litefs \\\n  epic-stack\n```\n\n### Zero-Downtime Deploys\n\n**Strategy:**\n\n- Deploy to multiple instances\n- Automatic blue-green deployment\n- Healthchecks verify app is ready\n- Auto-rollback if healthcheck fails\n\n**Configuration:**\n\n```toml\n[experimental]\nauto_rollback = true\n```\n\n### Monitoring\n\n**View logs:**\n\n```bash\nfly logs --app [YOUR_APP_NAME]\n```\n\n**View metrics:**\n\n```bash\nfly dashboard --app [YOUR_APP_NAME]\n# Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring\n```\n\n**Sentry (opcional):**\n\n```bash\nfly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME]\n```\n\n## Common examples\n\n### Example 1: Complete initial setup\n\n```bash\n# 1. Create apps\nfly apps create my-app\nfly apps create my-app-staging\n\n# 2. Configure secrets\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  --app my-app\n\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  ALLOW_INDEXING=false \\\n  --app my-app-staging\n\n# 3. Create volumes\nfly volumes create data --region sjc --size 1 --app my-app\nfly volumes create data --region sjc --size 1 --app my-app-staging\n\n# 4. Attach Consul\nfly consul attach --app my-app\nfly consul attach --app my-app-staging\n\n# 5. Create storage\nfly storage create --app my-app\nfly storage create --app my-app-staging\n\n# 6. Deploy\nfly deploy --app my-app\n```\n\n### Example 2: Multi-region setup\n\n```bash\n# First region (primary) - 2 instances\nfly scale count 2 --region sjc --app my-app\n\n# Secondary regions - 1 instance each\nfly scale count 1 --region ams --app my-app\nfly scale count 1 --region syd --app my-app\n\n# Verify\nfly status --app my-app\n```\n\n### Example 3: GitHub Actions workflow\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy-production:\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\n  deploy-staging:\n    if: github.ref == 'refs/heads/dev'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app-staging\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Example 4: Deploy with migrations\n\n```bash\n# Create migration\nnpx prisma migrate dev --name add_field\n\n# Commit and push\ngit add .\ngit commit -m \"Add field\"\ngit push origin main\n\n# GitHub Actions automatically runs:\n# 1. Build\n# 2. Deploy\n# 3. litefs.yml runs: npx prisma migrate deploy (only on primary)\n```\n\n## Common mistakes to avoid\n\n- ❌ **Non-deployable commits**: Every commit to main should be deployable - no\n  WIP or broken commits\n- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are\n  hard to review and risky\n- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml`\n  matches the volume region\n- ❌ **Secrets not configured**: Configure all secrets before first deploy\n- ❌ **Volume not created**: Create the `data` volume before deploy\n- ❌ **Consul not attached**: Attach Consul before first deploy\n- ❌ **Migrations on replicas**: Only the primary instance should run migrations\n- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime\n  deploys\n- ❌ **Deploy breaking changes without strategy**: Use \"widen then narrow\" for\n  migrations\n- ❌ **Secrets in code**: Never commit secrets, use `fly secrets`\n- ❌ **Not making backups**: Make regular database backups\n- ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets\n\n## References\n\n- [Epic Stack Deployment Docs](../epic-stack/docs/deployment.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Fly.io Documentation](https://fly.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [Fly.io CLI Reference](https://fly.io/docs/flyctl/)\n- `fly.toml` - Fly.io configuration\n- `other/litefs.yml` - LiteFS configuration\n- `other/Dockerfile` - Deployment Dockerfile\n- `.github/workflows/deploy.yml` - CI/CD workflow\n\n### Preview Deployments (Inspired by Vercel Deploy Claimable)\n\nEpic Stack can implement preview deployments similar to Vercel's deploy\nclaimable pattern.\n\n**✅ Good - Preview deployments for pull requests:**\n\n```yaml\n# .github/workflows/preview-deploy.yml\nname: Preview Deploy\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  preview:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Deploy preview\n        run: |\n          # Create or reuse preview app\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps list | grep \"$PREVIEW_APP\" || flyctl apps create \"$PREVIEW_APP\"\n\n          # Deploy to preview app\n          flyctl deploy --app \"$PREVIEW_APP\" --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n      - name: Comment preview URL\n        uses: actions/github-script@v7\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: context.issue.number,\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `🚀 Preview deployment: https://$PREVIEW_APP.fly.dev`\n            })\n```\n\n**✅ Good - Auto-cleanup preview deployments:**\n\n```yaml\n# .github/workflows/cleanup-preview.yml\nname: Cleanup Preview\n\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Destroy preview app\n        run: |\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps destroy \"$PREVIEW_APP\" --yes\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Environment Detection\n\n**✅ Good - Detect deployment environment:**\n\n```typescript\n// app/utils/env.server.ts\nexport function getDeploymentEnv():\n\t| 'production'\n\t| 'staging'\n\t| 'preview'\n\t| 'development' {\n\tif (process.env.NODE_ENV === 'development') {\n\t\treturn 'development'\n\t}\n\n\t// Preview deployments\n\tif (process.env.FLY_APP_NAME?.includes('pr-')) {\n\t\treturn 'preview'\n\t}\n\n\t// Staging environment\n\tif (process.env.FLY_APP_NAME?.includes('staging')) {\n\t\treturn 'staging'\n\t}\n\n\t// Production\n\treturn 'production'\n}\n```\n\n**✅ Good - Environment-specific configuration:**\n\n```typescript\nconst env = getDeploymentEnv()\n\nexport const config = {\n\tproduction: env === 'production',\n\tstaging: env === 'staging',\n\tpreview: env === 'preview',\n\tdevelopment: env === 'development',\n\n\t// Preview deployments might have limited features\n\tfeatures: {\n\t\tanalytics: env === 'production',\n\t\tsentry: env !== 'development',\n\t\tindexing: env === 'production',\n\t},\n}\n```\n\n### Build Artifact Exclusion\n\n**✅ Good - Optimize Docker builds:**\n\n```dockerfile\n# other/Dockerfile\n# Multi-stage build for smaller image size\nFROM node:20-alpine AS base\nWORKDIR /app\n\n# Install dependencies\nFROM base AS deps\nCOPY package*.json ./\nRUN npm ci --only=production\n\n# Build application\nFROM base AS builder\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production image\nFROM base AS runner\nENV NODE_ENV=production\n\n# Copy only what's needed\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY --from=builder /app/build ./build\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/other ./other\nCOPY --from=builder /app/prisma ./prisma\nCOPY --from=builder /app/package.json ./\n\n# Exclude unnecessary files\n# node_modules/.cache, .git, etc. are already excluded via .dockerignore\n\nCMD [\"npm\", \"start\"]\n```\n\n**✅ Good - Docker ignore file:**\n\n```dockerignore\n# .dockerignore (in other/)\nnode_modules\n.git\n.env\n.env.*\n!.env.example\n*.log\n.DS_Store\ncoverage\n.vscode\n.idea\n*.swp\n*.swo\n*~\n.cache\ndist\nbuild\n```\n\n### Deployment Status and Monitoring\n\n**✅ Good - Deployment status tracking:**\n\n```typescript\n// app/routes/admin/deployment-status.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst deploymentInfo = {\n\t\tappName: process.env.FLY_APP_NAME,\n\t\tregion: process.env.FLY_REGION,\n\t\tenvironment: getDeploymentEnv(),\n\t\tcommitSha: process.env.COMMIT_SHA,\n\t\tdeployedAt: process.env.DEPLOYED_AT,\n\t}\n\n\treturn { deploymentInfo }\n}\n```\n\n### Rollback Strategies\n\n**✅ Good - Quick rollback with Fly.io:**\n\n```bash\n# List recent releases\nfly releases list --app my-app\n\n# Rollback to previous release\nfly releases rollback --app my-app\n```\n\n**✅ Good - Automated rollback on failure:**\n\n```toml\n# fly.toml\n[experimental]\n  auto_rollback = true\n  min_machines_running = 1\n```\n","docs/skills/epic-forms/SKILL.md":"---\nname: epic-forms\ndescription: Guide on forms with Conform and validation with Zod for Epic Stack\ncategories:\n  - forms\n  - conform\n  - zod\n  - validation\n---\n\n# Epic Stack: Forms\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create forms in an Epic Stack application\n- Implement form validation with Zod\n- Work with Conform for progressively enhanced forms\n- Handle file uploads\n- Implement honeypot fields for spam protection\n- Handle form errors\n- Work with complex forms (fieldsets, arrays)\n\n## Patterns and conventions\n\n### Validation Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Make validation rules clear and explicit\nusing Zod schemas. Every validation rule should be visible in the schema, not\nhidden in business logic. Error messages should be specific and helpful, telling\nusers exactly what went wrong and how to fix it.\n\n**Design to fail fast and early** - Validate input as early as possible, ideally\non the client side before submission, and always on the server side. Return\nclear, specific error messages immediately so users can fix issues without\nfrustration.\n\n**Example - Explicit validation:**\n\n```typescript\n// ✅ Good - Explicit validation with clear error messages\nconst SignupSchema = z.object({\n\temail: z\n\t\t.string({ required_error: 'Email is required' })\n\t\t.email({ message: 'Please enter a valid email address' })\n\t\t.min(3, { message: 'Email must be at least 3 characters' })\n\t\t.max(100, { message: 'Email must be less than 100 characters' })\n\t\t.transform((val) => val.toLowerCase().trim()),\n\tpassword: z\n\t\t.string({ required_error: 'Password is required' })\n\t\t.min(6, { message: 'Password must be at least 6 characters' })\n\t\t.max(72, { message: 'Password must be less than 72 characters' }),\n})\n\n// ❌ Avoid - Implicit validation\nconst SignupSchema = z.object({\n\temail: z.string().email(), // No clear error messages\n\tpassword: z.string().min(6), // Generic error\n})\n```\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate early and return specific errors immediately\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: 400 }, // Clear error status\n\t\t)\n\t}\n\n\t// Only proceed if validation passed\n\tconst { email, password } = submission.value\n\t// ... continue with signup\n}\n\n// ❌ Avoid - Delayed or unclear validation\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst email = formData.get('email')\n\tconst password = formData.get('password')\n\n\t// Validation scattered throughout the function\n\tif (!email) {\n\t\t// Generic error, not specific\n\t\treturn json({ error: 'Invalid' }, { status: 400 })\n\t}\n\t// ... more scattered validation\n}\n```\n\n### Basic setup with Conform\n\nEpic Stack uses [Conform](https://conform.guide/) to handle forms with\nprogressive enhancement.\n\n**Basic setup:**\n\n```typescript\nimport { getFormProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tconstraint: getZodConstraint(SignupSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Integration with Zod\n\nConform integrates seamlessly with Zod for validation.\n\n**Define schema:**\n\n```typescript\nimport { z } from 'zod'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: z.string().email('Invalid email'),\n\t\tpassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\tconfirmPassword: z.string(),\n\t})\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'Passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Validation in action (fail fast):**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - explicit and fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return explicit errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Only proceed if validation passed - submission.value is type-safe\n\tconst { email, password } = submission.value\n\t// ... process with validated data\n}\n```\n\n### Async validation\n\nFor validations that require querying the database:\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true, // Important: enable async validation\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// ...\n}\n```\n\n### Field Components\n\nEpic Stack provides pre-built field components:\n\n**Basic Field:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\n**TextareaField:**\n\n```typescript\nimport { TextareaField } from '#app/components/forms.tsx'\nimport { getTextareaProps } from '@conform-to/react'\n\n<TextareaField\n\tlabelProps={{\n\t\thtmlFor: fields.content.id,\n\t\tchildren: 'Content',\n\t}}\n\ttextareaProps={{\n\t\t...getTextareaProps(fields.content),\n\t\trows: 10,\n\t}}\n\terrors={fields.content.errors}\n/>\n```\n\n**CheckboxField:**\n\n```typescript\nimport { CheckboxField } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<CheckboxField\n\tlabelProps={{\n\t\thtmlFor: fields.remember.id,\n\t\tchildren: 'Remember me',\n\t}}\n\tbuttonProps={getInputProps(fields.remember, { type: 'checkbox' })}\n\terrors={fields.remember.errors}\n/>\n```\n\n**OTPField:**\n\n```typescript\nimport { OTPField } from '#app/components/forms.tsx'\n\n<OTPField\n\tlabelProps={{\n\t\thtmlFor: fields.code.id,\n\t\tchildren: 'Verification Code',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.code),\n\t\tmaxLength: 6,\n\t}}\n\terrors={fields.code.errors}\n/>\n```\n\n### Error Handling\n\n**Display field errors:**\n\n```typescript\n<Field\n\t// ... props\n\terrors={fields.email.errors} // Errores específicos del campo\n/>\n```\n\n**Display form errors:**\n\n```typescript\nimport { ErrorList } from '#app/components/forms.tsx'\n\n<ErrorList errors={form.errors} id={form.errorId} />\n```\n\n**Error structure:**\n\n- `fields.fieldName.errors` - Errors for a specific field\n- `form.errors` - General form errors (like `formErrors`)\n\n### Honeypot Fields\n\nEpic Stack includes spam protection with honeypot fields.\n\n**In the form:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Rest of fields */}\n</Form>\n```\n\n**In the action:**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Throws error if spam\n\n\t// ... rest of code\n}\n```\n\n### File Uploads\n\nFor forms with file uploads, use `encType=\"multipart/form-data\"`.\n\n**Schema for files:**\n\n```typescript\nconst MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB\n\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z\n\t\t.instanceof(File)\n\t\t.optional()\n\t\t.refine((file) => {\n\t\t\treturn !file || file.size <= MAX_UPLOAD_SIZE\n\t\t}, 'File must be less than 3MB'),\n\taltText: z.string().optional(),\n})\n\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**Form with file upload:**\n\n```typescript\n<Form\n\tmethod=\"POST\"\n\tencType=\"multipart/form-data\"\n\t{...getFormProps(form)}\n>\n\t{/* Fields */}\n</Form>\n```\n\n**Process files in action:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteEditorSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { images } = submission.value\n\n\t// Process files\n\tfor (const image of images ?? []) {\n\t\tif (image.file) {\n\t\t\t// Upload file, save to storage, etc.\n\t\t}\n\t}\n\n\t// ...\n}\n```\n\n### Fieldsets y Arrays\n\nFor forms with repetitive fields (like multiple images):\n\n**Schema:**\n\n```typescript\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z.instanceof(File).optional(),\n\taltText: z.string().optional(),\n})\n\nconst FormSchema = z.object({\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**In the component:**\n\n```typescript\nimport { FormProvider, getFieldsetProps } from '@conform-to/react'\n\nconst [form, fields] = useForm({\n\t// ...\n\tdefaultValue: {\n\t\timages: note?.images ?? [{}],\n\t},\n})\n\nconst imageList = fields.images.getFieldList()\n\nreturn (\n\t<FormProvider context={form.context}>\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{imageList.map((image, index) => {\n\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\treturn (\n\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</fieldset>\n\t\t\t\t)\n\t\t\t})}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t>\n\t\t\t\tAdd Image\n\t\t\t</button>\n\t\t</Form>\n\t</FormProvider>\n)\n```\n\n### StatusButton\n\nUse `StatusButton` to display submission status:\n\n```typescript\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\nimport { useIsPending } from '#app/utils/misc.tsx'\n\nconst isPending = useIsPending()\n\n<StatusButton\n\tclassName=\"w-full\"\n\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\ttype=\"submit\"\n\tdisabled={isPending}\n>\n\tSubmit\n</StatusButton>\n```\n\n### Progressive Enhancement\n\nForms work without JavaScript thanks to Conform:\n\n- Native HTML5 validation\n- Server-side validation\n- Enhancements with JavaScript when available\n\n**You don't need to do anything special** - Conform handles this automatically.\n\n### Reusable Schema Validation\n\nCreate reusable schemas in `app/utils/user-validation.ts`:\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase())\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long',\n\t})\n\nexport const PasswordAndConfirmPasswordSchema = z\n\t.object({ password: PasswordSchema, confirmPassword: PasswordSchema })\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'The passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Use in forms:**\n\n```typescript\nimport {\n\tEmailSchema,\n\tPasswordAndConfirmPasswordSchema,\n} from '#app/utils/user-validation.ts'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: EmailSchema,\n\t\tusername: UsernameSchema,\n\t})\n\t.and(PasswordAndConfirmPasswordSchema)\n```\n\n## Common examples\n\n### Example 1: Simple login form\n\n```typescript\n// app/routes/_auth/login.tsx\nimport { getFormProps, getInputProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\n\nconst LoginSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(1),\n})\n\nexport default function LoginRoute({ actionData }: Route.ComponentProps) {\n\tconst isPending = useIsPending()\n\n\tconst [form, fields] = useForm({\n\t\tid: 'login-form',\n\t\tconstraint: getZodConstraint(LoginSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: LoginSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t\t\t\tautoFocus: true,\n\t\t\t\t\tautoComplete: 'email',\n\t\t\t\t}}\n\t\t\t\terrors={fields.email.errors}\n\t\t\t/>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.password, { type: 'password' }),\n\t\t\t\t\tautoComplete: 'current-password',\n\t\t\t\t}}\n\t\t\t\terrors={fields.password.errors}\n\t\t\t/>\n\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t<StatusButton\n\t\t\t\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\t\t\t\ttype=\"submit\"\n\t\t\t>\n\t\t\t\tLogin\n\t\t\t</StatusButton>\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Form with async validation\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Procesar signup...\n}\n```\n\n### Example 3: Form with file upload\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n\nexport default function NewNoteRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'note-editor',\n\t\tconstraint: getZodConstraint(NoteEditorSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: NoteEditorSchema })\n\t\t},\n\t\tdefaultValue: {\n\t\t\timages: [{}],\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\tconst imageList = fields.images.getFieldList()\n\n\treturn (\n\t\t<FormProvider context={form.context}>\n\t\t\t<Form\n\t\t\t\tmethod=\"POST\"\n\t\t\t\tencType=\"multipart/form-data\"\n\t\t\t\t{...getFormProps(form)}\n\t\t\t>\n\t\t\t\t<Field\n\t\t\t\t\tlabelProps={{ children: 'Title' }}\n\t\t\t\t\tinputProps={{\n\t\t\t\t\t\tautoFocus: true,\n\t\t\t\t\t\t...getInputProps(fields.title, { type: 'text' }),\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.title.errors}\n\t\t\t\t/>\n\t\t\t\t<TextareaField\n\t\t\t\t\tlabelProps={{ children: 'Content' }}\n\t\t\t\t\ttextareaProps={{\n\t\t\t\t\t\t...getTextareaProps(fields.content),\n\t\t\t\t\t\trows: 10,\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.content.errors}\n\t\t\t\t/>\n\n\t\t\t\t{imageList.map((image, index) => {\n\t\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t\t\taccept=\"image/*\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t\t\tdisabled={imageList.length >= 5}\n\t\t\t\t>\n\t\t\t\t\tAdd Image\n\t\t\t\t</button>\n\n\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t\t<StatusButton type=\"submit\">Create Note</StatusButton>\n\t\t\t</Form>\n\t\t</FormProvider>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit validation**: Always use explicit Zod schemas with clear error\n  messages, not hidden validation logic\n- ❌ **Delayed validation**: Validate input immediately and fail fast - don't\n  wait until the end of the function\n- ❌ **Generic error messages**: Use specific, helpful error messages that tell\n  users exactly what's wrong\n- ❌ **Forgetting `async: true` in async validation**: Always include\n  `async: true` when using `superRefine` with async\n- ❌ **Not including `HoneypotInputs` in public forms**: Always include honeypot\n  in forms accessible without authentication\n- ❌ **Forgetting `encType=\"multipart/form-data\"`**: Required for file uploads\n- ❌ **Not validating file sizes**: Always validate size in the schema\n- ❌ **Not using `getZodConstraint`**: Required for native HTML5 validation\n- ❌ **Forgetting `lastResult` in `useForm`**: Required to display server errors\n- ❌ **Not using `shouldRevalidate: 'onBlur'`**: Improves UX by validating on\n  field blur\n- ❌ **Not using pre-built field components**: `Field`, `TextareaField`, etc.\n  already handle accessibility and errors\n\n## References\n\n- [Conform Documentation](https://conform.guide/)\n- [Zod Documentation](https://zod.dev/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/components/forms.tsx` - Field components\n- `app/routes/_auth/signup.tsx` - Complete signup example\n- `app/routes/_auth/onboarding/index.tsx` - Complex form example\n- `app/routes/users/$username/notes/+shared/note-editor.tsx` - File uploads\n  example\n- `app/utils/user-validation.ts` - Reusable schemas\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n","docs/skills/epic-permissions/SKILL.md":"---\nname: epic-permissions\ndescription: Guide on RBAC system and permissions for Epic Stack\ncategories:\n  - permissions\n  - rbac\n  - access-control\n---\n\n# Epic Stack: Permissions\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement role-based access control (RBAC)\n- Validate permissions on server-side or client-side\n- Create new permissions or roles\n- Restrict access to routes or actions\n- Implement granular permissions (`own` vs `any`)\n\n## Patterns and conventions\n\n### Permissions Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Always explicitly check permissions.\nDon't assume a user has access based on implicit rules or hidden logic. Every\npermission check should be visible and clear in the code.\n\n**Example - Explicit permission checks:**\n\n```typescript\n// ✅ Good - Explicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly check permission - clear and visible\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Permission check is explicit and obvious\n\tawait prisma.note.delete({ where: { id: noteId } })\n}\n\n// ❌ Avoid - Implicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst note = await prisma.note.findUnique({ where: { id: noteId } })\n\n\t// Implicit check - not clear what permission is being checked\n\tif (note.ownerId !== userId) {\n\t\tthrow new Response('Forbidden', { status: 403 })\n\t}\n\t// What permission does this represent? Not explicit\n}\n```\n\n**Example - Explicit permission strings:**\n\n```typescript\n// ✅ Good - Explicit permission string\nconst permission: PermissionString = 'delete:note:own'\n// Clear: action (delete), entity (note), access (own)\n\nawait requireUserWithPermission(request, permission)\n\n// ❌ Avoid - Implicit or unclear permissions\nconst canDelete = checkUserCanDelete(user, note)\n// What permission is this checking? Not explicit\n```\n\n### RBAC Model\n\nEpic Stack uses an RBAC (Role-Based Access Control) model where:\n\n- **Users** have **Roles**\n- **Roles** have **Permissions**\n- A user's permissions are the union of all permissions from their roles\n\n### Permission Structure\n\nPermissions follow the format: `action:entity:access`\n\n**Components:**\n\n- `action`: The allowed action (`create`, `read`, `update`, `delete`)\n- `entity`: The entity being acted upon (`user`, `note`, etc.)\n- `access`: The access level (`own`, `any`, `own,any`)\n\n**Examples:**\n\n- `create:note:own` - Can create own notes\n- `read:note:any` - Can read any note\n- `delete:user:any` - Can delete any user (admin)\n- `update:note:own` - Can update only own notes\n\n### Prisma Schema\n\n**Models:**\n\n```prisma\nmodel Permission {\n  id          String @id @default(cuid())\n  action      String // e.g. create, read, update, delete\n  entity      String // e.g. note, user, etc.\n  access      String // e.g. own or any\n  description String @default(\"\")\n\n  roles Role[]\n\n  @@unique([action, entity, access])\n}\n\nmodel Role {\n  id          String @id @default(cuid())\n  name        String @unique\n  description String @default(\"\")\n\n  users       User[]\n  permissions Permission[]\n}\n\nmodel User {\n  id    String @id @default(cuid())\n  // ...\n  roles Role[]\n}\n```\n\n### Validate Permissions Server-Side\n\n**Require specific permission:**\n\n```typescript\nimport { requireUserWithPermission } from '#app/utils/permissions.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserWithPermission(\n\t\trequest,\n\t\t'delete:note:own', // Throws 403 error if doesn't have permission\n\t)\n\n\t// User has the permission, continue...\n}\n```\n\n**Require specific role:**\n\n```typescript\nimport { requireUserWithRole } from '#app/utils/permissions.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserWithRole(request, 'admin')\n\n\t// User has admin role, continue...\n}\n```\n\n**Conditional permissions (own vs any) - explicit:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly determine ownership\n\tconst note = await prisma.note.findUnique({\n\t\twhere: { id: noteId },\n\t\tselect: { ownerId: true },\n\t})\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Explicitly check the appropriate permission based on ownership\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string\n\t)\n\n\t// Permission check is explicit and clear\n\t// Proceed with deletion...\n}\n```\n\n### Validate Permissions Client-Side\n\n**Check if user has permission:**\n\n```typescript\nimport { userHasPermission, useOptionalUser } from '#app/utils/user.ts'\n\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t{canDelete && (\n\t\t\t\t<button onClick={handleDelete}>Delete</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n**Check if user has role:**\n\n```typescript\nimport { userHasRole } from '#app/utils/user.ts'\n\nexport default function AdminRoute() {\n\tconst user = useOptionalUser()\n\tconst isAdmin = userHasRole(user, 'admin')\n\n\tif (!isAdmin) {\n\t\treturn <div>Access Denied</div>\n\t}\n\n\treturn <div>Admin Panel</div>\n}\n```\n\n### Create New Permissions\n\n**En Prisma Studio o seed:**\n\n```typescript\n// prisma/seed.ts\nawait prisma.permission.create({\n\tdata: {\n\t\taction: 'create',\n\t\tentity: 'post',\n\t\taccess: 'own',\n\t\tdescription: 'Can create their own posts',\n\t\troles: {\n\t\t\tconnect: { name: 'user' },\n\t\t},\n\t},\n})\n```\n\n**Permiso con múltiples niveles de acceso:**\n\n```typescript\nawait prisma.permission.createMany({\n\tdata: [\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own posts',\n\t\t},\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t],\n})\n```\n\n### Assign Roles to Users\n\n**When creating user:**\n\n```typescript\nconst user = await prisma.user.create({\n\tdata: {\n\t\temail,\n\t\tusername,\n\t\troles: {\n\t\t\tconnect: { name: 'user' }, // Assign 'user' role\n\t\t},\n\t},\n})\n```\n\n**Assign multiple roles:**\n\n```typescript\nawait prisma.user.update({\n\twhere: { id: userId },\n\tdata: {\n\t\troles: {\n\t\t\tconnect: [{ name: 'user' }, { name: 'moderator' }],\n\t\t},\n\t},\n})\n```\n\n### Permissions and Roles Seed\n\n**Seed example:**\n\n```typescript\n// prisma/seed.ts\n\n// Create permissions\nconst permissions = await Promise.all([\n\t// User permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'update',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can update own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can delete own notes',\n\t\t},\n\t}),\n\t// Admin permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'user',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can delete any user',\n\t\t},\n\t}),\n])\n\n// Create roles\nconst userRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'user',\n\t\tdescription: 'Standard user',\n\t\tpermissions: {\n\t\t\tconnect: permissions.slice(0, 4).map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n\nconst adminRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'admin',\n\t\tdescription: 'Administrator',\n\t\tpermissions: {\n\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n```\n\n### Permission Type\n\n**Type-safe permission strings:**\n\n```typescript\nimport { type PermissionString } from '#app/utils/user.ts'\n\n// Tipo: 'create:note:own' | 'read:note:own' | etc.\nconst permission: PermissionString = 'delete:note:own'\n```\n\n**Parsear permission string:**\n\n```typescript\nimport { parsePermissionString } from '#app/utils/user.ts'\n\nconst { action, entity, access } = parsePermissionString('delete:note:own')\n// action: 'delete'\n// entity: 'note'\n// access: ['own']\n```\n\n## Common examples\n\n### Example 1: Proteger action con permiso\n\n```typescript\n// app/routes/users/$username/notes/$noteId.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\tconst { noteId } = Object.fromEntries(formData)\n\n\tconst note = await prisma.note.findFirst({\n\t\tselect: { id: true, ownerId: true, owner: { select: { username: true } } },\n\t\twhere: { id: noteId },\n\t})\n\n\tif (!note) {\n\t\tthrow new Response('Not found', { status: 404 })\n\t}\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Validate permiso según si es propietario o no\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\tawait prisma.note.delete({ where: { id: note.id } })\n\n\treturn redirect(`/users/${note.owner.username}/notes`)\n}\n```\n\n### Example 2: Mostrar UI condicional basada en permisos\n\n```typescript\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\tconst canEdit = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'update:note:own' : 'update:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.note.title}</h1>\n\t\t\t<p>{loaderData.note.content}</p>\n\n\t\t\t{(canEdit || canDelete) && (\n\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t{canEdit && (\n\t\t\t\t\t\t<Link to=\"edit\">\n\t\t\t\t\t\t\t<Button>Edit</Button>\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t)}\n\t\t\t\t\t{canDelete && (\n\t\t\t\t\t\t<DeleteNoteButton noteId={loaderData.note.id} />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n### Example 3: Ruta solo para admin\n\n```typescript\n// app/routes/admin/users.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireUserWithRole(request, 'admin')\n\n\tconst users = await prisma.user.findMany({\n\t\tselect: {\n\t\t\tid: true,\n\t\t\temail: true,\n\t\t\tusername: true,\n\t\t},\n\t})\n\n\treturn { users }\n}\n\nexport default function AdminUsersRoute({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>All Users</h1>\n\t\t\t{loaderData.users.map(user => (\n\t\t\t\t<div key={user.id}>{user.username}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n### Example 4: Create new permission and assign it\n\n```typescript\n// Migración o seed\nasync function setupPostPermissions() {\n\t// Create post permissions\n\tconst createOwn = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own posts',\n\t\t},\n\t})\n\n\tconst readAny = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t})\n\n\t// Assign to user role\n\tawait prisma.role.update({\n\t\twhere: { name: 'user' },\n\t\tdata: {\n\t\t\tpermissions: {\n\t\t\t\tconnect: [{ id: createOwn.id }, { id: readAny.id }],\n\t\t\t},\n\t\t},\n\t})\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit permission checks**: Always explicitly check permissions - make\n  permission requirements visible in code\n- ❌ **Not validating permissions on server-side**: Always validate permissions\n  in action/loader, never trust client-side only\n- ❌ **Forgetting to verify `own` vs `any`**: Explicitly determine if user is\n  owner before validating permission\n- ❌ **Not using correct helpers**: Use `requireUserWithPermission` for\n  server-side and `userHasPermission` for client-side - explicit helpers\n- ❌ **Not creating unique permissions**: Use\n  `@@unique([action, entity, access])` in schema - explicit permission structure\n- ❌ **Assuming permissions instead of verifying**: Always verify explicitly,\n  even if you think user has the permission\n- ❌ **Not handling 403 errors**: Helpers throw errors that must be handled by\n  ErrorBoundary\n- ❌ **Not using types**: Use `PermissionString` type for type-safety - explicit\n  types\n- ❌ **Hidden permission logic**: Don't hide permission checks in utility\n  functions - make them explicit at the call site\n\n## References\n\n- [Epic Stack Permissions Docs](../epic-stack/docs/permissions.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [RBAC Explained](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac)\n- `app/utils/permissions.server.ts` - Server-side permission utilities\n- `app/utils/user.ts` - Client-side permission utilities\n- `prisma/schema.prisma` - Permission and Role models\n- `prisma/seed.ts` - Permission seed examples\n","docs/skills/epic-react-patterns/SKILL.md":"---\nname: epic-react-patterns\ndescription:\n  Guide on React patterns, performance optimization, and code quality for Epic\n  Stack\ncategories:\n  - react\n  - performance\n  - patterns\n  - optimization\n---\n\n# Epic Stack: React Patterns and Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write efficient React components in Epic Stack applications\n- Optimize performance and bundle size\n- Follow React Router patterns and conventions\n- Avoid common React anti-patterns\n- Implement proper code splitting\n- Optimize re-renders and data fetching\n- Use React hooks correctly\n\n## Philosophy\n\nFollowing Epic Web principles:\n\n- **Make it work, make it right, make it fast** - In that order. First make it\n  functional, then refactor for clarity, then optimize for performance.\n- **Pragmatism over purity** - Choose practical solutions that work well in your\n  context rather than theoretically perfect ones.\n- **Optimize for sustainable velocity** - Write code that's easy to maintain and\n  extend, not just fast to write initially.\n- **Do as little as possible** - Only add complexity when it provides real\n  value.\n\n## Patterns and conventions\n\n### Data Fetching in React Router\n\nEpic Stack uses React Router loaders for data fetching, not `useEffect`.\n\n**✅ Good - Use loaders:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n**❌ Avoid - Don't fetch in useEffect:**\n\n```typescript\n// ❌ Don't do this\nexport default function UserRoute({ params }: Route.ComponentProps) {\n\tconst [user, setUser] = useState(null)\n\n\tuseEffect(() => {\n\t\tfetch(`/api/users/${params.username}`)\n\t\t\t.then(res => res.json())\n\t\t\t.then(setUser)\n\t}, [params.username])\n\n\treturn user ? <div>{user.name}</div> : <div>Loading...</div>\n}\n```\n\n### Avoid useEffect for Side Effects\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use event handlers, CSS, ref callbacks, or\n`useSyncExternalStore`.\n\n**✅ Good - Use event handlers:**\n\n```typescript\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigate('/checkout')\n\t}\n\n\treturn (\n\t\t<div>\n\t\t\t<button onClick={handleBuyClick}>Buy Now</button>\n\t\t\t<button onClick={handleCheckoutClick}>Checkout</button>\n\t\t</div>\n\t)\n}\n```\n\n**❌ Avoid - Side effects in useEffect:**\n\n```typescript\n// ❌ Don't do this\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\t// ...\n}\n```\n\n**✅ Appropriate use of useEffect:**\n\n```typescript\n// ✅ Good - Event listeners are appropriate\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\t\t\t// handle escape key\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n\n### Code Splitting with React Router\n\nReact Router automatically code-splits by route. Use dynamic imports for heavy\ncomponents.\n\n**✅ Good - Dynamic imports:**\n\n```typescript\n// app/routes/admin/dashboard.tsx\nimport { lazy } from 'react'\n\nconst AdminChart = lazy(() => import('#app/components/admin/chart.tsx'))\n\nexport default function AdminDashboard() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading chart...</div>}>\n\t\t\t<AdminChart />\n\t\t</Suspense>\n\t)\n}\n```\n\n### Optimizing Re-renders\n\n**✅ Good - Memoize expensive computations:**\n\n```typescript\nimport { useMemo } from 'react'\n\nfunction UserList({ users }: { users: User[] }) {\n\tconst sortedUsers = useMemo(() => {\n\t\treturn [...users].sort((a, b) => a.name.localeCompare(b.name))\n\t}, [users])\n\n\treturn (\n\t\t<ul>\n\t\t\t{sortedUsers.map(user => (\n\t\t\t\t<li key={user.id}>{user.name}</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**✅ Good - Memoize callbacks:**\n\n```typescript\nimport { useCallback } from 'react'\n\nfunction NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) {\n\tconst handleSave = useCallback((note: Note) => {\n\t\tonSave(note)\n\t}, [onSave])\n\n\treturn <Editor onSave={handleSave} />\n}\n```\n\n**❌ Avoid - Unnecessary memoization:**\n\n```typescript\n// ❌ Don't memoize simple values\nconst count = useMemo(() => items.length, [items]) // Just use items.length directly\n\n// ❌ Don't memoize simple callbacks\nconst handleClick = useCallback(() => {\n\tconsole.log('clicked')\n}, []) // Just define the function normally if it doesn't need memoization\n```\n\n### Bundle Size Optimization\n\n**✅ Good - Import only what you need:**\n\n```typescript\n// ✅ Import specific functions\nimport { useSearchParams } from 'react-router'\nimport { parseWithZod } from '@conform-to/zod'\n```\n\n**❌ Avoid - Barrel imports:**\n\n```typescript\n// ❌ Don't import entire libraries if you only need one thing\nimport * as ReactRouter from 'react-router'\nimport * as Conform from '@conform-to/zod'\n```\n\n### Form Handling with Conform\n\n**✅ Good - Use Conform for forms:**\n\n```typescript\nimport { useForm, getFormProps } from '@conform-to/react'\nimport { parseWithZod } from '@conform-to/zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Component Composition\n\n**✅ Good - Compose components:**\n\n```typescript\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<Card>\n\t\t\t<UserHeader user={user} />\n\t\t\t<UserDetails user={user} />\n\t\t\t<UserActions userId={user.id} />\n\t\t</Card>\n\t)\n}\n```\n\n**❌ Avoid - Large monolithic components:**\n\n```typescript\n// ❌ Don't put everything in one component\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<div className=\"card\">\n\t\t\t<div className=\"header\">\n\t\t\t\t<img src={user.avatar} alt={user.name} />\n\t\t\t\t<h1>{user.name}</h1>\n\t\t\t</div>\n\t\t\t<div className=\"details\">\n\t\t\t\t<p>{user.email}</p>\n\t\t\t\t<p>{user.bio}</p>\n\t\t\t</div>\n\t\t\t<div className=\"actions\">\n\t\t\t\t<button>Edit</button>\n\t\t\t\t<button>Delete</button>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n```\n\n### Error Boundaries\n\n**✅ Good - Use error boundaries:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>User \"{params.username}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### TypeScript Guidelines\n\n**✅ Good - Type props explicitly:**\n\n```typescript\ninterface UserCardProps {\n\tuser: {\n\t\tid: string\n\t\tname: string\n\t\temail: string\n\t}\n\tonEdit?: (userId: string) => void\n}\n\nfunction UserCard({ user, onEdit }: UserCardProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h2>{user.name}</h2>\n\t\t\t<p>{user.email}</p>\n\t\t\t{onEdit && <button onClick={() => onEdit(user.id)}>Edit</button>}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Route types:**\n\n```typescript\nimport type { Route } from './+types/users.$username'\n\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params is type-safe!\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\t// loaderData is type-safe!\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n### Loading States\n\n**✅ Good - Use React Router's pending states:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction NoteForm() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<button type=\"submit\" disabled={isSubmitting}>\n\t\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t\t</button>\n\t\t</Form>\n\t)\n}\n```\n\n### Preventing Data Fetching Waterfalls\n\nReact Router loaders can prevent waterfalls by fetching data in parallel.\n\n**❌ Avoid - Sequential data fetching (waterfall):**\n\n```typescript\n// ❌ Don't do this - creates a waterfall\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\t// Second fetch waits for first to complete\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t})\n\treturn { user, notes }\n}\n```\n\n**✅ Good - Parallel data fetching:**\n\n```typescript\n// ✅ Fetch data in parallel\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\n\t// Fetch notes in parallel with user data\n\tconst [notes, stats] = await Promise.all([\n\t\tuser\n\t\t\t? prisma.note.findMany({\n\t\t\t\t\twhere: { ownerId: user.id },\n\t\t\t\t\tselect: { id: true, title: true, updatedAt: true },\n\t\t\t\t})\n\t\t\t: Promise.resolve([]),\n\t\tuser\n\t\t\t? prisma.note.count({ where: { ownerId: user.id } })\n\t\t\t: Promise.resolve(0),\n\t])\n\n\treturn { user, notes, stats }\n}\n```\n\n**✅ Good - Nested route parallel loading:**\n\n```typescript\n// Parent route loader\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\n// Child route loader runs in parallel\n// app/routes/users/$username/notes.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true },\n\t})\n\n\tif (!user) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t\tselect: { id: true, title: true, updatedAt: true },\n\t})\n\n\treturn { notes }\n}\n```\n\n### Server-Side Rendering (SSR) Performance\n\nReact Router provides SSR by default. Optimize by:\n\n**✅ Good - Selective data fetching:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Only fetch what's needed for initial render\n\tconst searchParams = new URL(request.url).searchParams\n\tconst page = Number(searchParams.get('page') || '1')\n\n\tconst [items, total] = await Promise.all([\n\t\tprisma.item.findMany({\n\t\t\ttake: 20,\n\t\t\tskip: (page - 1) * 20,\n\t\t\tselect: { id: true, title: true }, // Only needed fields\n\t\t}),\n\t\tprisma.item.count(),\n\t])\n\n\treturn { items, total, page }\n}\n```\n\n**✅ Good - Use caching for expensive operations:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\t// Cache expensive database queries\n\tconst stats = await cachified({\n\t\tkey: 'user-stats',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.aggregate({\n\t\t\t\t_count: { id: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t})\n\n\treturn { stats }\n}\n```\n\n### Rendering Performance\n\n**✅ Good - Use React.memo for expensive components:**\n\n```typescript\nimport { memo } from 'react'\n\nconst ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) {\n\t// Expensive rendering logic\n\treturn <Chart data={data} />\n})\n\n// Only re-renders when data changes\nexport default function Dashboard({ chartData }: { chartData: Data[] }) {\n\treturn <ExpensiveChart data={chartData} />\n}\n```\n\n**✅ Good - Optimize list rendering:**\n\n```typescript\nimport { memo } from 'react'\n\nconst UserItem = memo(function UserItem({ user }: { user: User }) {\n\treturn (\n\t\t<li>\n\t\t\t<h3>{user.name}</h3>\n\t\t\t<p>{user.email}</p>\n\t\t</li>\n\t)\n}, (prev, next) => prev.user.id === next.user.id)\n\nfunction UserList({ users }: { users: User[] }) {\n\treturn (\n\t\t<ul>\n\t\t\t{users.map(user => (\n\t\t\t\t<UserItem key={user.id} user={user} />\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**❌ Avoid - Creating new objects/arrays in render:**\n\n```typescript\n// ❌ Don't create new objects on every render\nfunction UserProfile({ user }: { user: User }) {\n\treturn <Card user={{ ...user, fullName: `${user.firstName} ${user.lastName}` }} />\n}\n\n// ✅ Good - Compute in loader or memoize\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { firstName: true, lastName: true },\n\t})\n\n\treturn {\n\t\tuser: {\n\t\t\t...user,\n\t\t\tfullName: `${user.firstName} ${user.lastName}`,\n\t\t},\n\t}\n}\n```\n\n### Bundle Size Optimization Strategies\n\n**✅ Good - Route-based code splitting:** React Router automatically splits code\nby route. Leverage this:\n\n```typescript\n// Heavy dependencies are automatically split by route\n// app/routes/admin/dashboard.tsx\nimport { Chart } from 'chart.js' // Only loaded on /admin/dashboard route\n```\n\n**✅ Good - Dynamic imports for heavy components:**\n\n```typescript\nimport { lazy, Suspense } from 'react'\n\nconst HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx'))\n\nexport default function Route() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading...</div>}>\n\t\t\t<HeavyComponent />\n\t\t</Suspense>\n\t)\n}\n```\n\n**✅ Good - Tree-shakeable imports:**\n\n```typescript\n// ✅ Tree-shakeable - only imports what you use\nimport { format } from 'date-fns/format'\nimport { addDays } from 'date-fns/addDays'\n\n// ❌ Avoid - imports entire library\nimport * as dateFns from 'date-fns'\n```\n\n### React 18+ Features for Performance\n\n**✅ Good - Use transitions for non-urgent updates:**\n\n```typescript\nimport { useTransition } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction SearchInput() {\n\tconst [isPending, startTransition] = useTransition()\n\tconst navigation = useNavigation()\n\n\tfunction handleSearch(query: string) {\n\t\tstartTransition(() => {\n\t\t\t// Update search results (non-urgent)\n\t\t\tnavigation.navigate(`/search?q=${query}`)\n\t\t})\n\t}\n\n\treturn (\n\t\t<input\n\t\t\tonChange={(e) => handleSearch(e.target.value)}\n\t\t\tplaceholder={isPending ? 'Searching...' : 'Search'}\n\t\t/>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching data in useEffect**: Use React Router loaders instead\n- ❌ **Overusing useEffect**: Prefer event handlers, CSS, or ref callbacks\n- ❌ **Premature memoization**: Only memoize when there's a measurable\n  performance benefit\n- ❌ **Barrel imports**: Import only what you need\n- ❌ **Ignoring TypeScript types**: Use Route types for type safety\n- ❌ **Not handling loading states**: Use React Router's navigation states\n- ❌ **Large monolithic components**: Break components into smaller, focused\n  pieces\n- ❌ **Not using error boundaries**: Always add error boundaries to routes\n- ❌ **Client-side routing when server-side works**: Prefer server-side data\n  fetching\n- ❌ **Data fetching waterfalls**: Use `Promise.all()` to fetch data in parallel\n- ❌ **Fetching unnecessary data**: Only fetch what's needed for the initial\n  render\n- ❌ **Creating new objects in render**: Compute derived data in loaders or\n  memoize\n- ❌ **Not using React.memo for expensive lists**: Memoize list items for better\n  performance\n- ❌ **Not leveraging route-based code splitting**: React Router splits by route\n  automatically\n\n## References\n\n- [React Router Documentation](https://reactrouter.com/)\n- [React Documentation - You Might Not Need useEffect](https://react.dev/learn/you-might-not-need-an-effect)\n- [Conform Documentation](https://conform.guide/)\n- [Epic Stack Docs](https://www.epicweb.dev/docs)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/routes/` - Example routes using these patterns\n- `.cursor/rules/avoid-use-effect.mdc` - Epic Stack rule for avoiding useEffect\n","docs/skills/epic-routing/SKILL.md":"---\nname: epic-routing\ndescription:\n  Guide on routing with React Router and react-router-auto-routes for Epic Stack\ncategories:\n  - routing\n  - react-router\n  - file-based-routing\n---\n\n# Epic Stack: Routing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create new routes or pages in an Epic Stack application\n- Implement nested layouts\n- Configure resource routes (routes without UI)\n- Work with route parameters and search params\n- Understand Epic Stack's file-based routing conventions\n- Implement loaders and actions in routes\n\n## Patterns and conventions\n\n### Routing Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Keep your route structure simple. Don't create\ncomplex nested routes unless you actually need them. Start simple and add\ncomplexity only when there's a clear benefit.\n\n**Avoid over-engineering** - Don't create abstractions or complex route\nstructures \"just in case\". Use the simplest structure that works for your\ncurrent needs.\n\n**Example - Simple route structure:**\n\n```typescript\n// ✅ Good - Simple, straightforward route\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n\n// ❌ Avoid - Over-engineered route structure\n// app/routes/users/$username/_layout.tsx\n// app/routes/users/$username/index.tsx\n// app/routes/users/$username/_components/UserHeader.tsx\n// app/routes/users/$username/_components/UserDetails.tsx\n// Unnecessary complexity for a simple user page\n```\n\n**Example - Add complexity only when needed:**\n\n```typescript\n// ✅ Good - Add nested routes only when you actually need them\n// If you have user notes, then nested routes make sense:\n// app/routes/users/$username/notes/_layout.tsx\n// app/routes/users/$username/notes/index.tsx\n// app/routes/users/$username/notes/$noteId.tsx\n\n// ❌ Avoid - Creating nested routes \"just in case\"\n// Don't create complex structures before you need them\n```\n\n### File-based routing with react-router-auto-routes\n\nEpic Stack uses `react-router-auto-routes` instead of React Router's standard\nconvention. This enables better organization and code co-location.\n\n**Basic structure:**\n\n```\napp/routes/\n├── _layout.tsx        # Layout for child routes\n├── index.tsx          # Root route (/)\n├── about.tsx          # Route /about\n└── users/\n    ├── _layout.tsx    # Layout for user routes\n    ├── index.tsx      # Route /users\n    └── $username/\n        └── index.tsx  # Route /users/:username\n```\n\n**Configuration in `app/routes.ts`:**\n\n```typescript\nimport { type RouteConfig } from '@react-router/dev/routes'\nimport { autoRoutes } from 'react-router-auto-routes'\n\nexport default autoRoutes({\n\tignoredRouteFiles: [\n\t\t'.*',\n\t\t'**/*.css',\n\t\t'**/*.test.{js,jsx,ts,tsx}',\n\t\t'**/__*.*',\n\t\t'**/*.server.*', // Co-located server utilities\n\t\t'**/*.client.*', // Co-located client utilities\n\t],\n}) satisfies RouteConfig\n```\n\n### Route Groups\n\nRoute groups are folders that start with `_` and don't affect the URL but help\norganize related code.\n\n**Common examples:**\n\n- `_auth/` - Authentication routes (login, signup, etc.)\n- `_marketing/` - Marketing pages (home, about, etc.)\n- `_seo/` - SEO routes (sitemap, robots.txt)\n\n**Example:**\n\n```\napp/routes/\n├── _auth/\n│   ├── login.tsx          # URL: /login\n│   ├── signup.tsx         # URL: /signup\n│   └── forgot-password.tsx # URL: /forgot-password\n└── _marketing/\n    ├── index.tsx          # URL: /\n    └── about.tsx          # URL: /about\n```\n\n### Route Parameters\n\nUse `$` to indicate route parameters:\n\n**Syntax:**\n\n- `$param.tsx` → `:param` in URL\n- `$username.tsx` → `:username` in URL\n\n**Example route with parameter:**\n\n```typescript\n// app/routes/users/$username/index.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst username = params.username // Type-safe!\n\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username },\n\t})\n\n\treturn { user }\n}\n```\n\n### Nested Layouts with `_layout.tsx`\n\nUse `_layout.tsx` to create shared layouts for child routes.\n\n**Example:**\n\n```typescript\n// app/routes/users/$username/notes/_layout.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst owner = await prisma.user.findFirst({\n\t\twhere: { username: params.username },\n\t})\n\treturn { owner }\n}\n\nexport default function NotesLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<main className=\"container\">\n\t\t\t<h1>{loaderData.owner.name}'s Notes</h1>\n\t\t\t<Outlet /> {/* Child routes render here */}\n\t\t</main>\n\t)\n}\n```\n\nChild routes (`$noteId.tsx`, `index.tsx`, etc.) will render where `<Outlet />`\nis.\n\n### Resource Routes (Routes without UI)\n\nResource routes don't render UI; they only return data or perform actions.\n\n**Characteristics:**\n\n- Don't export a `default` component\n- Export `loader` or `action` or both\n- Useful for APIs, downloads, webhooks, etc.\n\n**Example:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check application health\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Check DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Loaders and Actions\n\n**Loaders** - Load data before rendering (GET requests) **Actions** - Handle\ndata mutations (POST, PUT, DELETE)\n\n**Loader pattern:**\n\n```typescript\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function RouteComponent({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Use loaderData.data */}</div>\n}\n```\n\n**Action pattern:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// Validate and process data\n\tawait prisma.something.create({\n\t\tdata: { /* ... */ },\n\t})\n\n\treturn redirect('/success')\n}\n\nexport default function RouteComponent() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Search Params\n\nAccess query parameters using `useSearchParams`:\n\n```typescript\nimport { useSearchParams } from 'react-router'\n\nexport default function SearchPage() {\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\tconst query = searchParams.get('q') || ''\n\tconst page = Number(searchParams.get('page') || '1')\n\n\treturn (\n\t\t<div>\n\t\t\t<input\n\t\t\t\tvalue={query}\n\t\t\t\tonChange={(e) => setSearchParams({ q: e.target.value })}\n\t\t\t/>\n\t\t\t{/* Results */}\n\t\t</div>\n\t)\n}\n```\n\n### Code Co-location\n\nEpic Stack encourages placing related code close to where it's used.\n\n**Typical structure:**\n\n```\napp/routes/users/$username/notes/\n├── _layout.tsx              # Layout with loader\n├── index.tsx                # Notes list\n├── $noteId.tsx              # Note view\n├── $noteId_.edit.tsx        # Edit note\n├── +shared/                 # Code shared between routes\n│   └── note-editor.tsx      # Shared editor\n└── $noteId.server.ts        # Server-side utilities\n```\n\nThe `+` prefix indicates co-located modules that are not routes.\n\n### Naming Conventions\n\n- `_layout.tsx` - Layout for child routes\n- `index.tsx` - Root route of the segment\n- `$param.tsx` - Route parameter\n- `$param_.action.tsx` - Route with parameter + action (using `_`)\n- `[.]ext.tsx` - Resource route (e.g., `robots[.]txt.ts`)\n\n## Common examples\n\n### Example 1: Create a basic route with layout\n\n```typescript\n// app/routes/products/_layout.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst categories = await prisma.category.findMany()\n\treturn { categories }\n}\n\nexport default function ProductsLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<nav>\n\t\t\t\t{loaderData.categories.map(cat => (\n\t\t\t\t\t<Link key={cat.id} to={`/products/${cat.slug}`}>\n\t\t\t\t\t\t{cat.name}\n\t\t\t\t\t</Link>\n\t\t\t\t))}\n\t\t\t</nav>\n\t\t\t<Outlet />\n\t\t</div>\n\t)\n}\n\n// app/routes/products/index.tsx\nexport default function ProductsIndex() {\n\treturn <div>Products list</div>\n}\n```\n\n### Example 2: Route with dynamic parameter\n\n```typescript\n// app/routes/products/$slug.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst product = await prisma.product.findUnique({\n\t\twhere: { slug: params.slug },\n\t})\n\n\tif (!product) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\treturn { product }\n}\n\nexport default function ProductPage({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.product.name}</h1>\n\t\t\t<p>{loaderData.product.description}</p>\n\t\t</div>\n\t)\n}\n\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>Product \"{params.slug}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### Example 3: Resource route for download\n\n```typescript\n// app/routes/resources/download-report.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst report = await generateReport(userId)\n\n\treturn new Response(report, {\n\t\theaders: {\n\t\t\t'Content-Type': 'application/pdf',\n\t\t\t'Content-Disposition': 'attachment; filename=\"report.pdf\"',\n\t\t},\n\t})\n}\n```\n\n### Example 4: Route with multiple nested parameters\n\n```typescript\n// app/routes/users/$username/posts/$postId/comments/$commentId.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params contains: { username, postId, commentId }\n\tconst comment = await prisma.comment.findUnique({\n\t\twhere: { id: params.commentId },\n\t\tinclude: {\n\t\t\tpost: {\n\t\t\t\tinclude: { author: true },\n\t\t\t},\n\t\t},\n\t})\n\n\treturn { comment }\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Over-engineering route structure**: Keep routes simple - don't create\n  complex nested structures unless you actually need them\n- ❌ **Creating abstractions prematurely**: Start with simple routes, add\n  complexity only when there's a clear benefit\n- ❌ **Using React Router's standard convention**: Epic Stack uses\n  `react-router-auto-routes`, not the standard convention\n- ❌ **Exporting default component in resource routes**: Resource routes should\n  not export components\n- ❌ **Not using nested layouts when needed**: Use `_layout.tsx` when you have\n  shared UI, but don't create layouts unnecessarily\n- ❌ **Forgetting `<Outlet />` in layouts**: Without `<Outlet />`, child routes\n  won't render\n- ❌ **Using incorrect names for parameters**: Should be `$param.tsx`, not\n  `:param.tsx` or `[param].tsx`\n- ❌ **Mixing route groups with URLs**: Groups (`_auth/`) don't appear in the\n  URL\n- ❌ **Not validating params**: Always validate that parameters exist before\n  using them\n- ❌ **Duplicating route logic**: Use layouts and shared components, but only\n  when it reduces duplication\n\n## References\n\n- [Epic Stack Routing Docs](../epic-stack/docs/routing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [React Router Auto Routes](https://github.com/kenn/react-router-auto-routes)\n- `app/routes.ts` - Auto-routes configuration\n- `app/routes/users/$username/notes/_layout.tsx` - Example of nested layout\n- `app/routes/resources/healthcheck.tsx` - Example of resource route\n- `app/routes/_auth/login.tsx` - Example of route in route group\n","docs/skills/epic-security/SKILL.md":"---\nname: epic-security\ndescription:\n  Guide on security practices including CSP, rate limiting, and session security\n  for Epic Stack\ncategories:\n  - security\n  - csp\n  - rate-limiting\n  - headers\n---\n\n# Epic Stack: Security\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure Content Security Policy (CSP)\n- Implement spam protection (honeypot)\n- Configure rate limiting\n- Manage session security\n- Implement input validation\n- Configure secure headers\n- Manage secrets\n\n## Patterns and conventions\n\n### Security Philosophy\n\nFollowing Epic Web principles:\n\n**Design to fail fast and early** - Validate security constraints as early as\npossible. Check authentication, authorization, and input validation before\nprocessing requests. Fail immediately with clear error messages rather than\nallowing potentially malicious data to flow through the system.\n\n**Optimize for the debugging experience** - When security checks fail, provide\nclear, actionable error messages that help developers understand what went\nwrong. Log security events with enough context to debug issues without exposing\nsensitive information.\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate security constraints early\nexport async function action({ request }: Route.ActionArgs) {\n\t// 1. Authenticate immediately - fail fast if not authenticated\n\tconst userId = await requireUserId(request)\n\n\t// 2. Validate input early - fail fast if invalid\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\t// 3. Check permissions early - fail fast if unauthorized\n\tawait requireUserWithPermission(request, 'create:note:own')\n\n\t// Only proceed if all security checks pass\n\tconst { title, content } = submission.value\n\t// ... create note\n}\n\n// ❌ Avoid - Security checks scattered or delayed\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\t// ... process data first\n\n\t// Security check at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed potentially malicious data\n\t\treturn json({ error: 'Unauthorized' }, { status: 401 })\n\t}\n}\n```\n\n**Example - Debugging-friendly error messages:**\n\n```typescript\n// ✅ Good - Clear error messages for debugging\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log with context for debugging\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tuserAgent: formData.get('user-agent'),\n\t\t\t\t// Don't log sensitive data\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n\n// ❌ Avoid - Generic or unhelpful errors\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\t// No context, hard to debug\n\t\tthrow new Response('Error', { status: 400 })\n\t}\n}\n```\n\n### Content Security Policy (CSP)\n\nEpic Stack uses CSP to prevent XSS and other attacks.\n\n**Configuration in `server/index.ts`:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Note:** By default, CSP is in `report-only` mode to avoid blocking resources\nduring development. In production, remove `reportOnly: true` to enable it fully.\n\n### Honeypot Fields\n\nEpic Stack uses honeypot fields to protect against spam bots.\n\n**En formularios públicos:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Resto de campos */}\n</Form>\n```\n\n**En el action (fail fast):**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Check honeypot first - fail fast if spam detected\n\tawait checkHoneypot(formData) // Lanza error si es spam\n\n\t// Only proceed if honeypot check passes\n\t// ... resto del código\n}\n```\n\n**Configuration:**\n\n```typescript\n// app/utils/honeypot.server.ts\nimport { Honeypot, SpamError } from 'remix-utils/honeypot/server'\n\nexport const honeypot = new Honeypot({\n\tvalidFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined,\n\tencryptionSeed: process.env.HONEYPOT_SECRET,\n})\n\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log for debugging (without sensitive data)\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n```\n\n### Rate Limiting\n\nEpic Stack uses `express-rate-limit` para prevenir abuso.\n\n**Basic configuration:**\n\n```typescript\n// server/index.ts\nimport rateLimit from 'express-rate-limit'\n\nconst rateLimitDefault = {\n\twindowMs: 60 * 1000, // 1 minute\n\tlimit: 1000, // 1000 requests per minute\n\tstandardHeaders: true,\n\tlegacyHeaders: false,\n\tvalidate: { trustProxy: false },\n\tkeyGenerator: (req: express.Request) => {\n\t\treturn req.get('fly-client-ip') ?? `${req.ip}`\n\t},\n}\n\nconst generalRateLimit = rateLimit(rateLimitDefault)\n```\n\n**Different levels of rate limiting:**\n\n```typescript\n// Stricter rate limit for sensitive routes\nconst strongestRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 10, // Only 10 requests per minute\n})\n\n// Strong rate limit for important actions\nconst strongRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 100, // 100 requests per minute\n})\n```\n\n**Apply to specific routes:**\n\n```typescript\napp.use((req, res, next) => {\n\tconst strongPaths = [\n\t\t'/login',\n\t\t'/signup',\n\t\t'/verify',\n\t\t'/admin',\n\t\t'/reset-password',\n\t]\n\n\tif (req.method !== 'GET' && req.method !== 'HEAD') {\n\t\tif (strongPaths.some((p) => req.path.includes(p))) {\n\t\t\treturn strongestRateLimit(req, res, next)\n\t\t}\n\t\treturn strongRateLimit(req, res, next)\n\t}\n\n\treturn generalRateLimit(req, res, next)\n})\n```\n\n**Note:** In tests and development, rate limiting is effectively disabled to\nallow fast tests.\n\n### Session Security\n\n**Secure session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true, // Prevents access from JavaScript\n\t\tsecrets: process.env.SESSION_SECRET.split(','), // Secret rotation\n\t\tsecure: process.env.NODE_ENV === 'production', // HTTPS only in production\n\t},\n})\n```\n\n**Security features:**\n\n- `httpOnly: true` - Prevents access from JavaScript (XSS protection)\n- `secure: true` - Only sends cookies over HTTPS in production\n- `sameSite: 'lax'` - CSRF protection\n- Secret rotation using array\n\n### Password Security\n\n**Hashing de passwords:**\n\n```typescript\nimport bcrypt from 'bcryptjs'\n\nexport async function getPasswordHash(password: string) {\n\tconst hash = await bcrypt.hash(password, 10) // 10 rounds\n\treturn hash\n}\n\nexport async function verifyUserPassword(\n\twhere: Pick<User, 'username'> | Pick<User, 'id'>,\n\tpassword: string,\n) {\n\tconst userWithPassword = await prisma.user.findUnique({\n\t\twhere,\n\t\tselect: { id: true, password: { select: { hash: true } } },\n\t})\n\n\tif (!userWithPassword || !userWithPassword.password) {\n\t\treturn null\n\t}\n\n\tconst isValid = await bcrypt.compare(password, userWithPassword.password.hash)\n\treturn isValid ? { id: userWithPassword.id } : null\n}\n```\n\n**Check common passwords (Have I Been Pwned):**\n\n```typescript\nimport { checkIsCommonPassword } from '#app/utils/auth.server.ts'\n\nconst isCommonPassword = await checkIsCommonPassword(password)\nif (isCommonPassword) {\n\tctx.addIssue({\n\t\tpath: ['password'],\n\t\tcode: 'custom',\n\t\tmessage: 'Password is too common',\n\t})\n}\n```\n\n### Input Validation y Sanitization\n\n**Always validate inputs with Zod:**\n\n```typescript\nimport { z } from 'zod'\n\nconst UserSchema = z.object({\n\temail: z\n\t\t.string()\n\t\t.email()\n\t\t.min(3)\n\t\t.max(100)\n\t\t.transform((val) => val.toLowerCase()),\n\tusername: z\n\t\t.string()\n\t\t.min(3)\n\t\t.max(20)\n\t\t.regex(/^[a-zA-Z0-9_]+$/),\n\tpassword: z.string().min(6).max(72), // bcrypt limit\n})\n\n// Validate before using\nconst result = UserSchema.safeParse(data)\nif (!result.success) {\n\treturn json({ errors: result.error.flatten() }, { status: 400 })\n}\n```\n\n**Sanitization:**\n\n- Use `.transform()` from Zod to sanitize data\n- Normalize emails to lowercase\n- Normalize usernames to lowercase\n- Clean whitespace\n\n### XSS Prevention\n\nReact prevents XSS automatically by escaping all values.\n\n**Never use `dangerouslySetInnerHTML` with user data:**\n\n```typescript\n// ❌ NEVER do this with user data\n<div dangerouslySetInnerHTML={{ __html: userContent }} />\n\n// ✅ React escapa automáticamente\n<div>{userContent}</div>\n```\n\n### Secure Headers\n\nEpic Stack uses Helmet for secure headers.\n\n**Configuration:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Included headers:**\n\n- X-Content-Type-Options: nosniff\n- X-Frame-Options: DENY\n- X-XSS-Protection: 1; mode=block\n- Referrer-Policy (configurable)\n\n### HTTPS Only\n\n**Redirect HTTP to HTTPS:**\n\n```typescript\napp.use((req, res, next) => {\n\tif (req.method !== 'GET') return next()\n\tconst proto = req.get('X-Forwarded-Proto')\n\tconst host = getHost(req)\n\tif (proto === 'http') {\n\t\tres.set('X-Forwarded-Proto', 'https')\n\t\tres.redirect(`https://${host}${req.originalUrl}`)\n\t\treturn\n\t}\n\tnext()\n})\n```\n\n### Secrets Management\n\n**Variables de entorno:**\n\n```bash\n# .env\nSESSION_SECRET=secret1,secret2,secret3 # Secret rotation\nHONEYPOT_SECRET=your-honeypot-secret\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**En Fly.io:**\n\n```bash\nfly secrets set SESSION_SECRET=\"secret1,secret2,secret3\"\nfly secrets set HONEYPOT_SECRET=\"your-secret\"\n```\n\n**Never commit secrets:**\n\n- Use `.env.example` to document required variables\n- `.env` is in `.gitignore`\n- Use `fly secrets` for production\n\n### Validación de Session Expiration (Fail Fast)\n\n**Always verify expiration early:**\n\n```typescript\nexport async function getUserId(request: Request) {\n\tconst authSession = await authSessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst sessionId = authSession.get(sessionKey)\n\n\t// Fail fast - return null immediately if no session\n\tif (!sessionId) return null\n\n\t// Verify expiration early - fail fast if expired\n\tconst session = await prisma.session.findUnique({\n\t\tselect: { userId: true },\n\t\twhere: {\n\t\t\tid: sessionId,\n\t\t\texpirationDate: { gt: new Date() }, // Verify expiration\n\t\t},\n\t})\n\n\t// Fail fast - destroy invalid session immediately\n\tif (!session?.userId) {\n\t\tthrow redirect('/', {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.destroySession(authSession),\n\t\t\t},\n\t\t})\n\t}\n\treturn session.userId\n}\n```\n\n## Common examples\n\n### Example 1: Public form with honeypot\n\n```typescript\n// app/routes/_auth/signup.tsx\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Spam protection\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// ... rest of code\n}\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<HoneypotInputs /> {/* Include in form */}\n\t\t\t{/* Rest of fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Custom rate limiting\n\n```typescript\n// server/index.ts\nconst apiRateLimit = rateLimit({\n\t...rateLimitDefault,\n\twindowMs: 60 * 1000,\n\tlimit: 100, // 100 requests per minute for API\n\tkeyGenerator: (req) => {\n\t\tconst apiKey = req.get('X-API-Key')\n\t\treturn apiKey ?? req.get('fly-client-ip') ?? req.ip\n\t},\n})\n\napp.use('/api', apiRateLimit)\n```\n\n### Example 3: Strict input validation\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const UsernameSchema = z\n\t.string({ required_error: 'Username is required' })\n\t.min(3, { message: 'Username is too short' })\n\t.max(20, { message: 'Username is too long' })\n\t.regex(/^[a-zA-Z0-9_]+$/, {\n\t\tmessage: 'Username can only include letters, numbers, and underscores',\n\t})\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long', // bcrypt limit\n\t})\n```\n\n### Example 4: Permission verification before actions\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Validate that user has permission\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Only after validating permissions\n\tawait prisma.note.delete({ where: { id: noteId } })\n\n\treturn redirect('/notes')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed security checks**: Validate authentication, authorization, and\n  input as early as possible - fail fast\n- ❌ **Generic error messages**: Provide clear, actionable error messages that\n  help with debugging (without exposing sensitive data)\n- ❌ **Forgetting honeypot in public forms**: Always include `HoneypotInputs` in\n  forms accessible without authentication\n- ❌ **Not validating session expiration**: Always verify `expirationDate` when\n  getting sessions - check early\n- ❌ **Using `dangerouslySetInnerHTML` with user data**: Never render user HTML\n  without sanitizing\n- ❌ **Not using rate limiting**: Protect sensitive routes with rate limiting\n- ❌ **Secrets in code**: Never hardcode secrets, use environment variables\n- ❌ **Not sanitizing inputs**: Always sanitize inputs with `.transform()` from\n  Zod\n- ❌ **Not validating common passwords**: Check passwords against Have I Been\n  Pwned\n- ❌ **Sessions without httpOnly**: Always use `httpOnly: true` in session\n  cookies\n- ❌ **Not using HTTPS in production**: Make sure to redirect HTTP to HTTPS\n- ❌ **CSP too permissive**: Review and adjust CSP according to your needs\n- ❌ **Not logging security events**: Log security failures with context for\n  debugging (without sensitive data)\n\n## References\n\n- [Epic Stack Security Docs](../epic-stack/docs/security.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [OWASP Top 10](https://owasp.org/www-project-top-ten/)\n- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)\n- `app/utils/honeypot.server.ts` - Honeypot implementation\n- `server/index.ts` - Server security configuration\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n- `docs/decisions/025-rate-limiting.md` - Rate limiting documentation\n- `docs/decisions/008-content-security-policy.md` - CSP documentation\n","docs/skills/epic-testing/SKILL.md":"---\nname: epic-testing\ndescription: Guide on testing with Vitest and Playwright for Epic Stack\ncategories:\n  - testing\n  - vitest\n  - playwright\n  - e2e\n---\n\n# Epic Stack: Testing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write unit tests for utilities and components\n- Create E2E tests with Playwright\n- Test forms and validation\n- Test routes and loaders\n- Mock external services with MSW\n- Test authentication and permissions\n- Configure test database\n\n## Patterns and conventions\n\n### Testing Philosophy\n\nFollowing Epic Web principles:\n\n**Tests should resemble users** - Write tests that mirror how real users\ninteract with your application. Test user workflows, not implementation details.\nIf a user would click a button, your test should click that button. If a user\nwould see an error message, your test should check for that specific message.\n\n**Make assertions specific** - Be explicit about what you're testing. Instead of\nvague assertions, use specific, meaningful checks that clearly communicate the\nexpected behavior. This makes tests easier to understand and debug when they\nfail.\n\n**Example - Tests that resemble users:**\n\n```typescript\n// ✅ Good - Tests user workflow\ntest('User can sign up and create their first note', async ({ page, navigate }) => {\n\t// User visits signup page\n\tawait navigate('/signup')\n\n\t// User fills out form like a real person would\n\tawait page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com')\n\tawait page.getByRole('textbox', { name: /username/i }).fill('newuser')\n\tawait page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123')\n\tawait page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123')\n\n\t// User submits form\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// User is redirected to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n\n\t// User creates their first note\n\tawait navigate('/notes/new')\n\tawait page.getByRole('textbox', { name: /title/i }).fill('My First Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('This is my first note!')\n\tawait page.getByRole('button', { name: /create/i }).click()\n\n\t// User sees their note\n\tawait expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible()\n\tawait expect(page.getByText('This is my first note!')).toBeVisible()\n})\n\n// ❌ Avoid - Testing implementation details\ntest('Signup form calls API endpoint', async ({ page }) => {\n\t// This tests implementation, not user experience\n\tconst response = await page.request.post('/signup', { data: {...} })\n\texpect(response.status()).toBe(200)\n})\n```\n\n**Example - Specific assertions:**\n\n```typescript\n// ✅ Good - Specific assertions\ntest('Form shows specific validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Specific error messages that users would see\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/username must be at least 3 characters/i),\n\t).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/password must be at least 6 characters/i),\n\t).toBeVisible()\n})\n\n// ❌ Avoid - Vague assertions\ntest('Form shows errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Too vague - what errors? where?\n\texpect(page.locator('.error')).toBeVisible()\n})\n```\n\n### Two Types of Tests\n\nEpic Stack uses two types of tests:\n\n1. **Unit Tests with Vitest** - Tests for individual components and utilities\n2. **E2E Tests with Playwright** - End-to-end tests of the complete flow\n\n### Unit Tests with Vitest\n\n**Basic setup:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { myUtil } from './my-util.ts'\n\ndescribe('myUtil', () => {\n\tit('should do something', () => {\n\t\texpect(myUtil('input')).toBe('expected')\n\t})\n})\n```\n\n**Testing con DOM:**\n\n```typescript\nimport { describe, expect, it } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { MyComponent } from './my-component.tsx'\n\ndescribe('MyComponent', () => {\n\tit('should render correctly', () => {\n\t\trender(<MyComponent />)\n\t\texpect(screen.getByText('Hello')).toBeInTheDocument()\n\t})\n})\n```\n\n### E2E Tests with Playwright\n\n**Basic setup:**\n\n```typescript\n// tests/e2e/my-feature.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\n\ntest('Users can do something', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/my-page')\n\n\t// Interact with the page\n\tawait page.getByRole('button', { name: /Submit/i }).click()\n\n\t// Verificar resultado\n\tawait expect(page).toHaveURL('/success')\n})\n```\n\n### Login Fixture\n\nEpic Stack provides a `login` fixture for authenticated tests.\n\n**Use login fixture:**\n\n```typescript\ntest('Protected route', async ({ page, navigate, login }) => {\n\tconst user = await login() // Creates user and session automatically\n\tawait navigate('/protected')\n\n\t// User is authenticated\n\tawait expect(page.getByText(`Welcome ${user.username}`)).toBeVisible()\n})\n```\n\n**Login with options:**\n\n```typescript\nconst user = await login({\n\tusername: 'testuser',\n\temail: 'test@example.com',\n\tpassword: 'password123',\n})\n```\n\n**Note:** The user is automatically deleted when the test completes.\n\n### Insert User without Login\n\nTo create user without authentication:\n\n```typescript\ntest('Public content', async ({ page, navigate, insertNewUser }) => {\n\tconst user = await insertNewUser({\n\t\tusername: 'publicuser',\n\t\temail: 'public@example.com',\n\t})\n\n\tawait navigate(`/users/${user.username}`)\n\tawait expect(page.getByText(user.username)).toBeVisible()\n})\n```\n\n### Navigate Helper\n\nUse the `navigate` helper to navigate with type-safety:\n\n```typescript\n// Type-safe navigation\nawait navigate('/users/:username/notes', { username: user.username })\nawait navigate('/users/:username/notes/:noteId', {\n\tusername: user.username,\n\tnoteId: note.id,\n})\n\n// Also works with routes without parameters\nawait navigate('/login')\n```\n\n### Test Database\n\nEpic Stack uses a separate test database.\n\n**Automatic configuration:**\n\n- The test database is configured automatically\n- It's cleaned between tests\n- Data created in tests is automatically deleted\n\n**Create data in tests:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n\ntest('User can see notes', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\n\t// Create note in database\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test Content',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: user.username,\n\t\tnoteId: note.id,\n\t})\n\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### MSW (Mock Service Worker)\n\nEpic Stack uses MSW to mock external services.\n\n**Mock example:**\n\n```typescript\n// tests/mocks/github.ts\nimport { http, HttpResponse } from 'msw'\n\nexport const handlers = [\n\thttp.get('https://api.github.com/user', () => {\n\t\treturn HttpResponse.json({\n\t\t\tid: '123',\n\t\t\tlogin: 'testuser',\n\t\t\temail: 'test@example.com',\n\t\t})\n\t}),\n]\n```\n\n**Use in tests:** Mocks are automatically applied when `MOCKS=true` is\nconfigured.\n\n### Testing Forms\n\n**Test form:**\n\n```typescript\ntest('User can submit form', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/notes/new')\n\n\t// Fill form\n\tawait page.getByRole('textbox', { name: /title/i }).fill('New Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Note content')\n\n\t// Submit\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar redirect\n\tawait expect(page).toHaveURL(new RegExp('/users/.*/notes/.*'))\n})\n```\n\n**Test validation:**\n\n```typescript\ntest('Form shows validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit sin llenar\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar errores\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n})\n```\n\n### Testing Loaders\n\n**Test loader:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { loader } from '../routes/my-route.ts'\nimport { prisma } from '../utils/db.server.ts'\n\ndescribe('loader', () => {\n\tit('should load data', async () => {\n\t\t// Create data\n\t\tconst user = await prisma.user.create({\n\t\t\tdata: {\n\t\t\t\temail: 'test@example.com',\n\t\t\t\tusername: 'testuser',\n\t\t\t\troles: { connect: { name: 'user' } },\n\t\t\t},\n\t\t})\n\n\t\t// Mock request\n\t\tconst request = new Request('http://localhost/my-route')\n\n\t\t// Execute loader\n\t\tconst result = await loader({ request, params: {}, context: {} })\n\n\t\t// Verify result\n\t\texpect(result.data).toBeDefined()\n\t})\n})\n```\n\n### Testing Actions\n\n**Test action:**\n\n```typescript\n// tests/e2e/notes.test.ts\ntest('User can create note', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\n\tconst formData = new FormData()\n\tformData.set('title', 'Test Note')\n\tformData.set('content', 'Test Content')\n\n\tawait page.getByRole('textbox', { name: /title/i }).fill('Test Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Test Content')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify that note was created\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### Testing Permissions\n\n**Test permissions:**\n\n```typescript\ntest('Only owner can delete note', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\tconst owner = await login()\n\tconst otherUser = await insertNewUser()\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test',\n\t\t\townerId: owner.id,\n\t\t},\n\t})\n\n\t// Login as other user\n\tconst session = await createSession(otherUser.id)\n\tawait page.context().addCookies([getCookie(session)])\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: owner.username,\n\t\tnoteId: note.id,\n\t})\n\n\t// Verify that can't delete\n\tawait expect(page.getByRole('button', { name: /delete/i })).not.toBeVisible()\n})\n```\n\n### DB Helpers\n\n**Create user:**\n\n```typescript\nimport { createUser } from '#tests/db-utils.ts'\n\nconst userData = createUser() // Generates unique random data\n```\n\n**Create password:**\n\n```typescript\nimport { createPassword } from '#tests/db-utils.ts'\n\nconst password = createPassword('mypassword') // { hash: '...' }\n```\n\n### Wait For Helper\n\nTo wait for async conditions:\n\n```typescript\nimport { waitFor } from '#tests/playwright-utils.ts'\n\nawait waitFor(\n\tasync () => {\n\t\tconst element = await page.getByText('Content loaded').first()\n\t\texpect(element).toBeVisible()\n\t\treturn element\n\t},\n\t{ timeout: 5000, errorMessage: 'Content never loaded' },\n)\n```\n\n### Testing GitHub OAuth\n\n**Prepare GitHub user:**\n\n```typescript\ntest('User can login with GitHub', async ({\n\tpage,\n\tnavigate,\n\tprepareGitHubUser,\n}) => {\n\tconst ghUser = await prepareGitHubUser()\n\n\tawait navigate('/login')\n\tawait page.getByRole('link', { name: /github/i }).click()\n\n\t// GitHub user is automatically prepared\n\tawait expect(page).toHaveURL('/onboarding/github')\n})\n```\n\n## Common examples\n\n### Example 1: Complete E2E test (resembling user workflow)\n\n```typescript\n// tests/e2e/notes.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { faker } from '@faker-js/faker'\n\ntest('Users can create, edit, and delete notes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n}) => {\n\t// User logs in (realistic workflow)\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\t// User creates a new note (clicking link, filling form, submitting)\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\tconst newNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(newNote.title)\n\tawait page.getByRole('textbox', { name: /content/i }).fill(newNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees their note with correct title and content\n\tawait expect(page.getByRole('heading', { name: newNote.title })).toBeVisible()\n\tawait expect(page.getByText(newNote.content)).toBeVisible()\n\tconst noteUrl = page.url()\n\tconst noteId = noteUrl.split('/').pop()\n\n\t// User edits the note (clicking edit, updating fields, saving)\n\tawait page.getByRole('link', { name: /edit/i }).click()\n\tconst updatedNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title)\n\tawait page\n\t\t.getByRole('textbox', { name: /content/i })\n\t\t.fill(updatedNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees updated content\n\tawait expect(\n\t\tpage.getByRole('heading', { name: updatedNote.title }),\n\t).toBeVisible()\n\tawait expect(page.getByText(updatedNote.content)).toBeVisible()\n\n\t// User deletes the note (clicking delete button)\n\tawait page.getByRole('button', { name: /delete/i }).click()\n\n\t// Specific assertion: user is redirected back to notes list\n\tawait expect(page).toHaveURL(`/users/${user.username}/notes`)\n\tawait expect(page.getByText(updatedNote.title)).not.toBeVisible()\n})\n```\n\n### Example 2: Unit test for utility\n\n```typescript\n// app/utils/misc.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { cn } from './misc.tsx'\n\ndescribe('cn', () => {\n\tit('should merge class names', () => {\n\t\texpect(cn('foo', 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', undefined, 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')\n\t})\n})\n```\n\n### Example 3: Form validation test\n\n```typescript\n// tests/e2e/signup.test.ts\ntest('Signup form validation', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit without filling\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify errors\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\n\t// Fill invalid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('invalid')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify email error\n\tawait expect(page.getByText(/email is invalid/i)).toBeVisible()\n\n\t// Fill valid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('test@example.com')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify redirect to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n})\n```\n\n### Example 4: Permissions test\n\n```typescript\n// tests/e2e/permissions.test.ts\ntest('Only admin can access admin routes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\t// Test with normal user\n\tconst normalUser = await login()\n\n\tawait navigate('/admin/users')\n\n\t// Should redirect or show error\n\tawait expect(page).toHaveURL('/') // Or verify error message\n\n\t// Test with admin\n\tawait page.context().clearCookies()\n\tconst admin = await insertNewUser()\n\tawait prisma.user.update({\n\t\twhere: { id: admin.id },\n\t\tdata: {\n\t\t\troles: {\n\t\t\t\tconnect: { name: 'admin' },\n\t\t\t},\n\t\t},\n\t})\n\n\t// Login as admin\n\tconst adminSession = await createSession(admin.id)\n\tawait page.context().addCookies([getCookie(adminSession)])\n\n\tawait navigate('/admin/users')\n\n\t// Now should work\n\tawait expect(page.getByText('All Users')).toBeVisible()\n})\n```\n\n## Common mistakes to avoid\n\n- ❌ **Testing implementation details instead of user workflows**: Write tests\n  that mirror how users actually use your app\n- ❌ **Vague assertions**: Use specific, meaningful assertions that clearly\n  communicate expected behavior\n- ❌ **Not cleaning data after tests**: Epic Stack cleans automatically, but\n  make sure not to depend on data between tests\n- ❌ **Assuming execution order**: Tests must be independent\n- ❌ **Not using fixtures**: Use `login`, `insertNewUser`, etc. instead of\n  creating everything manually\n- ❌ **Hardcoding data**: Use `faker` to generate unique data\n- ❌ **Not waiting for elements**: Use `expect` with `toBeVisible()` instead of\n  assuming it exists\n- ❌ **Not using type-safe navigation**: Use `navigate` helper instead of\n  `page.goto()` directly\n- ❌ **Forgetting MSW in tests**: External services are automatically mocked\n  when `MOCKS=true`\n- ❌ **Not testing error cases**: Test both happy path and errors\n- ❌ **Testing internal state instead of user-visible behavior**: Focus on what\n  users see and do\n\n## References\n\n- [Epic Stack Testing Docs](../epic-stack/docs/testing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Vitest Documentation](https://vitest.dev/)\n- [Playwright Documentation](https://playwright.dev/)\n- [Testing Library](https://testing-library.com/)\n- [MSW](https://mswjs.io/)\n- `tests/playwright-utils.ts` - Playwright fixtures and helpers\n- `tests/db-utils.ts` - DB helpers for tests\n- `tests/e2e/` - E2E test examples\n- `app/utils/*.test.ts` - Unit test examples\n","docs/skills/epic-ui-guidelines/SKILL.md":"---\nname: epic-ui-guidelines\ndescription:\n  Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack\ncategories:\n  - ui\n  - accessibility\n  - tailwind\n  - radix-ui\n---\n\n# Epic Stack: UI Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create accessible UI components\n- Follow Epic Stack design patterns\n- Use Tailwind CSS effectively\n- Implement semantic HTML\n- Add ARIA attributes correctly\n- Create responsive layouts\n- Ensure proper form accessibility\n- Follow Epic Stack's UI component conventions\n\n## Patterns and conventions\n\n### UI Philosophy\n\nFollowing Epic Web principles:\n\n**Software is built for people, by people** - Accessibility isn't about checking\nboxes or meeting standards. It's about creating software that works for real\npeople with diverse needs, abilities, and contexts. Every UI decision should\nprioritize the human experience over technical convenience.\n\nAccessibility is not optional - it's how we ensure our software serves all\nusers, not just some. When you make UI accessible, you're making it better for\neveryone: clearer labels help all users, keyboard navigation helps power users,\nand semantic HTML helps search engines.\n\n**Example - Human-centered approach:**\n\n```typescript\n// ✅ Good - Built for people\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<Field\n\t\t\t\tlabelProps={{\n\t\t\t\t\thtmlFor: fields.title.id,\n\t\t\t\t\tchildren: 'Note Title', // Clear, human-readable label\n\t\t\t\t}}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.title),\n\t\t\t\t\tplaceholder: 'Enter a descriptive title', // Helpful guidance\n\t\t\t\t\tautoFocus: true, // Saves time for users\n\t\t\t\t}}\n\t\t\t\terrors={fields.title.errors} // Clear error messages\n\t\t\t/>\n\t\t</Form>\n\t)\n}\n\n// ❌ Avoid - Technical convenience over user experience\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<input name=\"title\" /> {/* No label, no guidance, no accessibility */}\n\t\t</Form>\n\t)\n}\n```\n\n### Semantic HTML\n\n**✅ Good - Use semantic elements:**\n\n```typescript\nfunction UserCard({ user }: { user: User }) {\n\treturn (\n\t\t<article>\n\t\t\t<header>\n\t\t\t\t<h2>{user.name}</h2>\n\t\t\t</header>\n\t\t\t<p>{user.bio}</p>\n\t\t\t<footer>\n\t\t\t\t<time dateTime={user.createdAt}>{formatDate(user.createdAt)}</time>\n\t\t\t</footer>\n\t\t</article>\n\t)\n}\n```\n\n**❌ Avoid - Generic divs:**\n\n```typescript\n// ❌ Don't use divs for everything\n<div>\n\t<div>{user.name}</div>\n\t<div>{user.bio}</div>\n\t<div>{formatDate(user.createdAt)}</div>\n</div>\n```\n\n### Form Accessibility\n\n**✅ Good - Always use labels:**\n\n```typescript\nimport { Field } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\nThe `Field` component automatically:\n\n- Associates labels with inputs using `htmlFor` and `id`\n- Adds `aria-invalid` when there are errors\n- Adds `aria-describedby` pointing to error messages\n- Ensures proper error announcement\n\n**❌ Avoid - Unlabeled inputs:**\n\n```typescript\n// ❌ Don't forget labels\n<input type=\"email\" name=\"email\" />\n```\n\n### ARIA Attributes\n\n**✅ Good - Use ARIA appropriately:**\n\n```typescript\n// Epic Stack's Field component handles this automatically\n<Field\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t// aria-invalid and aria-describedby are added automatically\n\t}}\n\terrors={fields.email.errors} // Error messages are linked via aria-describedby\n/>\n```\n\n**✅ Good - ARIA for custom components:**\n\n```typescript\nfunction LoadingButton({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) {\n\treturn (\n\t\t<button aria-busy={isLoading} disabled={isLoading}>\n\t\t\t{isLoading ? 'Loading...' : children}\n\t\t</button>\n\t)\n}\n```\n\n### Using Radix UI Components\n\nEpic Stack uses Radix UI for accessible, unstyled components.\n\n**✅ Good - Use Radix primitives:**\n\n```typescript\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button } from '#app/components/ui/button.tsx'\n\nfunction MyDialog() {\n\treturn (\n\t\t<Dialog.Root>\n\t\t\t<Dialog.Trigger asChild>\n\t\t\t\t<Button>Open Dialog</Button>\n\t\t\t</Dialog.Trigger>\n\t\t\t<Dialog.Portal>\n\t\t\t\t<Dialog.Overlay className=\"fixed inset-0 bg-black/50\" />\n\t\t\t\t<Dialog.Content className=\"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6\">\n\t\t\t\t\t<Dialog.Title>Dialog Title</Dialog.Title>\n\t\t\t\t\t<Dialog.Description>Dialog description</Dialog.Description>\n\t\t\t\t\t<Dialog.Close asChild>\n\t\t\t\t\t\t<Button>Close</Button>\n\t\t\t\t\t</Dialog.Close>\n\t\t\t\t</Dialog.Content>\n\t\t\t</Dialog.Portal>\n\t\t</Dialog.Root>\n\t)\n}\n```\n\nRadix components automatically handle:\n\n- Keyboard navigation\n- Focus management\n- ARIA attributes\n- Screen reader announcements\n\n### Tailwind CSS Patterns\n\n**✅ Good - Use Tailwind utility classes:**\n\n```typescript\nfunction Card({ children }: { children: React.ReactNode }) {\n\treturn (\n\t\t<div className=\"rounded-lg border border-gray-200 bg-white p-6 shadow-sm\">\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Tailwind responsive utilities:**\n\n```typescript\n<div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">\n\t{items.map(item => (\n\t\t<Card key={item.id}>{item.name}</Card>\n\t))}\n</div>\n```\n\n**✅ Good - Use Tailwind dark mode:**\n\n```typescript\n<div className=\"bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n### Error Handling in Forms\n\n**✅ Good - Display errors accessibly:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\tinputProps={getInputProps(fields.email, { type: 'email' })}\n\terrors={fields.email.errors} // Errors are displayed below input\n/>\n\n<ErrorList errors={form.errors} id={form.errorId} /> // Form-level errors\n```\n\nErrors are automatically:\n\n- Associated with inputs via `aria-describedby`\n- Announced to screen readers\n- Visually distinct with error styling\n\n### Focus Management\n\n**✅ Good - Visible focus indicators:**\n\n```typescript\n// Tailwind's default focus:ring handles this\n<button className=\"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Focus on form errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus() {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\tfirstErrorRef.current.focus()\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Keyboard Navigation\n\n**✅ Good - Keyboard accessible components:**\n\n```typescript\n// Radix components handle keyboard navigation automatically\n<Dialog.Trigger asChild>\n\t<Button>Open</Button>\n</Dialog.Trigger>\n\n// Custom components should support keyboard\n<button\n\tonKeyDown={(e) => {\n\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\thandleClick()\n\t\t}\n\t}}\n>\n\tCustom Button\n</button>\n```\n\n### Color Contrast\n\n**✅ Good - Use accessible color combinations:**\n\n```typescript\n// Use Tailwind's semantic colors that meet WCAG AA\n<div className=\"bg-white text-gray-900\"> // High contrast\n<div className=\"text-blue-600 hover:text-blue-700\"> // Accessible links\n```\n\n**❌ Avoid - Low contrast text:**\n\n```typescript\n// ❌ Don't use low contrast\n<div className=\"bg-gray-100 text-gray-200\"> // Very low contrast\n```\n\n### Responsive Design\n\n**✅ Good - Mobile-first approach:**\n\n```typescript\n<div className=\"\n\tflex flex-col gap-4\n\tmd:flex-row md:gap-8\n\tlg:gap-12\n\">\n\t{/* Content */}\n</div>\n```\n\n**✅ Good - Responsive typography:**\n\n```typescript\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">\n\tResponsive Heading\n</h1>\n```\n\n### Loading States\n\n**✅ Good - Accessible loading indicators:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SubmitButton() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"submit\"\n\t\t\tdisabled={isSubmitting}\n\t\t\taria-busy={isSubmitting}\n\t\t>\n\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t</button>\n\t)\n}\n```\n\n### Icon Usage\n\n**✅ Good - Decorative icons:**\n\n```typescript\nimport { Icon } from '#app/components/ui/icon.tsx'\n\n<button aria-label=\"Delete note\">\n\t<Icon name=\"trash\" />\n\t<span className=\"sr-only\">Delete note</span>\n</button>\n```\n\n**✅ Good - Semantic icons:**\n\n```typescript\n<button>\n\t<Icon name=\"check\" aria-hidden=\"true\" />\n\tSave\n</button>\n```\n\n### Skip Links\n\n**✅ Good - Add skip to main content:**\n\n```typescript\n// In your root layout\n<a href=\"#main-content\" className=\"sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white\">\n\tSkip to main content\n</a>\n\n<main id=\"main-content\">\n\t{/* Main content */}\n</main>\n```\n\n### Progressive Enhancement\n\n**✅ Good - Forms work without JavaScript:**\n\n```typescript\n// Conform forms work without JavaScript\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<Field {...props} />\n\t<StatusButton type=\"submit\">Submit</StatusButton>\n</Form>\n```\n\nForms automatically:\n\n- Submit via native HTML forms if JavaScript is disabled\n- Validate server-side\n- Show errors appropriately\n\n### Screen Reader Best Practices\n\n**✅ Good - Use semantic HTML first:**\n\n```typescript\n// ✅ Semantic HTML provides context automatically\n<nav aria-label=\"Main navigation\">\n\t<ul>\n\t\t<li><a href=\"/\">Home</a></li>\n\t\t<li><a href=\"/about\">About</a></li>\n\t</ul>\n</nav>\n```\n\n**✅ Good - Announce dynamic content:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SearchResults({ results }: { results: Result[] }) {\n\tconst navigation = useNavigation()\n\tconst isSearching = navigation.state === 'loading'\n\n\treturn (\n\t\t<div\n\t\t\trole=\"status\"\n\t\t\taria-live=\"polite\"\n\t\t\taria-atomic=\"true\"\n\t\t\tclassName=\"sr-only\"\n\t\t>\n\t\t\t{isSearching ? 'Searching...' : `${results.length} results found`}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Live regions for important updates:**\n\n```typescript\nfunction ToastContainer({ toasts }: { toasts: Toast[] }) {\n\treturn (\n\t\t<div aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n\t\t\t{toasts.map(toast => (\n\t\t\t\t<div key={toast.id} role=\"alert\">\n\t\t\t\t\t{toast.message}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n**ARIA live region options:**\n\n- `aria-live=\"polite\"` - For non-critical updates (search results, status\n  messages)\n- `aria-live=\"assertive\"` - For critical updates (errors, confirmations)\n- `aria-atomic=\"true\"` - Screen reader reads entire region on update\n- `aria-atomic=\"false\"` - Screen reader reads only changed parts\n\n### Keyboard Navigation Patterns\n\n**✅ Good - Tab order follows visual order:**\n\n```typescript\n// Elements appear in logical tab order\n<nav>\n\t<a href=\"/\">Home</a>\n\t<a href=\"/about\">About</a>\n\t<a href=\"/contact\">Contact</a>\n</nav>\n```\n\n**✅ Good - Keyboard shortcuts:**\n\n```typescript\nimport { useEffect } from 'react'\n\nfunction SearchDialog({ onClose }: { onClose: () => void }) {\n\tuseEffect(() => {\n\t\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tonClose()\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [onClose])\n\n\treturn <Dialog>{/* content */}</Dialog>\n}\n```\n\n**✅ Good - Focus trap in modals:**\n\n```typescript\n// Radix Dialog automatically handles focus trap\n<Dialog.Root>\n\t<Dialog.Content>\n\t\t{/* Focus is trapped inside dialog */}\n\t\t<Dialog.Close>Close</Dialog.Close>\n\t</Dialog.Content>\n</Dialog.Root>\n```\n\n### Focus Management for React Router\n\n**✅ Good - Focus on route changes:**\n\n```typescript\nimport { useEffect } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction RouteComponent() {\n\tconst navigation = useNavigation()\n\tconst mainRef = useRef<HTMLElement>(null)\n\n\tuseEffect(() => {\n\t\tif (navigation.state === 'idle' && mainRef.current) {\n\t\t\tmainRef.current.focus()\n\t\t}\n\t}, [navigation.state])\n\n\treturn (\n\t\t<main ref={mainRef} tabIndex={-1}>\n\t\t\t{/* Content */}\n\t\t</main>\n\t)\n}\n```\n\n**✅ Good - Focus on errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus({ actionData }: Route.ComponentProps) {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\t// Focus first error field\n\t\t\tfirstErrorRef.current.focus()\n\t\t\t// Announce error\n\t\t\tfirstErrorRef.current.setAttribute('aria-invalid', 'true')\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Typography and Readability\n\n**✅ Good - Readable text sizes:**\n\n```typescript\n// Use Tailwind's text size scale\n<p className=\"text-base md:text-lg\">Readable body text</p>\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">Clear headings</h1>\n```\n\n**✅ Good - Sufficient line height:**\n\n```typescript\n// Tailwind defaults provide good line height\n<p className=\"leading-relaxed\">Comfortable reading</p>\n```\n\n**❌ Avoid - Small or hard-to-read text:**\n\n```typescript\n// ❌ Don't use very small text\n<p className=\"text-xs\">Hard to read</p>\n```\n\n### Touch Target Sizes\n\n**✅ Good - Sufficient touch targets:**\n\n```typescript\n// Buttons should be at least 44x44px (touch target size)\n<button className=\"min-h-[44px] min-w-[44px] px-4 py-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Spacing between interactive elements:**\n\n```typescript\n<div className=\"flex gap-4\">\n\t<Button>Save</Button>\n\t<Button>Cancel</Button>\n</div>\n```\n\n### Internationalization (i18n) Considerations\n\n**✅ Good - Use semantic HTML for dates/times:**\n\n```typescript\n<time dateTime={note.createdAt.toISOString()}>\n\t{formatDate(note.createdAt)}\n</time>\n```\n\n**✅ Good - Use semantic HTML for numbers:**\n\n```typescript\n// Screen readers can pronounce numbers correctly\n<p>Total: <span aria-label={`${count} items`}>{count}</span></p>\n```\n\n**✅ Good - Language attributes:**\n\n```typescript\n// In root.tsx\n<html lang=\"en\">\n\t<body>\n\t\t{/* Content */}\n\t</body>\n</html>\n```\n\n### Dark Mode Accessibility\n\n**✅ Good - Maintain contrast in dark mode:**\n\n```typescript\n// Ensure sufficient contrast in both modes\n<div className=\"bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n**✅ Good - Respect user preference:**\n\n```typescript\n// Epic Stack automatically handles theme preference\n// Use semantic colors that work in both modes\n<button className=\"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600\">\n\tButton\n</button>\n```\n\n### Animation and Motion\n\n**✅ Good - Respect reduced motion:**\n\n```typescript\n// Tailwind automatically respects prefers-reduced-motion\n<div className=\"transition-transform duration-200 hover:scale-105 motion-reduce:transition-none\">\n\t{/* Animations disabled for users who prefer reduced motion */}\n</div>\n```\n\n**✅ Good - Use CSS for animations:**\n\n```typescript\n// ✅ CSS animations can be disabled via prefers-reduced-motion\n<div className=\"animate-fade-in\">\n\t{/* Content */}\n</div>\n\n// ❌ JavaScript animations may not respect user preferences\n```\n\n## Common mistakes to avoid\n\n- ❌ **Treating accessibility as a checklist**: Accessibility is about serving\n  real people, not just meeting standards\n- ❌ **Missing form labels**: Always use `Field` component which includes\n  labels - helps all users, not just screen reader users\n- ❌ **Using divs for semantic elements**: Use `<article>`, `<header>`, `<nav>`,\n  etc. - helps all users understand content structure\n- ❌ **Ignoring keyboard navigation**: Ensure all interactive elements are\n  keyboard accessible - helps power users and those who can't use a mouse\n- ❌ **Low color contrast**: Test color combinations for WCAG AA compliance -\n  helps users with visual impairments and in bright sunlight\n- ❌ **Missing ARIA attributes**: Use Epic Stack components which handle this\n  automatically\n- ❌ **Breaking focus management**: Let Radix components handle focus\n- ❌ **Not testing with screen readers**: Test with VoiceOver, NVDA, or JAWS -\n  understand how real users experience your UI\n- ❌ **Hiding content from screen readers**: Use `sr-only` instead of\n  `display: none` for screen reader only content\n- ❌ **Ignoring mobile users**: Always test on mobile devices - many users only\n  have mobile access\n- ❌ **Not using Tailwind's responsive utilities**: Use mobile-first responsive\n  design\n- ❌ **Not using live regions**: Use `aria-live` for dynamic content\n  announcements\n- ❌ **Small touch targets**: Ensure interactive elements are at least 44x44px -\n  helps users with motor impairments and on mobile\n- ❌ **Ignoring reduced motion**: Respect `prefers-reduced-motion` media query -\n  helps users with vestibular disorders\n- ❌ **Poor focus indicators**: Ensure focus is always visible - helps keyboard\n  users navigate\n- ❌ **Missing skip links**: Add skip to main content links for keyboard users\n\n## References\n\n- [Web Content Accessibility Guidelines (WCAG)](https://www.w3.org/WAI/WCAG21/quickref/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Radix UI Documentation](https://www.radix-ui.com/)\n- [Tailwind CSS Documentation](https://tailwindcss.com/)\n- [Epic Stack Forms Documentation](../epic-forms/SKILL.md)\n- `app/components/forms.tsx` - Accessible form components\n- `app/components/ui/` - Accessible UI components using Radix\n- `app/styles/tailwind.css` - Tailwind configuration\n"},"items":[{"name":"routing.md","path":"docs/routing.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/routing.md","title":"docs - Multi-Agent Routing & Delegation Matrix","category":"root-instruction","format":"markdown","content":"# Routing\n\nThe Epic Stack uses file-based routing with React Router. However, it's not\nusing the built-in routing convention of React Router and instead is using\n[react-router-auto-routes](https://github.com/kenn/react-router-auto-routes)\nwhich is a special implementation of the React Router convention that adds a few\nfeatures. You'll find it configured for the application in the `app/routes.ts`\nfile at the root of the app.\n\nWe'll defer to the `react-router-auto-routes` documentation for specifics, but\nan important thing for you to know as you get used to this convention is you can\nalways run `npx react-router routes` from the root of the app and it will output\nthe routes of your application in a JSX-like output that will reveal the routes\nthat will be generated based on your current file structure. Here's an example\nof the Epic Stack routes at the time of this writing:\n\n```\napp/routes\n├── $.tsx\n├── me.tsx\n├── _auth\n│   ├── forgot-password.tsx\n│   ├── login.tsx\n│   ├── logout.tsx\n│   ├── reset-password.tsx\n│   ├── signup.tsx\n│   ├── verify.tsx\n│   ├── auth.$provider\n│   │   ├── callback.ts\n│   │   └── index.ts\n│   ├── onboarding\n│   │   ├── $provider.tsx\n│   │   └── index.tsx\n│   └── webauthn\n│       ├── authentication.ts\n│       └── registration.ts\n├── _marketing\n│   ├── about.tsx\n│   ├── index.tsx\n│   ├── privacy.tsx\n│   ├── support.tsx\n│   ├── tos.tsx\n│   └── +logos\n│       ├── logos.ts\n│       └── ...\n├── _seo\n│   ├── robots[.]txt.ts\n│   └── sitemap[.]xml.ts\n├── admin\n│   └── cache\n│       ├── index.tsx\n│       ├── lru.$cacheKey.ts\n│       ├── sqlite.$cacheKey.ts\n│       └── sqlite.tsx\n├── resources\n│   ├── download-user-data.tsx\n│   ├── healthcheck.tsx\n│   ├── images.tsx\n│   └── theme-switch.tsx\n├── settings\n│   └── profile\n│       ├── _layout.tsx\n│       ├── change-email.tsx\n│       ├── connections.tsx\n│       ├── index.tsx\n│       ├── passkeys.tsx\n│       ├── password.tsx\n│       ├── password_.create.tsx\n│       ├── photo.tsx\n│       └── two-factor\n│           ├── _layout.tsx\n│           ├── disable.tsx\n│           ├── index.tsx\n│           └── verify.tsx\n└── users\n    ├── index.tsx\n    └── $username\n        ├── index.tsx\n        └── notes\n            ├── $noteId.tsx\n            ├── $noteId_.edit.tsx\n            ├── _layout.tsx\n            ├── index.tsx\n            └── new.tsx\n\n17 directories, 72 files\n```\n\n```tsx\n<Routes>\n\t<Route file=\"root.tsx\">\n\t\t<Route path=\"*\" file=\"routes/$.tsx\" />\n\t\t<Route\n\t\t\tpath=\"auth/:provider/callback\"\n\t\t\tfile=\"routes/_auth/auth.$provider/callback.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"auth/:provider\"\n\t\t\tindex\n\t\t\tfile=\"routes/_auth/auth.$provider/index.ts\"\n\t\t/>\n\t\t<Route path=\"forgot-password\" file=\"routes/_auth/forgot-password.tsx\" />\n\t\t<Route path=\"login\" file=\"routes/_auth/login.tsx\" />\n\t\t<Route path=\"logout\" file=\"routes/_auth/logout.tsx\" />\n\t\t<Route\n\t\t\tpath=\"onboarding/:provider\"\n\t\t\tfile=\"routes/_auth/onboarding/$provider.tsx\"\n\t\t/>\n\t\t<Route path=\"onboarding\" index file=\"routes/_auth/onboarding/index.tsx\" />\n\t\t<Route path=\"reset-password\" file=\"routes/_auth/reset-password.tsx\" />\n\t\t<Route path=\"signup\" file=\"routes/_auth/signup.tsx\" />\n\t\t<Route path=\"verify\" file=\"routes/_auth/verify.tsx\" />\n\t\t<Route\n\t\t\tpath=\"webauthn/authentication\"\n\t\t\tfile=\"routes/_auth/webauthn/authentication.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"webauthn/registration\"\n\t\t\tfile=\"routes/_auth/webauthn/registration.ts\"\n\t\t/>\n\t\t<Route path=\"about\" file=\"routes/_marketing/about.tsx\" />\n\t\t<Route index file=\"routes/_marketing/index.tsx\" />\n\t\t<Route path=\"privacy\" file=\"routes/_marketing/privacy.tsx\" />\n\t\t<Route path=\"support\" file=\"routes/_marketing/support.tsx\" />\n\t\t<Route path=\"tos\" file=\"routes/_marketing/tos.tsx\" />\n\t\t<Route path=\"robots.txt\" file=\"routes/_seo/robots[.]txt.ts\" />\n\t\t<Route path=\"sitemap.xml\" file=\"routes/_seo/sitemap[.]xml.ts\" />\n\t\t<Route path=\"admin/cache\" index file=\"routes/admin/cache/index.tsx\" />\n\t\t<Route\n\t\t\tpath=\"admin/cache/lru/:cacheKey\"\n\t\t\tfile=\"routes/admin/cache/lru.$cacheKey.ts\"\n\t\t/>\n\t\t<Route path=\"admin/cache/sqlite\" file=\"routes/admin/cache/sqlite.tsx\">\n\t\t\t<Route path=\":cacheKey\" file=\"routes/admin/cache/sqlite.$cacheKey.ts\" />\n\t\t</Route>\n\t\t<Route path=\"me\" file=\"routes/me.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/download-user-data\"\n\t\t\tfile=\"routes/resources/download-user-data.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"resources/healthcheck\"\n\t\t\tfile=\"routes/resources/healthcheck.tsx\"\n\t\t/>\n\t\t<Route path=\"resources/images\" file=\"routes/resources/images.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/theme-switch\"\n\t\t\tfile=\"routes/resources/theme-switch.tsx\"\n\t\t/>\n\t\t<Route path=\"settings/profile\" file=\"routes/settings/profile/_layout.tsx\">\n\t\t\t<Route\n\t\t\t\tpath=\"change-email\"\n\t\t\t\tfile=\"routes/settings/profile/change-email.tsx\"\n\t\t\t/>\n\t\t\t<Route\n\t\t\t\tpath=\"connections\"\n\t\t\t\tfile=\"routes/settings/profile/connections.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/settings/profile/index.tsx\" />\n\t\t\t<Route path=\"passkeys\" file=\"routes/settings/profile/passkeys.tsx\" />\n\t\t\t<Route path=\"password\" file=\"routes/settings/profile/password.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"password/create\"\n\t\t\t\tfile=\"routes/settings/profile/password_.create.tsx\"\n\t\t\t/>\n\t\t\t<Route path=\"photo\" file=\"routes/settings/profile/photo.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"two-factor\"\n\t\t\t\tfile=\"routes/settings/profile/two-factor/_layout.tsx\"\n\t\t\t>\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"disable\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/disable.tsx\"\n\t\t\t\t/>\n\t\t\t\t<Route index file=\"routes/settings/profile/two-factor/index.tsx\" />\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"verify\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/verify.tsx\"\n\t\t\t\t/>\n\t\t\t</Route>\n\t\t</Route>\n\t\t<Route\n\t\t\tpath=\"users/:username\"\n\t\t\tindex\n\t\t\tfile=\"routes/users/$username/index.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"users/:username/notes\"\n\t\t\tfile=\"routes/users/$username/notes/_layout.tsx\"\n\t\t>\n\t\t\t<Route path=\":noteId\" file=\"routes/users/$username/notes/$noteId.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\":noteId/edit\"\n\t\t\t\tfile=\"routes/users/$username/notes/$noteId_.edit.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/users/$username/notes/index.tsx\" />\n\t\t\t<Route path=\"new\" file=\"routes/users/$username/notes/new.tsx\" />\n\t\t</Route>\n\t\t<Route path=\"users\" index file=\"routes/users/index.tsx\" />\n\t</Route>\n</Routes>\n```\n\nBasically, react-router-auto-routes allows us to get the best of both worlds:\n\n- Colocation of routes to the code they use\n- Organized folder structure to keep routes together as needed\n\nIf you're familiar with the React Router routing convention, just think of it\nthis way, react-router-auto-routes takes `+` prefix for colocated modules.\n","isInternal":false,"tokens":1847,"sizeBytes":6967},{"name":"avoid-use-effect.mdc","path":".cursor/rules/avoid-use-effect.mdc","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/.cursor/rules/avoid-use-effect.mdc","title":"Cursor Rule: avoid-use-effect","category":"cursor-rule","format":"mdc","content":"---\ndescription: \nglobs: *.tsx,*.jsx\nalwaysApply: false\n---\n### Avoid useEffect\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use ref callbacks, event handlers with\n`flushSync`, css, `useSyncExternalStore`, etc.\n\n```tsx\n// This example was ripped from the docs:\n// ✅ Good\nfunction ProductPage({ product, addToCart }) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n\nuseEffect(() => {\n\tsetCount(count + 1)\n}, [count])\n\n// ❌ Avoid\nfunction ProductPage({ product, addToCart }) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\taddToCart(product)\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n```\n\nThere are a lot more examples in the docs. `useEffect` is not banned or\nanything. There are just better ways to handle most cases.\n\nHere's an example of a situation where `useEffect` is appropriate:\n\n```tsx\n// ✅ Good\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\n\t\t\t// do something based on escape key being pressed\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n","isInternal":false,"tokens":411,"sizeBytes":1578},{"name":"SKILL.md","path":"docs/skills/epic-auth/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-auth/SKILL.md","title":"epic-auth","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-auth\ndescription:\n  Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack\ncategories:\n  - authentication\n  - sessions\n  - oauth\n  - security\n---\n\n# Epic Stack: Authentication\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement user authentication\n- Work with sessions and cookies\n- Configure OAuth providers (GitHub, Google, etc.)\n- Implement 2FA (Two-Factor Authentication)\n- Implement WebAuthn/Passkeys\n- Handle login, signup, logout flows\n- Manage email verification\n- Implement password reset\n\n## Patterns and conventions\n\n### Authentication Philosophy\n\nFollowing Epic Web principles:\n\n**Least privilege** - Users should only have access to what they need, when they\nneed it. Sessions should have minimal permissions and expire appropriately.\nDon't grant more access than necessary.\n\n**Design to fail fast and early** - Validate authentication and authorization as\nearly as possible. Check session validity immediately, verify permissions before\nprocessing requests, and return clear errors quickly.\n\n**Example - Least privilege in sessions:**\n\n```typescript\n// ✅ Good - Minimal session data, explicit permissions\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId, // Only store user ID, not full user data\n\t},\n})\n\n// Session only grants access to this specific user\n// Permissions checked separately when needed\n\n// ❌ Avoid - Storing too much in session\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t\tuserRole: 'admin', // Don't store roles in session\n\t\tpermissions: ['all'], // Don't store permissions in session\n\t},\n})\n// Roles and permissions should be checked from database, not session\n```\n\n**Example - Fail fast authentication:**\n\n```typescript\n// ✅ Good - Validate authentication early\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check authentication immediately - fail fast\n\tconst userId = await requireUserId(request)\n\n\t// Check permissions early - fail fast\n\tawait requireUserWithPermission(request, 'read:note:own')\n\n\t// Only proceed if authenticated and authorized\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: userId },\n\t})\n\n\treturn { notes }\n}\n\n// ❌ Avoid - Delayed authentication check\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Process request first...\n\tconst notes = await prisma.note.findMany()\n\n\t// Check authentication at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed request\n\t\tthrow redirect('/login')\n\t}\n}\n```\n\n### Cookie-based Sessions\n\nEpic Stack uses cookie-based sessions for authentication. Sessions are stored in\nthe database and identified by signed cookies.\n\n**Session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nimport { createCookieSessionStorage } from 'react-router'\n\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true,\n\t\tsecrets: process.env.SESSION_SECRET.split(','),\n\t\tsecure: process.env.NODE_ENV === 'production',\n\t},\n})\n```\n\n### Get current user\n\n**Server-side:**\n\n```typescript\nimport { getUserId, requireUserId } from '#app/utils/auth.server.ts'\n\n// Get userId or null if not authenticated\nconst userId = await getUserId(request)\n\n// Require authenticated user (redirects to /login if not)\nconst userId = await requireUserId(request)\nconst userId = await requireUserId(request, { redirectTo: '/custom-login' })\n\n// Require that user is NOT authenticated\nimport { requireAnonymous } from '#app/utils/auth.server.ts'\nawait requireAnonymous(request) // Redirects to / if authenticated\n```\n\n**Client-side:**\n\n```typescript\nimport { useOptionalUser, useUser } from '#app/utils/user.ts'\n\n// Get user or undefined if not authenticated\nconst user = useOptionalUser()\n\n// Get authenticated user (throws error if not)\nconst user = useUser()\n```\n\n### Login with Email/Password\n\n**Validation schema:**\n\n```typescript\nconst LoginSchema = z.object({\n\tusername: UsernameSchema,\n\tpassword: z.string().min(1, 'Password is required'),\n\tredirectTo: z.string().optional(),\n\tremember: z.boolean().optional(),\n})\n```\n\n**Login action (fail fast):**\n\n```typescript\nimport { login } from '#app/utils/auth.server.ts'\nimport { handleNewSession } from './login.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate input early - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\t// Authenticate early - fail fast if invalid\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\t// Return error immediately - don't process further\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Only create session if authentication succeeded\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Signup with Email/Password\n\n**Signup action:**\n\n```typescript\nimport { signup } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate form...\n\n\tconst session = await signup({\n\t\temail,\n\t\tusername,\n\t\tpassword,\n\t\tname,\n\t})\n\n\t// Handle session and redirect...\n}\n```\n\n### OAuth Providers (GitHub, Google, etc.)\n\nEpic Stack uses `remix-auth` for OAuth providers.\n\n**Configure provider (GitHub example):**\n\n```typescript\n// app/utils/providers/github.server.ts\nimport { GitHubStrategy } from 'remix-auth-github'\n\nexport class GitHubProvider implements AuthProvider {\n\tgetAuthStrategy() {\n\t\treturn new GitHubStrategy(\n\t\t\t{\n\t\t\t\tclientID: process.env.GITHUB_CLIENT_ID,\n\t\t\t\tclientSecret: process.env.GITHUB_CLIENT_SECRET,\n\t\t\t\tcallbackURL: '/auth/github/callback',\n\t\t\t},\n\t\t\tasync ({ profile }) => {\n\t\t\t\t// Return user profile\n\t\t\t\treturn {\n\t\t\t\t\tid: profile.id,\n\t\t\t\t\temail: profile.emails[0].value,\n\t\t\t\t\tusername: profile.displayName,\n\t\t\t\t\tname: profile.displayName,\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\t}\n}\n```\n\n**Callback handler:**\n\n```typescript\n// app/routes/_auth/auth.$provider/callback.ts\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst providerName = ProviderNameSchema.parse(params.provider)\n\tconst authResult = await authenticator.authenticate(providerName, request)\n\n\tif (!authResult.success) {\n\t\tthrow redirectWithToast('/login', {\n\t\t\ttitle: 'Auth Failed',\n\t\t\tdescription: `Error authenticating with ${providerName}`,\n\t\t\ttype: 'error',\n\t\t})\n\t}\n\n\tconst { data: profile } = authResult\n\n\t// Check if connection exists\n\tconst existingConnection = await prisma.connection.findUnique({\n\t\twhere: {\n\t\t\tproviderName_providerId: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t},\n\t\t},\n\t})\n\n\t// If exists, create session\n\tif (existingConnection) {\n\t\treturn makeSession({ request, userId: existingConnection.userId })\n\t}\n\n\t// If email exists, link account\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { email: profile.email.toLowerCase() },\n\t})\n\tif (user) {\n\t\tawait prisma.connection.create({\n\t\t\tdata: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t\tuserId: user.id,\n\t\t\t},\n\t\t})\n\t\treturn makeSession({ request, userId: user.id })\n\t}\n\n\t// New user, go to onboarding\n\t// ...\n}\n```\n\n### WebAuthn/Passkeys\n\nEpic Stack supports authentication with passkeys using WebAuthn.\n\n**Loader to generate options:**\n\n```typescript\n// app/routes/_auth/webauthn/authentication.ts\nimport { generateAuthenticationOptions } from '@simplewebauthn/server'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst config = getWebAuthnConfig(request)\n\tconst options = await generateAuthenticationOptions({\n\t\trpID: config.rpID,\n\t\tuserVerification: 'preferred',\n\t})\n\n\tconst cookieHeader = await passkeyCookie.serialize({\n\t\tchallenge: options.challenge,\n\t})\n\n\treturn Response.json(\n\t\t{ options },\n\t\t{\n\t\t\theaders: { 'Set-Cookie': cookieHeader },\n\t\t},\n\t)\n}\n```\n\n**Action to verify authentication:**\n\n```typescript\nimport { verifyAuthenticationResponse } from '@simplewebauthn/server'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst cookie = await passkeyCookie.parse(request.headers.get('Cookie'))\n\n\tif (!cookie?.challenge) {\n\t\tthrow new Error('Authentication challenge not found')\n\t}\n\n\tconst { authResponse } = await request.json()\n\tconst passkey = await prisma.passkey.findUnique({\n\t\twhere: { id: authResponse.id },\n\t\tinclude: { user: true },\n\t})\n\n\tconst verification = await verifyAuthenticationResponse({\n\t\tresponse: authResponse,\n\t\texpectedChallenge: cookie.challenge,\n\t\texpectedOrigin: config.origin,\n\t\texpectedRPID: config.rpID,\n\t\tcredential: {\n\t\t\tid: authResponse.id,\n\t\t\tpublicKey: passkey.publicKey,\n\t\t\tcounter: Number(passkey.counter),\n\t\t},\n\t})\n\n\tif (!verification.verified) {\n\t\tthrow new Error('Authentication verification failed')\n\t}\n\n\t// Actualizar counter\n\tawait prisma.passkey.update({\n\t\twhere: { id: passkey.id },\n\t\tdata: { counter: BigInt(verification.authenticationInfo.newCounter) },\n\t})\n\n\t// Create sesión\n\tconst session = await prisma.session.create({\n\t\tdata: {\n\t\t\texpirationDate: getSessionExpirationDate(),\n\t\t\tuserId: passkey.userId,\n\t\t},\n\t})\n\n\treturn handleNewSession({ request, session, remember: true })\n}\n```\n\n### Two-Factor Authentication (2FA) with TOTP\n\nEpic Stack uses TOTP (Time-based One-Time Password) para 2FA.\n\n**Check if user has 2FA:**\n\n```typescript\nconst verification = await prisma.verification.findUnique({\n\twhere: {\n\t\ttarget_type: {\n\t\t\ttarget: session.userId,\n\t\t\ttype: twoFAVerificationType,\n\t\t},\n\t},\n})\nconst userHasTwoFactor = Boolean(verification)\n```\n\n**Handle session with 2FA:**\n\n```typescript\nexport async function handleNewSession({\n\trequest,\n\tsession,\n\tredirectTo,\n\tremember,\n}: {\n\trequest: Request\n\tsession: { userId: string; id: string; expirationDate: Date }\n\tredirectTo?: string\n\tremember: boolean\n}) {\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: {\n\t\t\ttarget_type: {\n\t\t\t\ttarget: session.userId,\n\t\t\t\ttype: twoFAVerificationType,\n\t\t\t},\n\t\t},\n\t})\n\tconst userHasTwoFactor = Boolean(verification)\n\n\tif (userHasTwoFactor) {\n\t\t// Save unverified session\n\t\tconst verifySession = await verifySessionStorage.getSession()\n\t\tverifySession.set(unverifiedSessionIdKey, session.id)\n\t\tverifySession.set(rememberKey, remember)\n\n\t\t// Redirect to 2FA verification\n\t\tconst redirectUrl = getRedirectToUrl({\n\t\t\trequest,\n\t\t\ttype: twoFAVerificationType,\n\t\t\ttarget: session.userId,\n\t\t\tredirectTo,\n\t\t})\n\t\treturn redirect(redirectUrl.toString(), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),\n\t\t\t},\n\t\t})\n\t} else {\n\t\t// User without 2FA, create session directly\n\t\tconst authSession = await authSessionStorage.getSession(\n\t\t\trequest.headers.get('cookie'),\n\t\t)\n\t\tauthSession.set(sessionKey, session.id)\n\t\treturn redirect(safeRedirect(redirectTo), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.commitSession(authSession, {\n\t\t\t\t\texpires: remember ? session.expirationDate : undefined,\n\t\t\t\t}),\n\t\t\t},\n\t\t})\n\t}\n}\n```\n\n**Verify 2FA code:**\n\n```typescript\nimport { prepareTOTP, verifyTOTP } from '@epic-web/totp'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: VerifySchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { code } = submission.value\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: { target_type: { target, type } },\n\t\tselect: {\n\t\t\tsecret: true,\n\t\t\talgorithm: true,\n\t\t\tperiod: true,\n\t\t\tdigits: true,\n\t\t},\n\t})\n\n\tif (!verification) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst isValid = verifyTOTP({\n\t\totp: code,\n\t\tsecret: verification.secret,\n\t\talgorithm: verification.algorithm as any,\n\t\tperiod: verification.period,\n\t\tdigits: verification.digits,\n\t})\n\n\tif (!isValid) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid code'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Verify session and complete login\n\treturn handleVerification({ request, submission })\n}\n```\n\n### Email Verification\n\nEpic Stack uses TOTP codes sent via email for verification.\n\n**Prepare verification:**\n\n```typescript\nimport { prepareVerification } from './verify.server.ts'\n\nconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\tperiod: 10 * 60, // 10 minutes\n\trequest,\n\ttype: 'onboarding',\n\ttarget: email,\n})\n\n// Enviar email con código y URL\nawait sendEmail({\n\tto: email,\n\tsubject: 'Welcome!',\n\treact: <VerificationEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n})\n\nreturn redirect(redirectTo.toString())\n```\n\n**Verify code:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/signup')\n\t}\n\n\treturn { target, type }\n}\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code (similar to 2FA)\n\t// ...\n}\n```\n\n### Password Reset\n\n**Request reset:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ForgotPasswordSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { email } = submission.value\n\n\t// Prepare verification\n\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\t\tperiod: 10 * 60,\n\t\trequest,\n\t\ttype: 'reset-password',\n\t\ttarget: email,\n\t})\n\n\t// Enviar email con código y URL\n\tawait sendEmail({\n\t\tto: email,\n\t\tsubject: 'Reset your password',\n\t\treact: <ResetPasswordEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n\t})\n\n\treturn redirect(redirectTo.toString())\n}\n```\n\n**Reset password:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code first (similar to email verification)\n\t// Then reset password\n\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ResetPasswordSchema,\n\t})\n\n\t// Verify that code is valid\n\t// ...\n\n\tconst { password } = submission.value\n\n\tawait resetUserPassword({\n\t\tusername,\n\t\tpassword,\n\t})\n\n\t// Destroy verification session and redirect to login\n\t// ...\n}\n```\n\n### Logout\n\n```typescript\nimport { logout } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\treturn logout({ request, redirectTo: '/' })\n}\n```\n\n### Session Management\n\n**Create session:**\n\n```typescript\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t},\n\tselect: { id: true, expirationDate: true },\n})\n```\n\n**Session expiration:**\n\n```typescript\nexport const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport const getSessionExpirationDate = () =>\n\tnew Date(Date.now() + SESSION_EXPIRATION_TIME)\n```\n\n**Destroy session:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { id: sessionId } })\n```\n\n**Destroy all user sessions:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { userId } })\n```\n\n## Common examples\n\n### Example 1: Complete login\n\n```typescript\n// app/routes/_auth/login.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Example 2: Protect route\n\n```typescript\n// app/routes/protected.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function ProtectedRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Datos protegidos */}</div>\n}\n```\n\n### Example 3: Route only for unauthenticated users\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireAnonymous(request)\n\treturn null\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed authentication checks**: Validate authentication and\n  authorization as early as possible - fail fast\n- ❌ **Granting excessive privileges**: Follow least privilege - only grant\n  access to what's needed, when it's needed\n- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user\n  ID), check permissions from database\n- ❌ **Not verifying session on each request**: Always use `getUserId` or\n  `requireUserId` in protected loaders/actions\n- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating\n  session\n- ❌ **Not destroying expired sessions**: Sessions must be verified against\n  `expirationDate` - check early\n- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and\n  cookie creation\n- ❌ **Forgetting to handle `remember`**: Make sure to respect user preference\n- ❌ **Not validating OAuth callbacks**: Always validate that provider exists\n  and result is successful - fail fast\n- ❌ **Not linking OAuth accounts**: If email exists, link the account instead\n  of creating duplicate\n- ❌ **Not updating counter in passkeys**: Always update counter after\n  successful verification\n\n## References\n\n- [Epic Stack Authentication Docs](../epic-stack/docs/authentication.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Remix Auth](https://github.com/sergiodxa/remix-auth)\n- [SimpleWebAuthn](https://simplewebauthn.dev/)\n- `app/utils/auth.server.ts` - Authentication utilities\n- `app/utils/session.server.ts` - Gestión de sesiones\n- `app/routes/_auth/login.server.ts` - Helpers de login\n- `app/routes/_auth/auth.$provider/callback.ts` - OAuth callback\n- `app/routes/_auth/webauthn/` - WebAuthn implementation\n","frontmatter":{"name":"epic-auth","description":"Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack","categories":["authentication","sessions","oauth","security"]},"isInternal":false,"tokens":4463,"sizeBytes":19066},{"name":"SKILL.md","path":"docs/skills/epic-caching/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-caching/SKILL.md","title":"epic-caching","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-caching\ndescription:\n  Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack\ncategories:\n  - caching\n  - performance\n  - optimization\n---\n\n# Epic Stack: Caching\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Cache results of expensive queries\n- Cache responses from external APIs\n- Optimize performance of data that doesn't change frequently\n- Implement stale-while-revalidate\n- Manage cache invalidation\n- Integrate cache with server timing\n\n## Patterns and conventions\n\n### Caching Philosophy\n\nFollowing Epic Web principles:\n\n**Weigh the cost-benefit of performance optimizations** - Caching adds\ncomplexity. Only add cache when there's a clear, measurable benefit. Don't cache\n\"just in case\" - cache when you have a real performance problem that caching\nsolves.\n\n**When NOT to use cache:**\n\n- Data that changes frequently (cache invalidation becomes a problem)\n- Data that's already fast to fetch (no measurable benefit)\n- Data that's only fetched once (no benefit from caching)\n- Simple queries that don't need optimization\n- When cache invalidation logic becomes more complex than the problem it solves\n\n**Example - Evaluating cost-benefit:**\n\n```typescript\n// ✅ Good - Cache expensive external API call\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Expensive: External API call, rate limits, network latency\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour - reasonable for external data\n\t})\n}\n\n// ❌ Avoid - Caching simple, fast database query\nexport async function getUser({ userId }: { userId: string }) {\n\t// This query is already fast - caching adds complexity without benefit\n\treturn await cachified({\n\t\tkey: `user:${userId}`,\n\t\tcache,\n\t\tgetFreshValue: async () => {\n\t\t\t// Simple query, already fast\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: { id: true, username: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5,\n\t})\n\t// Better: Just query directly without cache\n}\n```\n\n### Two Types of Cache\n\nEpic Stack provides two types of cache:\n\n1. **SQLite Cache** - Long-lived, replicated with LiteFS\n   - Persistent across restarts\n   - Replicated across all instances\n   - Ideal for data that changes infrequently\n\n2. **LRU Cache** - Short-lived, in-memory\n   - Cleared on restart\n   - Not replicated (only on current instance)\n   - Ideal for deduplication and temporary cache\n\n### Using cachified\n\nEpic Stack uses `@epic-web/cachified` as an abstraction for cache management.\n\n**Basic import:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\n```\n\n**Basic structure:**\n\n```typescript\nexport async function getCachedData({\n\ttimings,\n}: {\n\ttimings?: Timings\n} = {}) {\n\treturn await cachified({\n\t\tkey: 'my-cache-key',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Get fresh data\n\t\t\treturn await fetchDataFromAPI()\n\t\t},\n\t\tcheckValue: z.object({\n\t\t\t/* schema */\n\t\t}), // Validation with Zod\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days\n\t})\n}\n```\n\n### Cache Keys\n\n**Naming conventions:**\n\n- Use format: `entity:identifier:data`\n- Examples:\n  - `user:${userId}:profile`\n  - `note:${noteId}:full`\n  - `api:github:events`\n  - `tito:scheduled-events`\n\n**Avoid:**\n\n- Keys that are too long\n- Keys with special characters\n- Keys that don't clearly identify the content\n\n### TTL (Time To Live)\n\n**Define TTL:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours in milliseconds\n})\n```\n\n**Null TTL to never expire:**\n\n```typescript\nttl: null, // Never expires (not recommended unless necessary)\n```\n\n### Stale-While-Revalidate (SWR)\n\nSWR allows returning stale data while fresh data is fetched in the background.\n\n**Example:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours - after this it's considered stale\n\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days - up to here returns stale while revalidating\n})\n```\n\n**Behavior:**\n\n- **Less than 24h**: Returns cache, no request made\n- **24h - 30 days**: Returns stale cache immediately, updates in background\n- **More than 30 days**: Waits for fresh data before returning\n\n### Validation with Zod\n\nAlways validate cached data with Zod:\n\n```typescript\nimport { z } from 'zod'\n\nconst EventSchema = z.object({\n\tid: z.string(),\n\ttitle: z.string(),\n\tdate: z.string(),\n})\n\nexport async function getEvents({ timings }: { timings?: Timings } = {}) {\n\treturn await cachified({\n\t\tkey: 'events:all',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch('https://api.example.com/events')\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: EventSchema.array(), // Validates it's an array of events\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\nIf cached data doesn't pass validation, fresh data is fetched.\n\n### Server Timing Integration\n\nIntegrate cache with server timing for monitoring:\n\n```typescript\nimport { type Timings } from '#app/utils/timing.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\tconst events = await getEvents({ timings })\n\n\t// Timings are automatically added to headers\n\treturn json(\n\t\t{ events },\n\t\t{\n\t\t\theaders: combineServerTimings(timings),\n\t\t},\n\t)\n}\n```\n\n### Cache Invalidation\n\n**Invalidate by key:**\n\n```typescript\nimport { cache } from '#app/utils/cache.server.ts'\n\nawait cache.delete('user:123:profile')\n```\n\n**Invalidate multiple keys:**\n\n```typescript\n// Search and delete matching keys\nimport { searchCacheKeys } from '#app/utils/cache.server.ts'\n\nconst keys = await searchCacheKeys('user:123', 100)\nawait Promise.all(keys.map((key) => cache.delete(key)))\n```\n\n**Invalidate entire SQLite cache:**\n\n```typescript\n// Use admin dashboard or\nawait cache.clear() // If available\n```\n\n### Using LRU Cache\n\nFor temporary data, use LRU cache directly:\n\n```typescript\nimport { lru } from '#app/utils/cache.server.ts'\n\n// LRU cache is useful for:\n// - Request deduplication\n// - Very temporary cache (< 5 minutes)\n// - Data that doesn't need to persist\n\nconst cachedValue = lru.get('temp-key')\nif (!cachedValue) {\n\tconst freshValue = await computeExpensiveValue()\n\tlru.set('temp-key', freshValue, { ttl: 1000 * 60 * 5 }) // 5 minutes\n\treturn freshValue\n}\nreturn cachedValue\n```\n\n### Multi-Region Cache\n\nWith LiteFS, SQLite cache is automatically replicated:\n\n**Behavior:**\n\n- Only the primary instance writes to cache\n- Replicas can read from cache\n- Writes are automatically synchronized\n\n**Best practices:**\n\n- Don't assume all writes are immediate\n- Use `ensurePrimary()` if you need to guarantee writes\n\n```typescript\nimport { ensurePrimary } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tawait ensurePrimary() // Ensure we're on primary instance\n\n\t// Invalidate cache\n\tawait cache.delete('my-key')\n\n\t// ...\n}\n```\n\n### Error Handling\n\n**Handle errors in getFreshValue:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: async () => {\n\t\ttry {\n\t\t\treturn await fetchData()\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to fetch fresh data:', error)\n\t\t\tthrow error // Re-throw so cachified handles it\n\t\t}\n\t},\n\t// If getFreshValue fails and there's stale cache, it returns it\n\tfallbackToCache: true, // Default: true\n})\n```\n\n### Cache Admin Dashboard\n\nEpic Stack includes a dashboard to manage cache:\n\n**Route:** `/admin/cache`\n\n**Features:**\n\n- View all cache keys\n- Search keys\n- View details of a key\n- Delete keys\n- Clear entire cache\n\n## Common examples\n\n### Example 1: Cache external API response\n\n```typescript\n// app/utils/api.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\nimport { z } from 'zod'\n\nconst GitHubEventSchema = z.object({\n\tid: z.string(),\n\ttype: z.string(),\n\tactor: z.object({\n\t\tlogin: z.string(),\n\t}),\n\tcreated_at: z.string(),\n})\n\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`GitHub API error: ${response.statusText}`)\n\t\t\t}\n\t\t\tconst data = await response.json()\n\t\t\treturn data\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\n### Example 2: Cache Prisma query\n\n```typescript\n// app/utils/user.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { z } from 'zod'\n\nconst UserStatsSchema = z.object({\n\ttotalNotes: z.number(),\n\ttotalLikes: z.number(),\n\tjoinDate: z.string(),\n})\n\nexport async function getUserStats({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `user:${userId}:stats`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst [totalNotes, totalLikes, user] = await Promise.all([\n\t\t\t\tprisma.note.count({ where: { ownerId: userId } }),\n\t\t\t\tprisma.like.count({ where: { userId } }),\n\t\t\t\tprisma.user.findUnique({\n\t\t\t\t\twhere: { id: userId },\n\t\t\t\t\tselect: { createdAt: true },\n\t\t\t\t}),\n\t\t\t])\n\n\t\t\treturn {\n\t\t\t\ttotalNotes,\n\t\t\t\ttotalLikes,\n\t\t\t\tjoinDate: user?.createdAt.toISOString() ?? '',\n\t\t\t}\n\t\t},\n\t\tcheckValue: UserStatsSchema,\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t\tstaleWhileRevalidate: 1000 * 60 * 60, // 1 hour\n\t})\n}\n```\n\n### Example 3: Invalidate cache after mutation\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// ... validate and create note\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle,\n\t\t\tcontent,\n\t\t\townerId: userId,\n\t\t},\n\t\tinclude: { owner: true },\n\t})\n\n\t// Invalidate related cache\n\tawait Promise.all([\n\t\tcache.delete(`user:${userId}:notes`),\n\t\tcache.delete(`user:${userId}:stats`),\n\t\tcache.delete(`note:${note.id}:full`),\n\t])\n\n\treturn redirect(`/users/${note.owner.username}/notes/${note.id}`)\n}\n```\n\n### Example 4: Cache with dependencies\n\n```typescript\nexport async function getUserWithNotes({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\tconst user = await cachified({\n\t\tkey: `user:${userId}:profile`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\tusername: true,\n\t\t\t\t\tname: true,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t\tcheckValue: z\n\t\t\t.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tusername: z.string(),\n\t\t\t\tname: z.string().nullable(),\n\t\t\t})\n\t\t\t.nullable(),\n\t\tttl: 1000 * 60 * 30, // 30 minutes\n\t})\n\n\tconst notes = await cachified({\n\t\tkey: `user:${userId}:notes`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.note.findMany({\n\t\t\t\twhere: { ownerId: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\ttitle: true,\n\t\t\t\t\tupdatedAt: true,\n\t\t\t\t},\n\t\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\t})\n\t\t},\n\t\tcheckValue: z.array(\n\t\t\tz.object({\n\t\t\t\tid: z.string(),\n\t\t\t\ttitle: z.string(),\n\t\t\t\tupdatedAt: z.date(),\n\t\t\t}),\n\t\t),\n\t\tttl: 1000 * 60 * 10, // 10 minutes\n\t})\n\n\treturn { user, notes }\n}\n```\n\n### Example 5: Use LRU for deduplication\n\n```typescript\n// Avoid multiple simultaneous requests to the same URL\nconst requestCache = new Map<string, Promise<any>>()\n\nexport async function fetchWithDedup(url: string) {\n\tif (requestCache.has(url)) {\n\t\treturn requestCache.get(url)\n\t}\n\n\tconst promise = fetch(url).then((res) => res.json())\n\trequestCache.set(url, promise)\n\n\t// Clean up after 1 second\n\tsetTimeout(() => {\n\t\trequestCache.delete(url)\n\t}, 1000)\n\n\treturn promise\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Caching without measuring benefit**: Only add cache when there's a clear,\n  measurable performance problem\n- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to\n  fetch - it adds complexity without benefit\n- ❌ **Caching frequently changing data**: Cache invalidation becomes more\n  complex than the problem it solves\n- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive\n  personal data\n- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely\n  necessary\n- ❌ **Not validating cached data**: Always use `checkValue` with Zod to\n  validate data\n- ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations\n- ❌ **Assuming cache always works**: Cache can fail, always handle errors\n- ❌ **Keys too long or ambiguous**: Use consistent and descriptive format\n- ❌ **Not using timings**: Integrate with server timing for monitoring\n- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when\n  appropriate\n- ❌ **Over-caching**: Too much caching makes the system harder to understand\n  and debug\n\n## References\n\n- [Epic Stack Caching Docs](../epic-stack/docs/caching.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [@epic-web/cachified](https://www.npmjs.com/package/@epic-web/cachified)\n- `app/utils/cache.server.ts` - Cache implementation\n- `app/routes/admin/cache/` - Admin dashboard\n- `app/utils/timing.server.ts` - Server timing utilities\n","frontmatter":{"name":"epic-caching","description":"Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack","categories":["caching","performance","optimization"]},"isInternal":false,"tokens":3625,"sizeBytes":13807},{"name":"SKILL.md","path":"docs/skills/epic-database/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-database/SKILL.md","title":"epic-database","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-database\ndescription: Guide on Prisma, SQLite, and LiteFS for Epic Stack\ncategories:\n  - database\n  - prisma\n  - sqlite\n  - litefs\n---\n\n# Epic Stack: Database\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Design database schema with Prisma\n- Create migrations\n- Work with SQLite and LiteFS\n- Optimize queries and performance\n- Create seed scripts\n- Work with multi-region deployments\n- Manage backups and restores\n\n## Patterns and conventions\n\n### Database Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Only fetch the data you actually need. Use\n`select` to fetch specific fields instead of entire models. Avoid over-fetching\ndata \"just in case\" - fetch what you need, when you need it.\n\n**Pragmatism over purity** - Optimize queries when there's a measurable benefit,\nbut don't over-optimize prematurely. Simple, readable queries are often better\nthan complex optimized ones. Add indexes when queries are slow, not before.\n\n**Example - Fetch only what you need:**\n\n```typescript\n// ✅ Good - Fetch only needed fields\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only fetch what you actually use\n\t},\n})\n\n// ❌ Avoid - Fetching everything\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches all fields including password hash, email, etc.\n})\n```\n\n**Example - Pragmatic optimization:**\n\n```typescript\n// ✅ Good - Simple query first, optimize if needed\nconst notes = await prisma.note.findMany({\n\twhere: { ownerId: userId },\n\tselect: { id: true, title: true, updatedAt: true },\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n})\n\n// Only add indexes if this query is actually slow\n// Don't pre-optimize\n\n// ❌ Avoid - Over-optimizing before measuring\n// Adding complex indexes, joins, etc. before knowing if it's needed\n```\n\n### Prisma Schema\n\nEpic Stack uses Prisma with SQLite as the database.\n\n**Basic configuration:**\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"typedSql\"]\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n```\n\n**Basic model:**\n\n```prisma\nmodel User {\n  id        String   @id @default(cuid())\n  email     String   @unique\n  username  String   @unique\n  name      String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  notes     Note[]\n  roles     Role[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  title   String\n  content String\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n  @@index([ownerId, updatedAt])\n}\n```\n\n### CUID2 for IDs\n\nEpic Stack uses CUID2 to generate unique IDs.\n\n**Advantages:**\n\n- Globally unique\n- Sortable\n- Secure (no exposed information)\n- URL-friendly\n\n**Example:**\n\n```prisma\nmodel User {\n  id String @id @default(cuid()) // Automatically generates CUID2\n}\n```\n\n### Timestamps\n\n**Standard fields:**\n\n```prisma\nmodel User {\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt // Automatically updated\n}\n```\n\n### Relationships\n\n**One-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n}\n```\n\n**One-to-One:**\n\n```prisma\nmodel User {\n  id      String  @id @default(cuid())\n  image   UserImage?\n}\n\nmodel UserImage {\n  id        String @id @default(cuid())\n  user      User   @relation(fields: [userId], references: [id])\n  userId    String @unique\n}\n```\n\n**Many-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  roles Role[]\n}\n\nmodel Role {\n  id    String @id @default(cuid())\n  users User[]\n}\n```\n\n### Indexes\n\n**Create indexes:**\n\n```prisma\nmodel Note {\n  id      String @id @default(cuid())\n  ownerId String\n  updatedAt DateTime\n\n  @@index([ownerId])              // Simple index\n  @@index([ownerId, updatedAt])   // Composite index\n}\n```\n\n**Best practices:**\n\n- Index foreign keys\n- Index fields used in `where` frequently\n- Index fields used in `orderBy`\n- Use composite indexes for complex queries\n\n### Cascade Delete\n\n**Configure cascade:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id], onDelete: Cascade)\n  ownerId String\n}\n```\n\n**Options:**\n\n- `onDelete: Cascade` - Deletes children when parent is deleted\n- `onDelete: SetNull` - Sets to null when parent is deleted\n- `onDelete: Restrict` - Prevents deletion if there are children\n\n### Migrations\n\n**Create migration:**\n\n```bash\nnpx prisma migrate dev --name add_user_field\n```\n\n**Apply migrations in production:**\n\n```bash\nnpx prisma migrate deploy\n```\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`.\n\n**\"Widen then Narrow\" strategy for zero-downtime:**\n\n1. **Widen app** - App accepts A or B\n2. **Widen db** - DB provides A and B, app writes to both\n3. **Narrow app** - App only uses B\n4. **Narrow db** - DB only provides B\n\n**Example: Rename field `name` to `firstName` and `lastName`:**\n\n```prisma\n// Step 1: Widen app (accepts both)\nmodel User {\n  id        String @id @default(cuid())\n  name      String?  // Deprecated\n  firstName String?  // New\n  lastName  String?  // New\n}\n\n// Step 2: Widen db (migration copies data)\n// In SQL migration:\nALTER TABLE User ADD COLUMN firstName TEXT;\nALTER TABLE User ADD COLUMN lastName TEXT;\nUPDATE User SET firstName = name;\n\n// Step 3: Narrow app (only uses new fields)\n// Code only uses firstName and lastName\n\n// Step 4: Narrow db (removes old field)\nALTER TABLE User DROP COLUMN name;\n```\n\n### Prisma Client\n\n**Import Prisma Client:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n```\n\n**Basic query:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n})\n```\n\n**Specific select:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\temail: true,\n\t\tusername: true,\n\t\t// Don't include password or sensitive data\n\t},\n})\n```\n\n**Include relations:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tinclude: {\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t},\n\t\troles: true,\n\t},\n})\n```\n\n**Complex queries:**\n\n```typescript\nconst notes = await prisma.note.findMany({\n\twhere: {\n\t\townerId: userId,\n\t\ttitle: { contains: searchTerm },\n\t},\n\tselect: {\n\t\tid: true,\n\t\ttitle: true,\n\t\tupdatedAt: true,\n\t},\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n\tskip: (page - 1) * 20,\n})\n```\n\n### Transactions\n\n**Use transactions:**\n\n```typescript\nawait prisma.$transaction(async (tx) => {\n\tconst user = await tx.user.create({\n\t\tdata: {\n\t\t\temail,\n\t\t\tusername,\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tawait tx.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Welcome',\n\t\t\tcontent: 'Welcome to the app!',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\treturn user\n})\n```\n\n### SQLite con LiteFS\n\n**Multi-region with LiteFS:**\n\n- Only the primary instance can write\n- Replicas can only read\n- Writes are automatically replicated\n\n**Check primary instance:**\n\n```typescript\nimport { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Ensure we're on primary instance for writes\n\tawait ensurePrimary()\n\n\t// Now we can write safely\n\tawait prisma.user.create({\n\t\tdata: {\n\t\t\t/* ... */\n\t\t},\n\t})\n}\n```\n\n**Get instance information:**\n\n```typescript\nimport { getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nconst { currentIsPrimary, primaryInstance } = await getInstanceInfo()\n\nif (currentIsPrimary) {\n\t// Can write\n} else {\n\t// Read-only, redirect to primary if necessary\n}\n```\n\n### Seed Scripts\n\n**Create seed:**\n\n```typescript\n// prisma/seed.ts\nimport { prisma } from '#app/utils/db.server.ts'\n\nasync function seed() {\n\t// Create roles\n\tawait prisma.role.createMany({\n\t\tdata: [\n\t\t\t{ name: 'user', description: 'Standard user' },\n\t\t\t{ name: 'admin', description: 'Administrator' },\n\t\t],\n\t})\n\n\t// Create users\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n\nseed()\n\t.catch((e) => {\n\t\tconsole.error(e)\n\t\tprocess.exit(1)\n\t})\n\t.finally(async () => {\n\t\tawait prisma.$disconnect()\n\t})\n```\n\n**Run seed:**\n\n```bash\nnpx prisma db seed\n# Or directly:\nnpx tsx prisma/seed.ts\n```\n\n### Query Optimization\n\n**Guidelines (pragmatic approach):**\n\n- Use `select` to fetch only needed fields - do as little as possible\n- Use selective `include` - only include relations you actually use\n- Index fields used in `where` and `orderBy` - but only if queries are slow\n- Use composite indexes for complex queries - when you have a real performance\n  problem\n- Avoid `select: true` (fetches everything) - be explicit about what you need\n- Measure first, optimize second - don't pre-optimize\n\n**Optimized example (do as little as possible):**\n\n```typescript\n// ❌ Avoid: Fetches everything unnecessarily\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches password hash, email, all relations, etc.\n})\n\n// ✅ Good: Only needed fields - do as little as possible\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only what you actually use\n\t},\n})\n\n// ✅ Better: With selective relations (only if you need them)\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\ttake: 10, // Only fetch what you need\n\t\t},\n\t},\n})\n```\n\n### Prisma Query Logging\n\n**Configure logging:**\n\n```typescript\n// app/utils/db.server.ts\nconst client = new PrismaClient({\n\tlog: [\n\t\t{ level: 'query', emit: 'event' },\n\t\t{ level: 'error', emit: 'stdout' },\n\t\t{ level: 'warn', emit: 'stdout' },\n\t],\n})\n\nclient.$on('query', async (e) => {\n\tif (e.duration < 20) return // Only log slow queries\n\n\tconsole.info(`prisma:query - ${e.duration}ms - ${e.query}`)\n})\n```\n\n### Database URL\n\n**Development:**\n\n```bash\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**Production (Fly.io):**\n\n```bash\nDATABASE_URL=file:/litefs/data/sqlite.db\n```\n\n### Connecting to DB in Production\n\n**SSH to Fly instance:**\n\n```bash\nfly ssh console --app [YOUR_APP_NAME]\n```\n\n**Connect to DB CLI:**\n\n```bash\nfly ssh console -C database-cli --app [YOUR_APP_NAME]\n```\n\n**Prisma Studio:**\n\n```bash\n# Terminal 1: Start Prisma Studio\nfly ssh console -C \"npx prisma studio\" -s --app [YOUR_APP_NAME]\n\n# Terminal 2: Local proxy\nfly proxy 5556:5555 --app [YOUR_APP_NAME]\n\n# Open in browser\n# http://localhost:5556\n```\n\n## Common examples\n\n### Example 1: Create model with relations\n\n```prisma\nmodel Post {\n  id        String   @id @default(cuid())\n  title     String\n  content   String\n  published Boolean  @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)\n  authorId String\n\n  comments Comment[]\n  tags     Tag[]\n\n  @@index([authorId])\n  @@index([authorId, published])\n  @@index([published, updatedAt])\n}\n\nmodel Comment {\n  id      String @id @default(cuid())\n  content String\n\n  createdAt DateTime @default(now())\n\n  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)\n  postId String\n\n  author   User   @relation(fields: [authorId], references: [id])\n  authorId String\n\n  @@index([postId])\n  @@index([authorId])\n}\n```\n\n### Example 2: Complex query with pagination\n\n```typescript\nexport async function getPosts({\n\tuserId,\n\tpage = 1,\n\tperPage = 20,\n\tpublished,\n}: {\n\tuserId?: string\n\tpage?: number\n\tperPage?: number\n\tpublished?: boolean\n}) {\n\tconst where: Prisma.PostWhereInput = {}\n\n\tif (userId) {\n\t\twhere.authorId = userId\n\t}\n\tif (published !== undefined) {\n\t\twhere.published = published\n\t}\n\n\tconst [posts, total] = await Promise.all([\n\t\tprisma.post.findMany({\n\t\t\twhere,\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t\tupdatedAt: true,\n\t\t\t\tauthor: {\n\t\t\t\t\tselect: {\n\t\t\t\t\t\tid: true,\n\t\t\t\t\t\tusername: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\ttake: perPage,\n\t\t\tskip: (page - 1) * perPage,\n\t\t}),\n\t\tprisma.post.count({ where }),\n\t])\n\n\treturn {\n\t\tposts,\n\t\ttotal,\n\t\tpages: Math.ceil(total / perPage),\n\t}\n}\n```\n\n### Example 3: Transaction with multiple operations\n\n```typescript\nexport async function createPostWithTags({\n\tauthorId,\n\ttitle,\n\tcontent,\n\ttagNames,\n}: {\n\tauthorId: string\n\ttitle: string\n\tcontent: string\n\ttagNames: string[]\n}) {\n\treturn await prisma.$transaction(async (tx) => {\n\t\t// Create tags if they don't exist\n\t\tawait Promise.all(\n\t\t\ttagNames.map((name) =>\n\t\t\t\ttx.tag.upsert({\n\t\t\t\t\twhere: { name },\n\t\t\t\t\tupdate: {},\n\t\t\t\t\tcreate: { name },\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\t// Create post\n\t\tconst post = await tx.post.create({\n\t\t\tdata: {\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\tauthorId,\n\t\t\t\ttags: {\n\t\t\t\t\tconnect: tagNames.map((name) => ({ name })),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\n\t\treturn post\n\t})\n}\n```\n\n### Example 4: Seed with related data\n\n```typescript\nasync function seed() {\n\t// Create permissions\n\tconst permissions = await Promise.all([\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'create',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can create own notes',\n\t\t\t},\n\t\t}),\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'read',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can read own notes',\n\t\t\t},\n\t\t}),\n\t])\n\n\t// Create roles with permissions\n\tconst userRole = await prisma.role.create({\n\t\tdata: {\n\t\t\tname: 'user',\n\t\t\tdescription: 'Standard user',\n\t\t\tpermissions: {\n\t\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t\t},\n\t\t},\n\t})\n\n\t// Create user with role\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: {\n\t\t\t\tconnect: { id: userRole.id },\n\t\t\t},\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need -\n  do as little as possible\n- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add\n  indexes \"just in case\"\n- ❌ **Not using indexes when needed**: Index foreign keys and fields used in\n  frequent queries, but only if they're actually slow\n- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when\n  you need them\n- ❌ **Not using transactions for related operations**: Always use transactions\n  when multiple operations must be atomic\n- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in\n  production\n- ❌ **Breaking migrations without strategy**: Use \"widen then narrow\" for\n  zero-downtime\n- ❌ **Not validating data before inserting**: Always validate with Zod before\n  create/update\n- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when\n  parent is deleted\n- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or\n  others\n- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but\n  ensure in scripts\n- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries\n  over complex optimized ones unless there's a real problem\n\n## References\n\n- [Epic Stack Database Docs](../epic-stack/docs/database.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Prisma Documentation](https://www.prisma.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [SQLite Documentation](https://www.sqlite.org/docs.html)\n- `prisma/schema.prisma` - Complete schema\n- `prisma/seed.ts` - Seed example\n- `app/utils/db.server.ts` - Prisma Client setup\n- `app/utils/litefs.server.ts` - LiteFS utilities\n","frontmatter":{"name":"epic-database","description":"Guide on Prisma, SQLite, and LiteFS for Epic Stack","categories":["database","prisma","sqlite","litefs"]},"isInternal":false,"tokens":4271,"sizeBytes":16034},{"name":"SKILL.md","path":"docs/skills/epic-deployment/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-deployment/SKILL.md","title":"epic-deployment","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-deployment\ndescription:\n  Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack\ncategories:\n  - deployment\n  - fly-io\n  - ci-cd\n  - docker\n---\n\n# Epic Stack: Deployment\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure deployment on Fly.io\n- Setup multi-region deployment\n- Configure CI/CD with GitHub Actions\n- Manage secrets in production\n- Configure healthchecks\n- Work with LiteFS and volumes\n- Local deployment with Docker\n\n## Patterns and conventions\n\n### Fly.io Configuration\n\nEpic Stack uses Fly.io for hosting with configuration in `fly.toml`.\n\n**Basic configuration:**\n\n```toml\n# fly.toml\napp = \"your-app-name\"\nprimary_region = \"sjc\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\n\n[build]\ndockerfile = \"/other/Dockerfile\"\nignorefile = \"/other/Dockerfile.dockerignore\"\n\n[mounts]\nsource = \"data\"\ndestination = \"/data\"\n```\n\n### Primary Region\n\n**Configure primary region:**\n\n```toml\nprimary_region = \"sjc\" # Change according to your location\n```\n\n**Important:** The primary region must be the same for:\n\n- `primary_region` en `fly.toml`\n- Region del volume `data`\n- `PRIMARY_REGION` en variables de entorno\n\n### LiteFS Configuration\n\n**Configuration in `other/litefs.yml`:**\n\n```yaml\nfuse:\n  dir: '${LITEFS_DIR}'\n\ndata:\n  dir: '/data/litefs'\n\nproxy:\n  addr: ':${INTERNAL_PORT}'\n  target: 'localhost:${PORT}'\n  db: '${DATABASE_FILENAME}'\n\nlease:\n  type: 'consul'\n  candidate: ${FLY_REGION == PRIMARY_REGION}\n  promote: true\n  advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'\n  consul:\n    url: '${FLY_CONSUL_URL}'\n    key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}'\n\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n  - cmd: sqlite3 $DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: sqlite3 $CACHE_DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: npx prisma generate --sql\n  - cmd: npm start\n```\n\n### Healthchecks\n\n**Configuration in `fly.toml`:**\n\n```toml\n[[services.http_checks]]\ninterval = \"10s\"\ngrace_period = \"5s\"\nmethod = \"get\"\npath = \"/resources/healthcheck\"\nprotocol = \"http\"\ntimeout = \"2s\"\ntls_skip_verify = false\n```\n\n**Healthcheck implementation:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Verify DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\tconsole.log('healthcheck ❌', { error })\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Environment Variables\n\n**Secrets in Fly.io:**\n\n```bash\n# Generate secrets\nfly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\nfly secrets set HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\n\n# List secrets\nfly secrets list --app [YOUR_APP_NAME]\n\n# Delete secret\nfly secrets unset SECRET_NAME --app [YOUR_APP_NAME]\n```\n\n**Common secrets:**\n\n- `SESSION_SECRET` - Secret for signing session cookies\n- `HONEYPOT_SECRET` - Secret for honeypot fields\n- `DATABASE_URL` - Automatically configured by LiteFS\n- `CACHE_DATABASE_PATH` - Automatically configured\n- `RESEND_API_KEY` - For sending emails (optional)\n- `TIGRIS_*` - For image storage (automatic)\n- `SENTRY_DSN` - For error monitoring (optional)\n\n### Volumes\n\n**Create volume:**\n\n```bash\nfly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]\n```\n\n**List volumes:**\n\n```bash\nfly volumes list --app [YOUR_APP_NAME]\n```\n\n**Expand volume:**\n\n```bash\nfly volumes extend <volume-id> --size 10 --app [YOUR_APP_NAME]\n```\n\n### Multi-Region Deployment\n\n**Deploy to multiple regions:**\n\n```bash\n# Deploy in primary region (more instances)\nfly scale count 2 --region sjc --app [YOUR_APP_NAME]\n\n# Deploy in secondary regions (read-only)\nfly scale count 1 --region ams --app [YOUR_APP_NAME]\nfly scale count 1 --region syd --app [YOUR_APP_NAME]\n```\n\n**Verify instances:**\n\n```bash\nfly status --app [YOUR_APP_NAME]\n# The ROLE column will show \"primary\" or \"replica\"\n```\n\n### Consul Setup\n\n**Attach Consul:**\n\n```bash\nfly consul attach --app [YOUR_APP_NAME]\n```\n\n**Consul manages:**\n\n- Which instance is primary\n- Automatic failover\n- Data replication\n\n### GitHub Actions CI/CD\n\n**Basic workflow:**\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n**Complete configuration:**\n\n- Deploy to `production` from `main` branch\n- Deploy to `staging` from `dev` branch\n- Tests before deploy (optional)\n\n### Deployable Commits\n\nFollowing Epic Web principles:\n\n**Deployable commits** - Every commit to the main branch should be deployable.\nThis means:\n\n- The code should be in a working state\n- Tests should pass\n- The application should build successfully\n- No \"WIP\" or \"TODO\" commits that break the build\n\n**Example - Deployable commit workflow:**\n\n```bash\n# ✅ Good - Each commit is deployable\ngit commit -m \"Add user profile page\"\n# This commit is complete, tested, and deployable\n\ngit commit -m \"Fix login redirect bug\"\n# This commit fixes a bug and is deployable\n\n# ❌ Avoid - Non-deployable commits\ngit commit -m \"WIP: working on feature\"\n# This commit might not work, not deployable\n\ngit commit -m \"Add feature (tests failing)\"\n# This commit breaks the build, not deployable\n```\n\n**Benefits:**\n\n- Easy rollback - any commit can be deployed\n- Continuous deployment - deploy any time\n- Clear history - each commit represents a working state\n- Faster recovery - can deploy any previous commit\n\n### Small and Short Lived Merge Requests\n\nFollowing Epic Web principles:\n\n**Small and short lived merge requests** - Keep PRs small and merge them\nquickly. Large PRs are hard to review, risky to merge, and slow down the team.\n\n**Guidelines:**\n\n- **Small PRs** - Focus on one feature or fix per PR\n- **Short-lived** - Merge within a day or two, not weeks\n- **Reviewable** - PRs should be reviewable in 30 minutes or less\n- **Independent** - Each PR should be independently deployable\n\n**Example - Small, focused PR:**\n\n```bash\n# ✅ Good - Small, focused PR\n# PR: \"Add email validation to signup form\"\n# - Only changes signup validation\n# - Includes tests\n# - Can be reviewed quickly\n# - Can be merged and deployed independently\n\n# ❌ Avoid - Large, complex PR\n# PR: \"Refactor authentication system and add 2FA and OAuth\"\n# - Too many changes at once\n# - Hard to review\n# - Risky to merge\n# - Takes days to review\n```\n\n**Benefits:**\n\n- Faster reviews - easier to understand and review\n- Lower risk - smaller changes are less risky\n- Faster feedback - get feedback sooner\n- Easier rollback - smaller changes are easier to revert\n- Better collaboration - team can work in parallel on different small PRs\n\n**When PRs get too large:**\n\n- Split into multiple smaller PRs\n- Use feature flags to merge incrementally\n- Break down into logical pieces\n\n### Tigris Object Storage\n\n**Create storage:**\n\n```bash\nfly storage create --app [YOUR_APP_NAME]\n```\n\n**This creates:**\n\n- Tigris bucket\n- Automatic environment variables:\n  - `TIGRIS_ENDPOINT`\n  - `TIGRIS_ACCESS_KEY_ID`\n  - `TIGRIS_SECRET_ACCESS_KEY`\n  - `TIGRIS_BUCKET_NAME`\n\n### Database Migrations\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`:\n\n```yaml\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n```\n\n**Note:** Only the primary instance runs migrations (`if-candidate: true`).\n\n### Database Backups\n\n**Create backup:**\n\n```bash\n# SSH to instance\nfly ssh console --app [YOUR_APP_NAME]\n\n# Create backup\nmkdir /backups\nlitefs export -name sqlite.db /backups/backup-$(date +%Y-%m-%d).db\nexit\n\n# Download backup\nfly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME]\n```\n\n**Restore backup:**\n\n```bash\n# Upload backup\nfly ssh sftp shell --app [YOUR_APP_NAME]\nput backup-2024-01-01.db\n# Ctrl+C to exit\n\n# SSH and restore\nfly ssh console --app [YOUR_APP_NAME]\nlitefs import -name sqlite.db /backup-2024-01-01.db\nexit\n```\n\n### Deployment Local\n\n**Deploy con Fly CLI:**\n\n```bash\nfly deploy\n```\n\n**Deploy con Docker:**\n\n```bash\n# Build\ndocker build -t epic-stack . -f other/Dockerfile \\\n  --build-arg COMMIT_SHA=$(git rev-parse --short HEAD)\n\n# Run\ndocker run -d \\\n  -p 8081:8081 \\\n  -e SESSION_SECRET='secret' \\\n  -e HONEYPOT_SECRET='secret' \\\n  -e FLY='false' \\\n  -v ~/litefs:/litefs \\\n  epic-stack\n```\n\n### Zero-Downtime Deploys\n\n**Strategy:**\n\n- Deploy to multiple instances\n- Automatic blue-green deployment\n- Healthchecks verify app is ready\n- Auto-rollback if healthcheck fails\n\n**Configuration:**\n\n```toml\n[experimental]\nauto_rollback = true\n```\n\n### Monitoring\n\n**View logs:**\n\n```bash\nfly logs --app [YOUR_APP_NAME]\n```\n\n**View metrics:**\n\n```bash\nfly dashboard --app [YOUR_APP_NAME]\n# Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring\n```\n\n**Sentry (opcional):**\n\n```bash\nfly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME]\n```\n\n## Common examples\n\n### Example 1: Complete initial setup\n\n```bash\n# 1. Create apps\nfly apps create my-app\nfly apps create my-app-staging\n\n# 2. Configure secrets\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  --app my-app\n\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  ALLOW_INDEXING=false \\\n  --app my-app-staging\n\n# 3. Create volumes\nfly volumes create data --region sjc --size 1 --app my-app\nfly volumes create data --region sjc --size 1 --app my-app-staging\n\n# 4. Attach Consul\nfly consul attach --app my-app\nfly consul attach --app my-app-staging\n\n# 5. Create storage\nfly storage create --app my-app\nfly storage create --app my-app-staging\n\n# 6. Deploy\nfly deploy --app my-app\n```\n\n### Example 2: Multi-region setup\n\n```bash\n# First region (primary) - 2 instances\nfly scale count 2 --region sjc --app my-app\n\n# Secondary regions - 1 instance each\nfly scale count 1 --region ams --app my-app\nfly scale count 1 --region syd --app my-app\n\n# Verify\nfly status --app my-app\n```\n\n### Example 3: GitHub Actions workflow\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy-production:\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\n  deploy-staging:\n    if: github.ref == 'refs/heads/dev'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app-staging\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Example 4: Deploy with migrations\n\n```bash\n# Create migration\nnpx prisma migrate dev --name add_field\n\n# Commit and push\ngit add .\ngit commit -m \"Add field\"\ngit push origin main\n\n# GitHub Actions automatically runs:\n# 1. Build\n# 2. Deploy\n# 3. litefs.yml runs: npx prisma migrate deploy (only on primary)\n```\n\n## Common mistakes to avoid\n\n- ❌ **Non-deployable commits**: Every commit to main should be deployable - no\n  WIP or broken commits\n- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are\n  hard to review and risky\n- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml`\n  matches the volume region\n- ❌ **Secrets not configured**: Configure all secrets before first deploy\n- ❌ **Volume not created**: Create the `data` volume before deploy\n- ❌ **Consul not attached**: Attach Consul before first deploy\n- ❌ **Migrations on replicas**: Only the primary instance should run migrations\n- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime\n  deploys\n- ❌ **Deploy breaking changes without strategy**: Use \"widen then narrow\" for\n  migrations\n- ❌ **Secrets in code**: Never commit secrets, use `fly secrets`\n- ❌ **Not making backups**: Make regular database backups\n- ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets\n\n## References\n\n- [Epic Stack Deployment Docs](../epic-stack/docs/deployment.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Fly.io Documentation](https://fly.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [Fly.io CLI Reference](https://fly.io/docs/flyctl/)\n- `fly.toml` - Fly.io configuration\n- `other/litefs.yml` - LiteFS configuration\n- `other/Dockerfile` - Deployment Dockerfile\n- `.github/workflows/deploy.yml` - CI/CD workflow\n\n### Preview Deployments (Inspired by Vercel Deploy Claimable)\n\nEpic Stack can implement preview deployments similar to Vercel's deploy\nclaimable pattern.\n\n**✅ Good - Preview deployments for pull requests:**\n\n```yaml\n# .github/workflows/preview-deploy.yml\nname: Preview Deploy\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  preview:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Deploy preview\n        run: |\n          # Create or reuse preview app\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps list | grep \"$PREVIEW_APP\" || flyctl apps create \"$PREVIEW_APP\"\n\n          # Deploy to preview app\n          flyctl deploy --app \"$PREVIEW_APP\" --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n      - name: Comment preview URL\n        uses: actions/github-script@v7\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: context.issue.number,\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `🚀 Preview deployment: https://$PREVIEW_APP.fly.dev`\n            })\n```\n\n**✅ Good - Auto-cleanup preview deployments:**\n\n```yaml\n# .github/workflows/cleanup-preview.yml\nname: Cleanup Preview\n\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Destroy preview app\n        run: |\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps destroy \"$PREVIEW_APP\" --yes\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Environment Detection\n\n**✅ Good - Detect deployment environment:**\n\n```typescript\n// app/utils/env.server.ts\nexport function getDeploymentEnv():\n\t| 'production'\n\t| 'staging'\n\t| 'preview'\n\t| 'development' {\n\tif (process.env.NODE_ENV === 'development') {\n\t\treturn 'development'\n\t}\n\n\t// Preview deployments\n\tif (process.env.FLY_APP_NAME?.includes('pr-')) {\n\t\treturn 'preview'\n\t}\n\n\t// Staging environment\n\tif (process.env.FLY_APP_NAME?.includes('staging')) {\n\t\treturn 'staging'\n\t}\n\n\t// Production\n\treturn 'production'\n}\n```\n\n**✅ Good - Environment-specific configuration:**\n\n```typescript\nconst env = getDeploymentEnv()\n\nexport const config = {\n\tproduction: env === 'production',\n\tstaging: env === 'staging',\n\tpreview: env === 'preview',\n\tdevelopment: env === 'development',\n\n\t// Preview deployments might have limited features\n\tfeatures: {\n\t\tanalytics: env === 'production',\n\t\tsentry: env !== 'development',\n\t\tindexing: env === 'production',\n\t},\n}\n```\n\n### Build Artifact Exclusion\n\n**✅ Good - Optimize Docker builds:**\n\n```dockerfile\n# other/Dockerfile\n# Multi-stage build for smaller image size\nFROM node:20-alpine AS base\nWORKDIR /app\n\n# Install dependencies\nFROM base AS deps\nCOPY package*.json ./\nRUN npm ci --only=production\n\n# Build application\nFROM base AS builder\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production image\nFROM base AS runner\nENV NODE_ENV=production\n\n# Copy only what's needed\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY --from=builder /app/build ./build\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/other ./other\nCOPY --from=builder /app/prisma ./prisma\nCOPY --from=builder /app/package.json ./\n\n# Exclude unnecessary files\n# node_modules/.cache, .git, etc. are already excluded via .dockerignore\n\nCMD [\"npm\", \"start\"]\n```\n\n**✅ Good - Docker ignore file:**\n\n```dockerignore\n# .dockerignore (in other/)\nnode_modules\n.git\n.env\n.env.*\n!.env.example\n*.log\n.DS_Store\ncoverage\n.vscode\n.idea\n*.swp\n*.swo\n*~\n.cache\ndist\nbuild\n```\n\n### Deployment Status and Monitoring\n\n**✅ Good - Deployment status tracking:**\n\n```typescript\n// app/routes/admin/deployment-status.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst deploymentInfo = {\n\t\tappName: process.env.FLY_APP_NAME,\n\t\tregion: process.env.FLY_REGION,\n\t\tenvironment: getDeploymentEnv(),\n\t\tcommitSha: process.env.COMMIT_SHA,\n\t\tdeployedAt: process.env.DEPLOYED_AT,\n\t}\n\n\treturn { deploymentInfo }\n}\n```\n\n### Rollback Strategies\n\n**✅ Good - Quick rollback with Fly.io:**\n\n```bash\n# List recent releases\nfly releases list --app my-app\n\n# Rollback to previous release\nfly releases rollback --app my-app\n```\n\n**✅ Good - Automated rollback on failure:**\n\n```toml\n# fly.toml\n[experimental]\n  auto_rollback = true\n  min_machines_running = 1\n```\n","frontmatter":{"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"]},"isInternal":false,"tokens":4707,"sizeBytes":17666},{"name":"SKILL.md","path":"docs/skills/epic-forms/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-forms/SKILL.md","title":"epic-forms","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-forms\ndescription: Guide on forms with Conform and validation with Zod for Epic Stack\ncategories:\n  - forms\n  - conform\n  - zod\n  - validation\n---\n\n# Epic Stack: Forms\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create forms in an Epic Stack application\n- Implement form validation with Zod\n- Work with Conform for progressively enhanced forms\n- Handle file uploads\n- Implement honeypot fields for spam protection\n- Handle form errors\n- Work with complex forms (fieldsets, arrays)\n\n## Patterns and conventions\n\n### Validation Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Make validation rules clear and explicit\nusing Zod schemas. Every validation rule should be visible in the schema, not\nhidden in business logic. Error messages should be specific and helpful, telling\nusers exactly what went wrong and how to fix it.\n\n**Design to fail fast and early** - Validate input as early as possible, ideally\non the client side before submission, and always on the server side. Return\nclear, specific error messages immediately so users can fix issues without\nfrustration.\n\n**Example - Explicit validation:**\n\n```typescript\n// ✅ Good - Explicit validation with clear error messages\nconst SignupSchema = z.object({\n\temail: z\n\t\t.string({ required_error: 'Email is required' })\n\t\t.email({ message: 'Please enter a valid email address' })\n\t\t.min(3, { message: 'Email must be at least 3 characters' })\n\t\t.max(100, { message: 'Email must be less than 100 characters' })\n\t\t.transform((val) => val.toLowerCase().trim()),\n\tpassword: z\n\t\t.string({ required_error: 'Password is required' })\n\t\t.min(6, { message: 'Password must be at least 6 characters' })\n\t\t.max(72, { message: 'Password must be less than 72 characters' }),\n})\n\n// ❌ Avoid - Implicit validation\nconst SignupSchema = z.object({\n\temail: z.string().email(), // No clear error messages\n\tpassword: z.string().min(6), // Generic error\n})\n```\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate early and return specific errors immediately\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: 400 }, // Clear error status\n\t\t)\n\t}\n\n\t// Only proceed if validation passed\n\tconst { email, password } = submission.value\n\t// ... continue with signup\n}\n\n// ❌ Avoid - Delayed or unclear validation\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst email = formData.get('email')\n\tconst password = formData.get('password')\n\n\t// Validation scattered throughout the function\n\tif (!email) {\n\t\t// Generic error, not specific\n\t\treturn json({ error: 'Invalid' }, { status: 400 })\n\t}\n\t// ... more scattered validation\n}\n```\n\n### Basic setup with Conform\n\nEpic Stack uses [Conform](https://conform.guide/) to handle forms with\nprogressive enhancement.\n\n**Basic setup:**\n\n```typescript\nimport { getFormProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tconstraint: getZodConstraint(SignupSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Integration with Zod\n\nConform integrates seamlessly with Zod for validation.\n\n**Define schema:**\n\n```typescript\nimport { z } from 'zod'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: z.string().email('Invalid email'),\n\t\tpassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\tconfirmPassword: z.string(),\n\t})\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'Passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Validation in action (fail fast):**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - explicit and fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return explicit errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Only proceed if validation passed - submission.value is type-safe\n\tconst { email, password } = submission.value\n\t// ... process with validated data\n}\n```\n\n### Async validation\n\nFor validations that require querying the database:\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true, // Important: enable async validation\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// ...\n}\n```\n\n### Field Components\n\nEpic Stack provides pre-built field components:\n\n**Basic Field:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\n**TextareaField:**\n\n```typescript\nimport { TextareaField } from '#app/components/forms.tsx'\nimport { getTextareaProps } from '@conform-to/react'\n\n<TextareaField\n\tlabelProps={{\n\t\thtmlFor: fields.content.id,\n\t\tchildren: 'Content',\n\t}}\n\ttextareaProps={{\n\t\t...getTextareaProps(fields.content),\n\t\trows: 10,\n\t}}\n\terrors={fields.content.errors}\n/>\n```\n\n**CheckboxField:**\n\n```typescript\nimport { CheckboxField } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<CheckboxField\n\tlabelProps={{\n\t\thtmlFor: fields.remember.id,\n\t\tchildren: 'Remember me',\n\t}}\n\tbuttonProps={getInputProps(fields.remember, { type: 'checkbox' })}\n\terrors={fields.remember.errors}\n/>\n```\n\n**OTPField:**\n\n```typescript\nimport { OTPField } from '#app/components/forms.tsx'\n\n<OTPField\n\tlabelProps={{\n\t\thtmlFor: fields.code.id,\n\t\tchildren: 'Verification Code',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.code),\n\t\tmaxLength: 6,\n\t}}\n\terrors={fields.code.errors}\n/>\n```\n\n### Error Handling\n\n**Display field errors:**\n\n```typescript\n<Field\n\t// ... props\n\terrors={fields.email.errors} // Errores específicos del campo\n/>\n```\n\n**Display form errors:**\n\n```typescript\nimport { ErrorList } from '#app/components/forms.tsx'\n\n<ErrorList errors={form.errors} id={form.errorId} />\n```\n\n**Error structure:**\n\n- `fields.fieldName.errors` - Errors for a specific field\n- `form.errors` - General form errors (like `formErrors`)\n\n### Honeypot Fields\n\nEpic Stack includes spam protection with honeypot fields.\n\n**In the form:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Rest of fields */}\n</Form>\n```\n\n**In the action:**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Throws error if spam\n\n\t// ... rest of code\n}\n```\n\n### File Uploads\n\nFor forms with file uploads, use `encType=\"multipart/form-data\"`.\n\n**Schema for files:**\n\n```typescript\nconst MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB\n\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z\n\t\t.instanceof(File)\n\t\t.optional()\n\t\t.refine((file) => {\n\t\t\treturn !file || file.size <= MAX_UPLOAD_SIZE\n\t\t}, 'File must be less than 3MB'),\n\taltText: z.string().optional(),\n})\n\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**Form with file upload:**\n\n```typescript\n<Form\n\tmethod=\"POST\"\n\tencType=\"multipart/form-data\"\n\t{...getFormProps(form)}\n>\n\t{/* Fields */}\n</Form>\n```\n\n**Process files in action:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteEditorSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { images } = submission.value\n\n\t// Process files\n\tfor (const image of images ?? []) {\n\t\tif (image.file) {\n\t\t\t// Upload file, save to storage, etc.\n\t\t}\n\t}\n\n\t// ...\n}\n```\n\n### Fieldsets y Arrays\n\nFor forms with repetitive fields (like multiple images):\n\n**Schema:**\n\n```typescript\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z.instanceof(File).optional(),\n\taltText: z.string().optional(),\n})\n\nconst FormSchema = z.object({\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**In the component:**\n\n```typescript\nimport { FormProvider, getFieldsetProps } from '@conform-to/react'\n\nconst [form, fields] = useForm({\n\t// ...\n\tdefaultValue: {\n\t\timages: note?.images ?? [{}],\n\t},\n})\n\nconst imageList = fields.images.getFieldList()\n\nreturn (\n\t<FormProvider context={form.context}>\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{imageList.map((image, index) => {\n\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\treturn (\n\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</fieldset>\n\t\t\t\t)\n\t\t\t})}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t>\n\t\t\t\tAdd Image\n\t\t\t</button>\n\t\t</Form>\n\t</FormProvider>\n)\n```\n\n### StatusButton\n\nUse `StatusButton` to display submission status:\n\n```typescript\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\nimport { useIsPending } from '#app/utils/misc.tsx'\n\nconst isPending = useIsPending()\n\n<StatusButton\n\tclassName=\"w-full\"\n\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\ttype=\"submit\"\n\tdisabled={isPending}\n>\n\tSubmit\n</StatusButton>\n```\n\n### Progressive Enhancement\n\nForms work without JavaScript thanks to Conform:\n\n- Native HTML5 validation\n- Server-side validation\n- Enhancements with JavaScript when available\n\n**You don't need to do anything special** - Conform handles this automatically.\n\n### Reusable Schema Validation\n\nCreate reusable schemas in `app/utils/user-validation.ts`:\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase())\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long',\n\t})\n\nexport const PasswordAndConfirmPasswordSchema = z\n\t.object({ password: PasswordSchema, confirmPassword: PasswordSchema })\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'The passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Use in forms:**\n\n```typescript\nimport {\n\tEmailSchema,\n\tPasswordAndConfirmPasswordSchema,\n} from '#app/utils/user-validation.ts'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: EmailSchema,\n\t\tusername: UsernameSchema,\n\t})\n\t.and(PasswordAndConfirmPasswordSchema)\n```\n\n## Common examples\n\n### Example 1: Simple login form\n\n```typescript\n// app/routes/_auth/login.tsx\nimport { getFormProps, getInputProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\n\nconst LoginSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(1),\n})\n\nexport default function LoginRoute({ actionData }: Route.ComponentProps) {\n\tconst isPending = useIsPending()\n\n\tconst [form, fields] = useForm({\n\t\tid: 'login-form',\n\t\tconstraint: getZodConstraint(LoginSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: LoginSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t\t\t\tautoFocus: true,\n\t\t\t\t\tautoComplete: 'email',\n\t\t\t\t}}\n\t\t\t\terrors={fields.email.errors}\n\t\t\t/>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.password, { type: 'password' }),\n\t\t\t\t\tautoComplete: 'current-password',\n\t\t\t\t}}\n\t\t\t\terrors={fields.password.errors}\n\t\t\t/>\n\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t<StatusButton\n\t\t\t\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\t\t\t\ttype=\"submit\"\n\t\t\t>\n\t\t\t\tLogin\n\t\t\t</StatusButton>\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Form with async validation\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Procesar signup...\n}\n```\n\n### Example 3: Form with file upload\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n\nexport default function NewNoteRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'note-editor',\n\t\tconstraint: getZodConstraint(NoteEditorSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: NoteEditorSchema })\n\t\t},\n\t\tdefaultValue: {\n\t\t\timages: [{}],\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\tconst imageList = fields.images.getFieldList()\n\n\treturn (\n\t\t<FormProvider context={form.context}>\n\t\t\t<Form\n\t\t\t\tmethod=\"POST\"\n\t\t\t\tencType=\"multipart/form-data\"\n\t\t\t\t{...getFormProps(form)}\n\t\t\t>\n\t\t\t\t<Field\n\t\t\t\t\tlabelProps={{ children: 'Title' }}\n\t\t\t\t\tinputProps={{\n\t\t\t\t\t\tautoFocus: true,\n\t\t\t\t\t\t...getInputProps(fields.title, { type: 'text' }),\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.title.errors}\n\t\t\t\t/>\n\t\t\t\t<TextareaField\n\t\t\t\t\tlabelProps={{ children: 'Content' }}\n\t\t\t\t\ttextareaProps={{\n\t\t\t\t\t\t...getTextareaProps(fields.content),\n\t\t\t\t\t\trows: 10,\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.content.errors}\n\t\t\t\t/>\n\n\t\t\t\t{imageList.map((image, index) => {\n\t\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t\t\taccept=\"image/*\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t\t\tdisabled={imageList.length >= 5}\n\t\t\t\t>\n\t\t\t\t\tAdd Image\n\t\t\t\t</button>\n\n\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t\t<StatusButton type=\"submit\">Create Note</StatusButton>\n\t\t\t</Form>\n\t\t</FormProvider>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit validation**: Always use explicit Zod schemas with clear error\n  messages, not hidden validation logic\n- ❌ **Delayed validation**: Validate input immediately and fail fast - don't\n  wait until the end of the function\n- ❌ **Generic error messages**: Use specific, helpful error messages that tell\n  users exactly what's wrong\n- ❌ **Forgetting `async: true` in async validation**: Always include\n  `async: true` when using `superRefine` with async\n- ❌ **Not including `HoneypotInputs` in public forms**: Always include honeypot\n  in forms accessible without authentication\n- ❌ **Forgetting `encType=\"multipart/form-data\"`**: Required for file uploads\n- ❌ **Not validating file sizes**: Always validate size in the schema\n- ❌ **Not using `getZodConstraint`**: Required for native HTML5 validation\n- ❌ **Forgetting `lastResult` in `useForm`**: Required to display server errors\n- ❌ **Not using `shouldRevalidate: 'onBlur'`**: Improves UX by validating on\n  field blur\n- ❌ **Not using pre-built field components**: `Field`, `TextareaField`, etc.\n  already handle accessibility and errors\n\n## References\n\n- [Conform Documentation](https://conform.guide/)\n- [Zod Documentation](https://zod.dev/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/components/forms.tsx` - Field components\n- `app/routes/_auth/signup.tsx` - Complete signup example\n- `app/routes/_auth/onboarding/index.tsx` - Complex form example\n- `app/routes/users/$username/notes/+shared/note-editor.tsx` - File uploads\n  example\n- `app/utils/user-validation.ts` - Reusable schemas\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n","frontmatter":{"name":"epic-forms","description":"Guide on forms with Conform and validation with Zod for Epic Stack","categories":["forms","conform","zod","validation"]},"isInternal":false,"tokens":4826,"sizeBytes":18963},{"name":"SKILL.md","path":"docs/skills/epic-permissions/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-permissions/SKILL.md","title":"epic-permissions","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-permissions\ndescription: Guide on RBAC system and permissions for Epic Stack\ncategories:\n  - permissions\n  - rbac\n  - access-control\n---\n\n# Epic Stack: Permissions\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement role-based access control (RBAC)\n- Validate permissions on server-side or client-side\n- Create new permissions or roles\n- Restrict access to routes or actions\n- Implement granular permissions (`own` vs `any`)\n\n## Patterns and conventions\n\n### Permissions Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Always explicitly check permissions.\nDon't assume a user has access based on implicit rules or hidden logic. Every\npermission check should be visible and clear in the code.\n\n**Example - Explicit permission checks:**\n\n```typescript\n// ✅ Good - Explicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly check permission - clear and visible\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Permission check is explicit and obvious\n\tawait prisma.note.delete({ where: { id: noteId } })\n}\n\n// ❌ Avoid - Implicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst note = await prisma.note.findUnique({ where: { id: noteId } })\n\n\t// Implicit check - not clear what permission is being checked\n\tif (note.ownerId !== userId) {\n\t\tthrow new Response('Forbidden', { status: 403 })\n\t}\n\t// What permission does this represent? Not explicit\n}\n```\n\n**Example - Explicit permission strings:**\n\n```typescript\n// ✅ Good - Explicit permission string\nconst permission: PermissionString = 'delete:note:own'\n// Clear: action (delete), entity (note), access (own)\n\nawait requireUserWithPermission(request, permission)\n\n// ❌ Avoid - Implicit or unclear permissions\nconst canDelete = checkUserCanDelete(user, note)\n// What permission is this checking? Not explicit\n```\n\n### RBAC Model\n\nEpic Stack uses an RBAC (Role-Based Access Control) model where:\n\n- **Users** have **Roles**\n- **Roles** have **Permissions**\n- A user's permissions are the union of all permissions from their roles\n\n### Permission Structure\n\nPermissions follow the format: `action:entity:access`\n\n**Components:**\n\n- `action`: The allowed action (`create`, `read`, `update`, `delete`)\n- `entity`: The entity being acted upon (`user`, `note`, etc.)\n- `access`: The access level (`own`, `any`, `own,any`)\n\n**Examples:**\n\n- `create:note:own` - Can create own notes\n- `read:note:any` - Can read any note\n- `delete:user:any` - Can delete any user (admin)\n- `update:note:own` - Can update only own notes\n\n### Prisma Schema\n\n**Models:**\n\n```prisma\nmodel Permission {\n  id          String @id @default(cuid())\n  action      String // e.g. create, read, update, delete\n  entity      String // e.g. note, user, etc.\n  access      String // e.g. own or any\n  description String @default(\"\")\n\n  roles Role[]\n\n  @@unique([action, entity, access])\n}\n\nmodel Role {\n  id          String @id @default(cuid())\n  name        String @unique\n  description String @default(\"\")\n\n  users       User[]\n  permissions Permission[]\n}\n\nmodel User {\n  id    String @id @default(cuid())\n  // ...\n  roles Role[]\n}\n```\n\n### Validate Permissions Server-Side\n\n**Require specific permission:**\n\n```typescript\nimport { requireUserWithPermission } from '#app/utils/permissions.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserWithPermission(\n\t\trequest,\n\t\t'delete:note:own', // Throws 403 error if doesn't have permission\n\t)\n\n\t// User has the permission, continue...\n}\n```\n\n**Require specific role:**\n\n```typescript\nimport { requireUserWithRole } from '#app/utils/permissions.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserWithRole(request, 'admin')\n\n\t// User has admin role, continue...\n}\n```\n\n**Conditional permissions (own vs any) - explicit:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly determine ownership\n\tconst note = await prisma.note.findUnique({\n\t\twhere: { id: noteId },\n\t\tselect: { ownerId: true },\n\t})\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Explicitly check the appropriate permission based on ownership\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string\n\t)\n\n\t// Permission check is explicit and clear\n\t// Proceed with deletion...\n}\n```\n\n### Validate Permissions Client-Side\n\n**Check if user has permission:**\n\n```typescript\nimport { userHasPermission, useOptionalUser } from '#app/utils/user.ts'\n\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t{canDelete && (\n\t\t\t\t<button onClick={handleDelete}>Delete</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n**Check if user has role:**\n\n```typescript\nimport { userHasRole } from '#app/utils/user.ts'\n\nexport default function AdminRoute() {\n\tconst user = useOptionalUser()\n\tconst isAdmin = userHasRole(user, 'admin')\n\n\tif (!isAdmin) {\n\t\treturn <div>Access Denied</div>\n\t}\n\n\treturn <div>Admin Panel</div>\n}\n```\n\n### Create New Permissions\n\n**En Prisma Studio o seed:**\n\n```typescript\n// prisma/seed.ts\nawait prisma.permission.create({\n\tdata: {\n\t\taction: 'create',\n\t\tentity: 'post',\n\t\taccess: 'own',\n\t\tdescription: 'Can create their own posts',\n\t\troles: {\n\t\t\tconnect: { name: 'user' },\n\t\t},\n\t},\n})\n```\n\n**Permiso con múltiples niveles de acceso:**\n\n```typescript\nawait prisma.permission.createMany({\n\tdata: [\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own posts',\n\t\t},\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t],\n})\n```\n\n### Assign Roles to Users\n\n**When creating user:**\n\n```typescript\nconst user = await prisma.user.create({\n\tdata: {\n\t\temail,\n\t\tusername,\n\t\troles: {\n\t\t\tconnect: { name: 'user' }, // Assign 'user' role\n\t\t},\n\t},\n})\n```\n\n**Assign multiple roles:**\n\n```typescript\nawait prisma.user.update({\n\twhere: { id: userId },\n\tdata: {\n\t\troles: {\n\t\t\tconnect: [{ name: 'user' }, { name: 'moderator' }],\n\t\t},\n\t},\n})\n```\n\n### Permissions and Roles Seed\n\n**Seed example:**\n\n```typescript\n// prisma/seed.ts\n\n// Create permissions\nconst permissions = await Promise.all([\n\t// User permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'update',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can update own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can delete own notes',\n\t\t},\n\t}),\n\t// Admin permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'user',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can delete any user',\n\t\t},\n\t}),\n])\n\n// Create roles\nconst userRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'user',\n\t\tdescription: 'Standard user',\n\t\tpermissions: {\n\t\t\tconnect: permissions.slice(0, 4).map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n\nconst adminRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'admin',\n\t\tdescription: 'Administrator',\n\t\tpermissions: {\n\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n```\n\n### Permission Type\n\n**Type-safe permission strings:**\n\n```typescript\nimport { type PermissionString } from '#app/utils/user.ts'\n\n// Tipo: 'create:note:own' | 'read:note:own' | etc.\nconst permission: PermissionString = 'delete:note:own'\n```\n\n**Parsear permission string:**\n\n```typescript\nimport { parsePermissionString } from '#app/utils/user.ts'\n\nconst { action, entity, access } = parsePermissionString('delete:note:own')\n// action: 'delete'\n// entity: 'note'\n// access: ['own']\n```\n\n## Common examples\n\n### Example 1: Proteger action con permiso\n\n```typescript\n// app/routes/users/$username/notes/$noteId.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\tconst { noteId } = Object.fromEntries(formData)\n\n\tconst note = await prisma.note.findFirst({\n\t\tselect: { id: true, ownerId: true, owner: { select: { username: true } } },\n\t\twhere: { id: noteId },\n\t})\n\n\tif (!note) {\n\t\tthrow new Response('Not found', { status: 404 })\n\t}\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Validate permiso según si es propietario o no\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\tawait prisma.note.delete({ where: { id: note.id } })\n\n\treturn redirect(`/users/${note.owner.username}/notes`)\n}\n```\n\n### Example 2: Mostrar UI condicional basada en permisos\n\n```typescript\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\tconst canEdit = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'update:note:own' : 'update:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.note.title}</h1>\n\t\t\t<p>{loaderData.note.content}</p>\n\n\t\t\t{(canEdit || canDelete) && (\n\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t{canEdit && (\n\t\t\t\t\t\t<Link to=\"edit\">\n\t\t\t\t\t\t\t<Button>Edit</Button>\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t)}\n\t\t\t\t\t{canDelete && (\n\t\t\t\t\t\t<DeleteNoteButton noteId={loaderData.note.id} />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n### Example 3: Ruta solo para admin\n\n```typescript\n// app/routes/admin/users.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireUserWithRole(request, 'admin')\n\n\tconst users = await prisma.user.findMany({\n\t\tselect: {\n\t\t\tid: true,\n\t\t\temail: true,\n\t\t\tusername: true,\n\t\t},\n\t})\n\n\treturn { users }\n}\n\nexport default function AdminUsersRoute({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>All Users</h1>\n\t\t\t{loaderData.users.map(user => (\n\t\t\t\t<div key={user.id}>{user.username}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n### Example 4: Create new permission and assign it\n\n```typescript\n// Migración o seed\nasync function setupPostPermissions() {\n\t// Create post permissions\n\tconst createOwn = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own posts',\n\t\t},\n\t})\n\n\tconst readAny = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t})\n\n\t// Assign to user role\n\tawait prisma.role.update({\n\t\twhere: { name: 'user' },\n\t\tdata: {\n\t\t\tpermissions: {\n\t\t\t\tconnect: [{ id: createOwn.id }, { id: readAny.id }],\n\t\t\t},\n\t\t},\n\t})\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit permission checks**: Always explicitly check permissions - make\n  permission requirements visible in code\n- ❌ **Not validating permissions on server-side**: Always validate permissions\n  in action/loader, never trust client-side only\n- ❌ **Forgetting to verify `own` vs `any`**: Explicitly determine if user is\n  owner before validating permission\n- ❌ **Not using correct helpers**: Use `requireUserWithPermission` for\n  server-side and `userHasPermission` for client-side - explicit helpers\n- ❌ **Not creating unique permissions**: Use\n  `@@unique([action, entity, access])` in schema - explicit permission structure\n- ❌ **Assuming permissions instead of verifying**: Always verify explicitly,\n  even if you think user has the permission\n- ❌ **Not handling 403 errors**: Helpers throw errors that must be handled by\n  ErrorBoundary\n- ❌ **Not using types**: Use `PermissionString` type for type-safety - explicit\n  types\n- ❌ **Hidden permission logic**: Don't hide permission checks in utility\n  functions - make them explicit at the call site\n\n## References\n\n- [Epic Stack Permissions Docs](../epic-stack/docs/permissions.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [RBAC Explained](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac)\n- `app/utils/permissions.server.ts` - Server-side permission utilities\n- `app/utils/user.ts` - Client-side permission utilities\n- `prisma/schema.prisma` - Permission and Role models\n- `prisma/seed.ts` - Permission seed examples\n","frontmatter":{"name":"epic-permissions","description":"Guide on RBAC system and permissions for Epic Stack","categories":["permissions","rbac","access-control"]},"isInternal":false,"tokens":3259,"sizeBytes":12742},{"name":"SKILL.md","path":"docs/skills/epic-react-patterns/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-react-patterns/SKILL.md","title":"epic-react-patterns","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-react-patterns\ndescription:\n  Guide on React patterns, performance optimization, and code quality for Epic\n  Stack\ncategories:\n  - react\n  - performance\n  - patterns\n  - optimization\n---\n\n# Epic Stack: React Patterns and Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write efficient React components in Epic Stack applications\n- Optimize performance and bundle size\n- Follow React Router patterns and conventions\n- Avoid common React anti-patterns\n- Implement proper code splitting\n- Optimize re-renders and data fetching\n- Use React hooks correctly\n\n## Philosophy\n\nFollowing Epic Web principles:\n\n- **Make it work, make it right, make it fast** - In that order. First make it\n  functional, then refactor for clarity, then optimize for performance.\n- **Pragmatism over purity** - Choose practical solutions that work well in your\n  context rather than theoretically perfect ones.\n- **Optimize for sustainable velocity** - Write code that's easy to maintain and\n  extend, not just fast to write initially.\n- **Do as little as possible** - Only add complexity when it provides real\n  value.\n\n## Patterns and conventions\n\n### Data Fetching in React Router\n\nEpic Stack uses React Router loaders for data fetching, not `useEffect`.\n\n**✅ Good - Use loaders:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n**❌ Avoid - Don't fetch in useEffect:**\n\n```typescript\n// ❌ Don't do this\nexport default function UserRoute({ params }: Route.ComponentProps) {\n\tconst [user, setUser] = useState(null)\n\n\tuseEffect(() => {\n\t\tfetch(`/api/users/${params.username}`)\n\t\t\t.then(res => res.json())\n\t\t\t.then(setUser)\n\t}, [params.username])\n\n\treturn user ? <div>{user.name}</div> : <div>Loading...</div>\n}\n```\n\n### Avoid useEffect for Side Effects\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use event handlers, CSS, ref callbacks, or\n`useSyncExternalStore`.\n\n**✅ Good - Use event handlers:**\n\n```typescript\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigate('/checkout')\n\t}\n\n\treturn (\n\t\t<div>\n\t\t\t<button onClick={handleBuyClick}>Buy Now</button>\n\t\t\t<button onClick={handleCheckoutClick}>Checkout</button>\n\t\t</div>\n\t)\n}\n```\n\n**❌ Avoid - Side effects in useEffect:**\n\n```typescript\n// ❌ Don't do this\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\t// ...\n}\n```\n\n**✅ Appropriate use of useEffect:**\n\n```typescript\n// ✅ Good - Event listeners are appropriate\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\t\t\t// handle escape key\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n\n### Code Splitting with React Router\n\nReact Router automatically code-splits by route. Use dynamic imports for heavy\ncomponents.\n\n**✅ Good - Dynamic imports:**\n\n```typescript\n// app/routes/admin/dashboard.tsx\nimport { lazy } from 'react'\n\nconst AdminChart = lazy(() => import('#app/components/admin/chart.tsx'))\n\nexport default function AdminDashboard() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading chart...</div>}>\n\t\t\t<AdminChart />\n\t\t</Suspense>\n\t)\n}\n```\n\n### Optimizing Re-renders\n\n**✅ Good - Memoize expensive computations:**\n\n```typescript\nimport { useMemo } from 'react'\n\nfunction UserList({ users }: { users: User[] }) {\n\tconst sortedUsers = useMemo(() => {\n\t\treturn [...users].sort((a, b) => a.name.localeCompare(b.name))\n\t}, [users])\n\n\treturn (\n\t\t<ul>\n\t\t\t{sortedUsers.map(user => (\n\t\t\t\t<li key={user.id}>{user.name}</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**✅ Good - Memoize callbacks:**\n\n```typescript\nimport { useCallback } from 'react'\n\nfunction NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) {\n\tconst handleSave = useCallback((note: Note) => {\n\t\tonSave(note)\n\t}, [onSave])\n\n\treturn <Editor onSave={handleSave} />\n}\n```\n\n**❌ Avoid - Unnecessary memoization:**\n\n```typescript\n// ❌ Don't memoize simple values\nconst count = useMemo(() => items.length, [items]) // Just use items.length directly\n\n// ❌ Don't memoize simple callbacks\nconst handleClick = useCallback(() => {\n\tconsole.log('clicked')\n}, []) // Just define the function normally if it doesn't need memoization\n```\n\n### Bundle Size Optimization\n\n**✅ Good - Import only what you need:**\n\n```typescript\n// ✅ Import specific functions\nimport { useSearchParams } from 'react-router'\nimport { parseWithZod } from '@conform-to/zod'\n```\n\n**❌ Avoid - Barrel imports:**\n\n```typescript\n// ❌ Don't import entire libraries if you only need one thing\nimport * as ReactRouter from 'react-router'\nimport * as Conform from '@conform-to/zod'\n```\n\n### Form Handling with Conform\n\n**✅ Good - Use Conform for forms:**\n\n```typescript\nimport { useForm, getFormProps } from '@conform-to/react'\nimport { parseWithZod } from '@conform-to/zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Component Composition\n\n**✅ Good - Compose components:**\n\n```typescript\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<Card>\n\t\t\t<UserHeader user={user} />\n\t\t\t<UserDetails user={user} />\n\t\t\t<UserActions userId={user.id} />\n\t\t</Card>\n\t)\n}\n```\n\n**❌ Avoid - Large monolithic components:**\n\n```typescript\n// ❌ Don't put everything in one component\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<div className=\"card\">\n\t\t\t<div className=\"header\">\n\t\t\t\t<img src={user.avatar} alt={user.name} />\n\t\t\t\t<h1>{user.name}</h1>\n\t\t\t</div>\n\t\t\t<div className=\"details\">\n\t\t\t\t<p>{user.email}</p>\n\t\t\t\t<p>{user.bio}</p>\n\t\t\t</div>\n\t\t\t<div className=\"actions\">\n\t\t\t\t<button>Edit</button>\n\t\t\t\t<button>Delete</button>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n```\n\n### Error Boundaries\n\n**✅ Good - Use error boundaries:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>User \"{params.username}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### TypeScript Guidelines\n\n**✅ Good - Type props explicitly:**\n\n```typescript\ninterface UserCardProps {\n\tuser: {\n\t\tid: string\n\t\tname: string\n\t\temail: string\n\t}\n\tonEdit?: (userId: string) => void\n}\n\nfunction UserCard({ user, onEdit }: UserCardProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h2>{user.name}</h2>\n\t\t\t<p>{user.email}</p>\n\t\t\t{onEdit && <button onClick={() => onEdit(user.id)}>Edit</button>}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Route types:**\n\n```typescript\nimport type { Route } from './+types/users.$username'\n\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params is type-safe!\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\t// loaderData is type-safe!\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n### Loading States\n\n**✅ Good - Use React Router's pending states:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction NoteForm() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<button type=\"submit\" disabled={isSubmitting}>\n\t\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t\t</button>\n\t\t</Form>\n\t)\n}\n```\n\n### Preventing Data Fetching Waterfalls\n\nReact Router loaders can prevent waterfalls by fetching data in parallel.\n\n**❌ Avoid - Sequential data fetching (waterfall):**\n\n```typescript\n// ❌ Don't do this - creates a waterfall\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\t// Second fetch waits for first to complete\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t})\n\treturn { user, notes }\n}\n```\n\n**✅ Good - Parallel data fetching:**\n\n```typescript\n// ✅ Fetch data in parallel\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\n\t// Fetch notes in parallel with user data\n\tconst [notes, stats] = await Promise.all([\n\t\tuser\n\t\t\t? prisma.note.findMany({\n\t\t\t\t\twhere: { ownerId: user.id },\n\t\t\t\t\tselect: { id: true, title: true, updatedAt: true },\n\t\t\t\t})\n\t\t\t: Promise.resolve([]),\n\t\tuser\n\t\t\t? prisma.note.count({ where: { ownerId: user.id } })\n\t\t\t: Promise.resolve(0),\n\t])\n\n\treturn { user, notes, stats }\n}\n```\n\n**✅ Good - Nested route parallel loading:**\n\n```typescript\n// Parent route loader\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\n// Child route loader runs in parallel\n// app/routes/users/$username/notes.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true },\n\t})\n\n\tif (!user) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t\tselect: { id: true, title: true, updatedAt: true },\n\t})\n\n\treturn { notes }\n}\n```\n\n### Server-Side Rendering (SSR) Performance\n\nReact Router provides SSR by default. Optimize by:\n\n**✅ Good - Selective data fetching:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Only fetch what's needed for initial render\n\tconst searchParams = new URL(request.url).searchParams\n\tconst page = Number(searchParams.get('page') || '1')\n\n\tconst [items, total] = await Promise.all([\n\t\tprisma.item.findMany({\n\t\t\ttake: 20,\n\t\t\tskip: (page - 1) * 20,\n\t\t\tselect: { id: true, title: true }, // Only needed fields\n\t\t}),\n\t\tprisma.item.count(),\n\t])\n\n\treturn { items, total, page }\n}\n```\n\n**✅ Good - Use caching for expensive operations:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\t// Cache expensive database queries\n\tconst stats = await cachified({\n\t\tkey: 'user-stats',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.aggregate({\n\t\t\t\t_count: { id: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t})\n\n\treturn { stats }\n}\n```\n\n### Rendering Performance\n\n**✅ Good - Use React.memo for expensive components:**\n\n```typescript\nimport { memo } from 'react'\n\nconst ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) {\n\t// Expensive rendering logic\n\treturn <Chart data={data} />\n})\n\n// Only re-renders when data changes\nexport default function Dashboard({ chartData }: { chartData: Data[] }) {\n\treturn <ExpensiveChart data={chartData} />\n}\n```\n\n**✅ Good - Optimize list rendering:**\n\n```typescript\nimport { memo } from 'react'\n\nconst UserItem = memo(function UserItem({ user }: { user: User }) {\n\treturn (\n\t\t<li>\n\t\t\t<h3>{user.name}</h3>\n\t\t\t<p>{user.email}</p>\n\t\t</li>\n\t)\n}, (prev, next) => prev.user.id === next.user.id)\n\nfunction UserList({ users }: { users: User[] }) {\n\treturn (\n\t\t<ul>\n\t\t\t{users.map(user => (\n\t\t\t\t<UserItem key={user.id} user={user} />\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**❌ Avoid - Creating new objects/arrays in render:**\n\n```typescript\n// ❌ Don't create new objects on every render\nfunction UserProfile({ user }: { user: User }) {\n\treturn <Card user={{ ...user, fullName: `${user.firstName} ${user.lastName}` }} />\n}\n\n// ✅ Good - Compute in loader or memoize\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { firstName: true, lastName: true },\n\t})\n\n\treturn {\n\t\tuser: {\n\t\t\t...user,\n\t\t\tfullName: `${user.firstName} ${user.lastName}`,\n\t\t},\n\t}\n}\n```\n\n### Bundle Size Optimization Strategies\n\n**✅ Good - Route-based code splitting:** React Router automatically splits code\nby route. Leverage this:\n\n```typescript\n// Heavy dependencies are automatically split by route\n// app/routes/admin/dashboard.tsx\nimport { Chart } from 'chart.js' // Only loaded on /admin/dashboard route\n```\n\n**✅ Good - Dynamic imports for heavy components:**\n\n```typescript\nimport { lazy, Suspense } from 'react'\n\nconst HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx'))\n\nexport default function Route() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading...</div>}>\n\t\t\t<HeavyComponent />\n\t\t</Suspense>\n\t)\n}\n```\n\n**✅ Good - Tree-shakeable imports:**\n\n```typescript\n// ✅ Tree-shakeable - only imports what you use\nimport { format } from 'date-fns/format'\nimport { addDays } from 'date-fns/addDays'\n\n// ❌ Avoid - imports entire library\nimport * as dateFns from 'date-fns'\n```\n\n### React 18+ Features for Performance\n\n**✅ Good - Use transitions for non-urgent updates:**\n\n```typescript\nimport { useTransition } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction SearchInput() {\n\tconst [isPending, startTransition] = useTransition()\n\tconst navigation = useNavigation()\n\n\tfunction handleSearch(query: string) {\n\t\tstartTransition(() => {\n\t\t\t// Update search results (non-urgent)\n\t\t\tnavigation.navigate(`/search?q=${query}`)\n\t\t})\n\t}\n\n\treturn (\n\t\t<input\n\t\t\tonChange={(e) => handleSearch(e.target.value)}\n\t\t\tplaceholder={isPending ? 'Searching...' : 'Search'}\n\t\t/>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching data in useEffect**: Use React Router loaders instead\n- ❌ **Overusing useEffect**: Prefer event handlers, CSS, or ref callbacks\n- ❌ **Premature memoization**: Only memoize when there's a measurable\n  performance benefit\n- ❌ **Barrel imports**: Import only what you need\n- ❌ **Ignoring TypeScript types**: Use Route types for type safety\n- ❌ **Not handling loading states**: Use React Router's navigation states\n- ❌ **Large monolithic components**: Break components into smaller, focused\n  pieces\n- ❌ **Not using error boundaries**: Always add error boundaries to routes\n- ❌ **Client-side routing when server-side works**: Prefer server-side data\n  fetching\n- ❌ **Data fetching waterfalls**: Use `Promise.all()` to fetch data in parallel\n- ❌ **Fetching unnecessary data**: Only fetch what's needed for the initial\n  render\n- ❌ **Creating new objects in render**: Compute derived data in loaders or\n  memoize\n- ❌ **Not using React.memo for expensive lists**: Memoize list items for better\n  performance\n- ❌ **Not leveraging route-based code splitting**: React Router splits by route\n  automatically\n\n## References\n\n- [React Router Documentation](https://reactrouter.com/)\n- [React Documentation - You Might Not Need useEffect](https://react.dev/learn/you-might-not-need-an-effect)\n- [Conform Documentation](https://conform.guide/)\n- [Epic Stack Docs](https://www.epicweb.dev/docs)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/routes/` - Example routes using these patterns\n- `.cursor/rules/avoid-use-effect.mdc` - Epic Stack rule for avoiding useEffect\n","frontmatter":{"name":"epic-react-patterns","description":"Guide on React patterns, performance optimization, and code quality for Epic Stack","categories":["react","performance","patterns","optimization"]},"isInternal":false,"tokens":4110,"sizeBytes":16202},{"name":"SKILL.md","path":"docs/skills/epic-routing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-routing/SKILL.md","title":"epic-routing","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-routing\ndescription:\n  Guide on routing with React Router and react-router-auto-routes for Epic Stack\ncategories:\n  - routing\n  - react-router\n  - file-based-routing\n---\n\n# Epic Stack: Routing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create new routes or pages in an Epic Stack application\n- Implement nested layouts\n- Configure resource routes (routes without UI)\n- Work with route parameters and search params\n- Understand Epic Stack's file-based routing conventions\n- Implement loaders and actions in routes\n\n## Patterns and conventions\n\n### Routing Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Keep your route structure simple. Don't create\ncomplex nested routes unless you actually need them. Start simple and add\ncomplexity only when there's a clear benefit.\n\n**Avoid over-engineering** - Don't create abstractions or complex route\nstructures \"just in case\". Use the simplest structure that works for your\ncurrent needs.\n\n**Example - Simple route structure:**\n\n```typescript\n// ✅ Good - Simple, straightforward route\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n\n// ❌ Avoid - Over-engineered route structure\n// app/routes/users/$username/_layout.tsx\n// app/routes/users/$username/index.tsx\n// app/routes/users/$username/_components/UserHeader.tsx\n// app/routes/users/$username/_components/UserDetails.tsx\n// Unnecessary complexity for a simple user page\n```\n\n**Example - Add complexity only when needed:**\n\n```typescript\n// ✅ Good - Add nested routes only when you actually need them\n// If you have user notes, then nested routes make sense:\n// app/routes/users/$username/notes/_layout.tsx\n// app/routes/users/$username/notes/index.tsx\n// app/routes/users/$username/notes/$noteId.tsx\n\n// ❌ Avoid - Creating nested routes \"just in case\"\n// Don't create complex structures before you need them\n```\n\n### File-based routing with react-router-auto-routes\n\nEpic Stack uses `react-router-auto-routes` instead of React Router's standard\nconvention. This enables better organization and code co-location.\n\n**Basic structure:**\n\n```\napp/routes/\n├── _layout.tsx        # Layout for child routes\n├── index.tsx          # Root route (/)\n├── about.tsx          # Route /about\n└── users/\n    ├── _layout.tsx    # Layout for user routes\n    ├── index.tsx      # Route /users\n    └── $username/\n        └── index.tsx  # Route /users/:username\n```\n\n**Configuration in `app/routes.ts`:**\n\n```typescript\nimport { type RouteConfig } from '@react-router/dev/routes'\nimport { autoRoutes } from 'react-router-auto-routes'\n\nexport default autoRoutes({\n\tignoredRouteFiles: [\n\t\t'.*',\n\t\t'**/*.css',\n\t\t'**/*.test.{js,jsx,ts,tsx}',\n\t\t'**/__*.*',\n\t\t'**/*.server.*', // Co-located server utilities\n\t\t'**/*.client.*', // Co-located client utilities\n\t],\n}) satisfies RouteConfig\n```\n\n### Route Groups\n\nRoute groups are folders that start with `_` and don't affect the URL but help\norganize related code.\n\n**Common examples:**\n\n- `_auth/` - Authentication routes (login, signup, etc.)\n- `_marketing/` - Marketing pages (home, about, etc.)\n- `_seo/` - SEO routes (sitemap, robots.txt)\n\n**Example:**\n\n```\napp/routes/\n├── _auth/\n│   ├── login.tsx          # URL: /login\n│   ├── signup.tsx         # URL: /signup\n│   └── forgot-password.tsx # URL: /forgot-password\n└── _marketing/\n    ├── index.tsx          # URL: /\n    └── about.tsx          # URL: /about\n```\n\n### Route Parameters\n\nUse `$` to indicate route parameters:\n\n**Syntax:**\n\n- `$param.tsx` → `:param` in URL\n- `$username.tsx` → `:username` in URL\n\n**Example route with parameter:**\n\n```typescript\n// app/routes/users/$username/index.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst username = params.username // Type-safe!\n\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username },\n\t})\n\n\treturn { user }\n}\n```\n\n### Nested Layouts with `_layout.tsx`\n\nUse `_layout.tsx` to create shared layouts for child routes.\n\n**Example:**\n\n```typescript\n// app/routes/users/$username/notes/_layout.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst owner = await prisma.user.findFirst({\n\t\twhere: { username: params.username },\n\t})\n\treturn { owner }\n}\n\nexport default function NotesLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<main className=\"container\">\n\t\t\t<h1>{loaderData.owner.name}'s Notes</h1>\n\t\t\t<Outlet /> {/* Child routes render here */}\n\t\t</main>\n\t)\n}\n```\n\nChild routes (`$noteId.tsx`, `index.tsx`, etc.) will render where `<Outlet />`\nis.\n\n### Resource Routes (Routes without UI)\n\nResource routes don't render UI; they only return data or perform actions.\n\n**Characteristics:**\n\n- Don't export a `default` component\n- Export `loader` or `action` or both\n- Useful for APIs, downloads, webhooks, etc.\n\n**Example:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check application health\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Check DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Loaders and Actions\n\n**Loaders** - Load data before rendering (GET requests) **Actions** - Handle\ndata mutations (POST, PUT, DELETE)\n\n**Loader pattern:**\n\n```typescript\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function RouteComponent({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Use loaderData.data */}</div>\n}\n```\n\n**Action pattern:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// Validate and process data\n\tawait prisma.something.create({\n\t\tdata: { /* ... */ },\n\t})\n\n\treturn redirect('/success')\n}\n\nexport default function RouteComponent() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Search Params\n\nAccess query parameters using `useSearchParams`:\n\n```typescript\nimport { useSearchParams } from 'react-router'\n\nexport default function SearchPage() {\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\tconst query = searchParams.get('q') || ''\n\tconst page = Number(searchParams.get('page') || '1')\n\n\treturn (\n\t\t<div>\n\t\t\t<input\n\t\t\t\tvalue={query}\n\t\t\t\tonChange={(e) => setSearchParams({ q: e.target.value })}\n\t\t\t/>\n\t\t\t{/* Results */}\n\t\t</div>\n\t)\n}\n```\n\n### Code Co-location\n\nEpic Stack encourages placing related code close to where it's used.\n\n**Typical structure:**\n\n```\napp/routes/users/$username/notes/\n├── _layout.tsx              # Layout with loader\n├── index.tsx                # Notes list\n├── $noteId.tsx              # Note view\n├── $noteId_.edit.tsx        # Edit note\n├── +shared/                 # Code shared between routes\n│   └── note-editor.tsx      # Shared editor\n└── $noteId.server.ts        # Server-side utilities\n```\n\nThe `+` prefix indicates co-located modules that are not routes.\n\n### Naming Conventions\n\n- `_layout.tsx` - Layout for child routes\n- `index.tsx` - Root route of the segment\n- `$param.tsx` - Route parameter\n- `$param_.action.tsx` - Route with parameter + action (using `_`)\n- `[.]ext.tsx` - Resource route (e.g., `robots[.]txt.ts`)\n\n## Common examples\n\n### Example 1: Create a basic route with layout\n\n```typescript\n// app/routes/products/_layout.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst categories = await prisma.category.findMany()\n\treturn { categories }\n}\n\nexport default function ProductsLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<nav>\n\t\t\t\t{loaderData.categories.map(cat => (\n\t\t\t\t\t<Link key={cat.id} to={`/products/${cat.slug}`}>\n\t\t\t\t\t\t{cat.name}\n\t\t\t\t\t</Link>\n\t\t\t\t))}\n\t\t\t</nav>\n\t\t\t<Outlet />\n\t\t</div>\n\t)\n}\n\n// app/routes/products/index.tsx\nexport default function ProductsIndex() {\n\treturn <div>Products list</div>\n}\n```\n\n### Example 2: Route with dynamic parameter\n\n```typescript\n// app/routes/products/$slug.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst product = await prisma.product.findUnique({\n\t\twhere: { slug: params.slug },\n\t})\n\n\tif (!product) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\treturn { product }\n}\n\nexport default function ProductPage({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.product.name}</h1>\n\t\t\t<p>{loaderData.product.description}</p>\n\t\t</div>\n\t)\n}\n\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>Product \"{params.slug}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### Example 3: Resource route for download\n\n```typescript\n// app/routes/resources/download-report.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst report = await generateReport(userId)\n\n\treturn new Response(report, {\n\t\theaders: {\n\t\t\t'Content-Type': 'application/pdf',\n\t\t\t'Content-Disposition': 'attachment; filename=\"report.pdf\"',\n\t\t},\n\t})\n}\n```\n\n### Example 4: Route with multiple nested parameters\n\n```typescript\n// app/routes/users/$username/posts/$postId/comments/$commentId.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params contains: { username, postId, commentId }\n\tconst comment = await prisma.comment.findUnique({\n\t\twhere: { id: params.commentId },\n\t\tinclude: {\n\t\t\tpost: {\n\t\t\t\tinclude: { author: true },\n\t\t\t},\n\t\t},\n\t})\n\n\treturn { comment }\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Over-engineering route structure**: Keep routes simple - don't create\n  complex nested structures unless you actually need them\n- ❌ **Creating abstractions prematurely**: Start with simple routes, add\n  complexity only when there's a clear benefit\n- ❌ **Using React Router's standard convention**: Epic Stack uses\n  `react-router-auto-routes`, not the standard convention\n- ❌ **Exporting default component in resource routes**: Resource routes should\n  not export components\n- ❌ **Not using nested layouts when needed**: Use `_layout.tsx` when you have\n  shared UI, but don't create layouts unnecessarily\n- ❌ **Forgetting `<Outlet />` in layouts**: Without `<Outlet />`, child routes\n  won't render\n- ❌ **Using incorrect names for parameters**: Should be `$param.tsx`, not\n  `:param.tsx` or `[param].tsx`\n- ❌ **Mixing route groups with URLs**: Groups (`_auth/`) don't appear in the\n  URL\n- ❌ **Not validating params**: Always validate that parameters exist before\n  using them\n- ❌ **Duplicating route logic**: Use layouts and shared components, but only\n  when it reduces duplication\n\n## References\n\n- [Epic Stack Routing Docs](../epic-stack/docs/routing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [React Router Auto Routes](https://github.com/kenn/react-router-auto-routes)\n- `app/routes.ts` - Auto-routes configuration\n- `app/routes/users/$username/notes/_layout.tsx` - Example of nested layout\n- `app/routes/resources/healthcheck.tsx` - Example of resource route\n- `app/routes/_auth/login.tsx` - Example of route in route group\n","frontmatter":{"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"]},"isInternal":false,"tokens":2914,"sizeBytes":11865},{"name":"SKILL.md","path":"docs/skills/epic-security/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-security/SKILL.md","title":"epic-security","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-security\ndescription:\n  Guide on security practices including CSP, rate limiting, and session security\n  for Epic Stack\ncategories:\n  - security\n  - csp\n  - rate-limiting\n  - headers\n---\n\n# Epic Stack: Security\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure Content Security Policy (CSP)\n- Implement spam protection (honeypot)\n- Configure rate limiting\n- Manage session security\n- Implement input validation\n- Configure secure headers\n- Manage secrets\n\n## Patterns and conventions\n\n### Security Philosophy\n\nFollowing Epic Web principles:\n\n**Design to fail fast and early** - Validate security constraints as early as\npossible. Check authentication, authorization, and input validation before\nprocessing requests. Fail immediately with clear error messages rather than\nallowing potentially malicious data to flow through the system.\n\n**Optimize for the debugging experience** - When security checks fail, provide\nclear, actionable error messages that help developers understand what went\nwrong. Log security events with enough context to debug issues without exposing\nsensitive information.\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate security constraints early\nexport async function action({ request }: Route.ActionArgs) {\n\t// 1. Authenticate immediately - fail fast if not authenticated\n\tconst userId = await requireUserId(request)\n\n\t// 2. Validate input early - fail fast if invalid\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\t// 3. Check permissions early - fail fast if unauthorized\n\tawait requireUserWithPermission(request, 'create:note:own')\n\n\t// Only proceed if all security checks pass\n\tconst { title, content } = submission.value\n\t// ... create note\n}\n\n// ❌ Avoid - Security checks scattered or delayed\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\t// ... process data first\n\n\t// Security check at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed potentially malicious data\n\t\treturn json({ error: 'Unauthorized' }, { status: 401 })\n\t}\n}\n```\n\n**Example - Debugging-friendly error messages:**\n\n```typescript\n// ✅ Good - Clear error messages for debugging\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log with context for debugging\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tuserAgent: formData.get('user-agent'),\n\t\t\t\t// Don't log sensitive data\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n\n// ❌ Avoid - Generic or unhelpful errors\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\t// No context, hard to debug\n\t\tthrow new Response('Error', { status: 400 })\n\t}\n}\n```\n\n### Content Security Policy (CSP)\n\nEpic Stack uses CSP to prevent XSS and other attacks.\n\n**Configuration in `server/index.ts`:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Note:** By default, CSP is in `report-only` mode to avoid blocking resources\nduring development. In production, remove `reportOnly: true` to enable it fully.\n\n### Honeypot Fields\n\nEpic Stack uses honeypot fields to protect against spam bots.\n\n**En formularios públicos:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Resto de campos */}\n</Form>\n```\n\n**En el action (fail fast):**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Check honeypot first - fail fast if spam detected\n\tawait checkHoneypot(formData) // Lanza error si es spam\n\n\t// Only proceed if honeypot check passes\n\t// ... resto del código\n}\n```\n\n**Configuration:**\n\n```typescript\n// app/utils/honeypot.server.ts\nimport { Honeypot, SpamError } from 'remix-utils/honeypot/server'\n\nexport const honeypot = new Honeypot({\n\tvalidFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined,\n\tencryptionSeed: process.env.HONEYPOT_SECRET,\n})\n\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log for debugging (without sensitive data)\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n```\n\n### Rate Limiting\n\nEpic Stack uses `express-rate-limit` para prevenir abuso.\n\n**Basic configuration:**\n\n```typescript\n// server/index.ts\nimport rateLimit from 'express-rate-limit'\n\nconst rateLimitDefault = {\n\twindowMs: 60 * 1000, // 1 minute\n\tlimit: 1000, // 1000 requests per minute\n\tstandardHeaders: true,\n\tlegacyHeaders: false,\n\tvalidate: { trustProxy: false },\n\tkeyGenerator: (req: express.Request) => {\n\t\treturn req.get('fly-client-ip') ?? `${req.ip}`\n\t},\n}\n\nconst generalRateLimit = rateLimit(rateLimitDefault)\n```\n\n**Different levels of rate limiting:**\n\n```typescript\n// Stricter rate limit for sensitive routes\nconst strongestRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 10, // Only 10 requests per minute\n})\n\n// Strong rate limit for important actions\nconst strongRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 100, // 100 requests per minute\n})\n```\n\n**Apply to specific routes:**\n\n```typescript\napp.use((req, res, next) => {\n\tconst strongPaths = [\n\t\t'/login',\n\t\t'/signup',\n\t\t'/verify',\n\t\t'/admin',\n\t\t'/reset-password',\n\t]\n\n\tif (req.method !== 'GET' && req.method !== 'HEAD') {\n\t\tif (strongPaths.some((p) => req.path.includes(p))) {\n\t\t\treturn strongestRateLimit(req, res, next)\n\t\t}\n\t\treturn strongRateLimit(req, res, next)\n\t}\n\n\treturn generalRateLimit(req, res, next)\n})\n```\n\n**Note:** In tests and development, rate limiting is effectively disabled to\nallow fast tests.\n\n### Session Security\n\n**Secure session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true, // Prevents access from JavaScript\n\t\tsecrets: process.env.SESSION_SECRET.split(','), // Secret rotation\n\t\tsecure: process.env.NODE_ENV === 'production', // HTTPS only in production\n\t},\n})\n```\n\n**Security features:**\n\n- `httpOnly: true` - Prevents access from JavaScript (XSS protection)\n- `secure: true` - Only sends cookies over HTTPS in production\n- `sameSite: 'lax'` - CSRF protection\n- Secret rotation using array\n\n### Password Security\n\n**Hashing de passwords:**\n\n```typescript\nimport bcrypt from 'bcryptjs'\n\nexport async function getPasswordHash(password: string) {\n\tconst hash = await bcrypt.hash(password, 10) // 10 rounds\n\treturn hash\n}\n\nexport async function verifyUserPassword(\n\twhere: Pick<User, 'username'> | Pick<User, 'id'>,\n\tpassword: string,\n) {\n\tconst userWithPassword = await prisma.user.findUnique({\n\t\twhere,\n\t\tselect: { id: true, password: { select: { hash: true } } },\n\t})\n\n\tif (!userWithPassword || !userWithPassword.password) {\n\t\treturn null\n\t}\n\n\tconst isValid = await bcrypt.compare(password, userWithPassword.password.hash)\n\treturn isValid ? { id: userWithPassword.id } : null\n}\n```\n\n**Check common passwords (Have I Been Pwned):**\n\n```typescript\nimport { checkIsCommonPassword } from '#app/utils/auth.server.ts'\n\nconst isCommonPassword = await checkIsCommonPassword(password)\nif (isCommonPassword) {\n\tctx.addIssue({\n\t\tpath: ['password'],\n\t\tcode: 'custom',\n\t\tmessage: 'Password is too common',\n\t})\n}\n```\n\n### Input Validation y Sanitization\n\n**Always validate inputs with Zod:**\n\n```typescript\nimport { z } from 'zod'\n\nconst UserSchema = z.object({\n\temail: z\n\t\t.string()\n\t\t.email()\n\t\t.min(3)\n\t\t.max(100)\n\t\t.transform((val) => val.toLowerCase()),\n\tusername: z\n\t\t.string()\n\t\t.min(3)\n\t\t.max(20)\n\t\t.regex(/^[a-zA-Z0-9_]+$/),\n\tpassword: z.string().min(6).max(72), // bcrypt limit\n})\n\n// Validate before using\nconst result = UserSchema.safeParse(data)\nif (!result.success) {\n\treturn json({ errors: result.error.flatten() }, { status: 400 })\n}\n```\n\n**Sanitization:**\n\n- Use `.transform()` from Zod to sanitize data\n- Normalize emails to lowercase\n- Normalize usernames to lowercase\n- Clean whitespace\n\n### XSS Prevention\n\nReact prevents XSS automatically by escaping all values.\n\n**Never use `dangerouslySetInnerHTML` with user data:**\n\n```typescript\n// ❌ NEVER do this with user data\n<div dangerouslySetInnerHTML={{ __html: userContent }} />\n\n// ✅ React escapa automáticamente\n<div>{userContent}</div>\n```\n\n### Secure Headers\n\nEpic Stack uses Helmet for secure headers.\n\n**Configuration:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Included headers:**\n\n- X-Content-Type-Options: nosniff\n- X-Frame-Options: DENY\n- X-XSS-Protection: 1; mode=block\n- Referrer-Policy (configurable)\n\n### HTTPS Only\n\n**Redirect HTTP to HTTPS:**\n\n```typescript\napp.use((req, res, next) => {\n\tif (req.method !== 'GET') return next()\n\tconst proto = req.get('X-Forwarded-Proto')\n\tconst host = getHost(req)\n\tif (proto === 'http') {\n\t\tres.set('X-Forwarded-Proto', 'https')\n\t\tres.redirect(`https://${host}${req.originalUrl}`)\n\t\treturn\n\t}\n\tnext()\n})\n```\n\n### Secrets Management\n\n**Variables de entorno:**\n\n```bash\n# .env\nSESSION_SECRET=secret1,secret2,secret3 # Secret rotation\nHONEYPOT_SECRET=your-honeypot-secret\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**En Fly.io:**\n\n```bash\nfly secrets set SESSION_SECRET=\"secret1,secret2,secret3\"\nfly secrets set HONEYPOT_SECRET=\"your-secret\"\n```\n\n**Never commit secrets:**\n\n- Use `.env.example` to document required variables\n- `.env` is in `.gitignore`\n- Use `fly secrets` for production\n\n### Validación de Session Expiration (Fail Fast)\n\n**Always verify expiration early:**\n\n```typescript\nexport async function getUserId(request: Request) {\n\tconst authSession = await authSessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst sessionId = authSession.get(sessionKey)\n\n\t// Fail fast - return null immediately if no session\n\tif (!sessionId) return null\n\n\t// Verify expiration early - fail fast if expired\n\tconst session = await prisma.session.findUnique({\n\t\tselect: { userId: true },\n\t\twhere: {\n\t\t\tid: sessionId,\n\t\t\texpirationDate: { gt: new Date() }, // Verify expiration\n\t\t},\n\t})\n\n\t// Fail fast - destroy invalid session immediately\n\tif (!session?.userId) {\n\t\tthrow redirect('/', {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.destroySession(authSession),\n\t\t\t},\n\t\t})\n\t}\n\treturn session.userId\n}\n```\n\n## Common examples\n\n### Example 1: Public form with honeypot\n\n```typescript\n// app/routes/_auth/signup.tsx\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Spam protection\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// ... rest of code\n}\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<HoneypotInputs /> {/* Include in form */}\n\t\t\t{/* Rest of fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Custom rate limiting\n\n```typescript\n// server/index.ts\nconst apiRateLimit = rateLimit({\n\t...rateLimitDefault,\n\twindowMs: 60 * 1000,\n\tlimit: 100, // 100 requests per minute for API\n\tkeyGenerator: (req) => {\n\t\tconst apiKey = req.get('X-API-Key')\n\t\treturn apiKey ?? req.get('fly-client-ip') ?? req.ip\n\t},\n})\n\napp.use('/api', apiRateLimit)\n```\n\n### Example 3: Strict input validation\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const UsernameSchema = z\n\t.string({ required_error: 'Username is required' })\n\t.min(3, { message: 'Username is too short' })\n\t.max(20, { message: 'Username is too long' })\n\t.regex(/^[a-zA-Z0-9_]+$/, {\n\t\tmessage: 'Username can only include letters, numbers, and underscores',\n\t})\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long', // bcrypt limit\n\t})\n```\n\n### Example 4: Permission verification before actions\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Validate that user has permission\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Only after validating permissions\n\tawait prisma.note.delete({ where: { id: noteId } })\n\n\treturn redirect('/notes')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed security checks**: Validate authentication, authorization, and\n  input as early as possible - fail fast\n- ❌ **Generic error messages**: Provide clear, actionable error messages that\n  help with debugging (without exposing sensitive data)\n- ❌ **Forgetting honeypot in public forms**: Always include `HoneypotInputs` in\n  forms accessible without authentication\n- ❌ **Not validating session expiration**: Always verify `expirationDate` when\n  getting sessions - check early\n- ❌ **Using `dangerouslySetInnerHTML` with user data**: Never render user HTML\n  without sanitizing\n- ❌ **Not using rate limiting**: Protect sensitive routes with rate limiting\n- ❌ **Secrets in code**: Never hardcode secrets, use environment variables\n- ❌ **Not sanitizing inputs**: Always sanitize inputs with `.transform()` from\n  Zod\n- ❌ **Not validating common passwords**: Check passwords against Have I Been\n  Pwned\n- ❌ **Sessions without httpOnly**: Always use `httpOnly: true` in session\n  cookies\n- ❌ **Not using HTTPS in production**: Make sure to redirect HTTP to HTTPS\n- ❌ **CSP too permissive**: Review and adjust CSP according to your needs\n- ❌ **Not logging security events**: Log security failures with context for\n  debugging (without sensitive data)\n\n## References\n\n- [Epic Stack Security Docs](../epic-stack/docs/security.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [OWASP Top 10](https://owasp.org/www-project-top-ten/)\n- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)\n- `app/utils/honeypot.server.ts` - Honeypot implementation\n- `server/index.ts` - Server security configuration\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n- `docs/decisions/025-rate-limiting.md` - Rate limiting documentation\n- `docs/decisions/008-content-security-policy.md` - CSP documentation\n","frontmatter":{"name":"epic-security","description":"Guide on security practices including CSP, rate limiting, and session security for Epic Stack","categories":["security","csp","rate-limiting","headers"]},"isInternal":false,"tokens":3882,"sizeBytes":15557},{"name":"SKILL.md","path":"docs/skills/epic-testing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-testing/SKILL.md","title":"epic-testing","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-testing\ndescription: Guide on testing with Vitest and Playwright for Epic Stack\ncategories:\n  - testing\n  - vitest\n  - playwright\n  - e2e\n---\n\n# Epic Stack: Testing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write unit tests for utilities and components\n- Create E2E tests with Playwright\n- Test forms and validation\n- Test routes and loaders\n- Mock external services with MSW\n- Test authentication and permissions\n- Configure test database\n\n## Patterns and conventions\n\n### Testing Philosophy\n\nFollowing Epic Web principles:\n\n**Tests should resemble users** - Write tests that mirror how real users\ninteract with your application. Test user workflows, not implementation details.\nIf a user would click a button, your test should click that button. If a user\nwould see an error message, your test should check for that specific message.\n\n**Make assertions specific** - Be explicit about what you're testing. Instead of\nvague assertions, use specific, meaningful checks that clearly communicate the\nexpected behavior. This makes tests easier to understand and debug when they\nfail.\n\n**Example - Tests that resemble users:**\n\n```typescript\n// ✅ Good - Tests user workflow\ntest('User can sign up and create their first note', async ({ page, navigate }) => {\n\t// User visits signup page\n\tawait navigate('/signup')\n\n\t// User fills out form like a real person would\n\tawait page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com')\n\tawait page.getByRole('textbox', { name: /username/i }).fill('newuser')\n\tawait page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123')\n\tawait page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123')\n\n\t// User submits form\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// User is redirected to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n\n\t// User creates their first note\n\tawait navigate('/notes/new')\n\tawait page.getByRole('textbox', { name: /title/i }).fill('My First Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('This is my first note!')\n\tawait page.getByRole('button', { name: /create/i }).click()\n\n\t// User sees their note\n\tawait expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible()\n\tawait expect(page.getByText('This is my first note!')).toBeVisible()\n})\n\n// ❌ Avoid - Testing implementation details\ntest('Signup form calls API endpoint', async ({ page }) => {\n\t// This tests implementation, not user experience\n\tconst response = await page.request.post('/signup', { data: {...} })\n\texpect(response.status()).toBe(200)\n})\n```\n\n**Example - Specific assertions:**\n\n```typescript\n// ✅ Good - Specific assertions\ntest('Form shows specific validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Specific error messages that users would see\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/username must be at least 3 characters/i),\n\t).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/password must be at least 6 characters/i),\n\t).toBeVisible()\n})\n\n// ❌ Avoid - Vague assertions\ntest('Form shows errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Too vague - what errors? where?\n\texpect(page.locator('.error')).toBeVisible()\n})\n```\n\n### Two Types of Tests\n\nEpic Stack uses two types of tests:\n\n1. **Unit Tests with Vitest** - Tests for individual components and utilities\n2. **E2E Tests with Playwright** - End-to-end tests of the complete flow\n\n### Unit Tests with Vitest\n\n**Basic setup:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { myUtil } from './my-util.ts'\n\ndescribe('myUtil', () => {\n\tit('should do something', () => {\n\t\texpect(myUtil('input')).toBe('expected')\n\t})\n})\n```\n\n**Testing con DOM:**\n\n```typescript\nimport { describe, expect, it } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { MyComponent } from './my-component.tsx'\n\ndescribe('MyComponent', () => {\n\tit('should render correctly', () => {\n\t\trender(<MyComponent />)\n\t\texpect(screen.getByText('Hello')).toBeInTheDocument()\n\t})\n})\n```\n\n### E2E Tests with Playwright\n\n**Basic setup:**\n\n```typescript\n// tests/e2e/my-feature.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\n\ntest('Users can do something', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/my-page')\n\n\t// Interact with the page\n\tawait page.getByRole('button', { name: /Submit/i }).click()\n\n\t// Verificar resultado\n\tawait expect(page).toHaveURL('/success')\n})\n```\n\n### Login Fixture\n\nEpic Stack provides a `login` fixture for authenticated tests.\n\n**Use login fixture:**\n\n```typescript\ntest('Protected route', async ({ page, navigate, login }) => {\n\tconst user = await login() // Creates user and session automatically\n\tawait navigate('/protected')\n\n\t// User is authenticated\n\tawait expect(page.getByText(`Welcome ${user.username}`)).toBeVisible()\n})\n```\n\n**Login with options:**\n\n```typescript\nconst user = await login({\n\tusername: 'testuser',\n\temail: 'test@example.com',\n\tpassword: 'password123',\n})\n```\n\n**Note:** The user is automatically deleted when the test completes.\n\n### Insert User without Login\n\nTo create user without authentication:\n\n```typescript\ntest('Public content', async ({ page, navigate, insertNewUser }) => {\n\tconst user = await insertNewUser({\n\t\tusername: 'publicuser',\n\t\temail: 'public@example.com',\n\t})\n\n\tawait navigate(`/users/${user.username}`)\n\tawait expect(page.getByText(user.username)).toBeVisible()\n})\n```\n\n### Navigate Helper\n\nUse the `navigate` helper to navigate with type-safety:\n\n```typescript\n// Type-safe navigation\nawait navigate('/users/:username/notes', { username: user.username })\nawait navigate('/users/:username/notes/:noteId', {\n\tusername: user.username,\n\tnoteId: note.id,\n})\n\n// Also works with routes without parameters\nawait navigate('/login')\n```\n\n### Test Database\n\nEpic Stack uses a separate test database.\n\n**Automatic configuration:**\n\n- The test database is configured automatically\n- It's cleaned between tests\n- Data created in tests is automatically deleted\n\n**Create data in tests:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n\ntest('User can see notes', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\n\t// Create note in database\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test Content',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: user.username,\n\t\tnoteId: note.id,\n\t})\n\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### MSW (Mock Service Worker)\n\nEpic Stack uses MSW to mock external services.\n\n**Mock example:**\n\n```typescript\n// tests/mocks/github.ts\nimport { http, HttpResponse } from 'msw'\n\nexport const handlers = [\n\thttp.get('https://api.github.com/user', () => {\n\t\treturn HttpResponse.json({\n\t\t\tid: '123',\n\t\t\tlogin: 'testuser',\n\t\t\temail: 'test@example.com',\n\t\t})\n\t}),\n]\n```\n\n**Use in tests:** Mocks are automatically applied when `MOCKS=true` is\nconfigured.\n\n### Testing Forms\n\n**Test form:**\n\n```typescript\ntest('User can submit form', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/notes/new')\n\n\t// Fill form\n\tawait page.getByRole('textbox', { name: /title/i }).fill('New Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Note content')\n\n\t// Submit\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar redirect\n\tawait expect(page).toHaveURL(new RegExp('/users/.*/notes/.*'))\n})\n```\n\n**Test validation:**\n\n```typescript\ntest('Form shows validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit sin llenar\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar errores\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n})\n```\n\n### Testing Loaders\n\n**Test loader:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { loader } from '../routes/my-route.ts'\nimport { prisma } from '../utils/db.server.ts'\n\ndescribe('loader', () => {\n\tit('should load data', async () => {\n\t\t// Create data\n\t\tconst user = await prisma.user.create({\n\t\t\tdata: {\n\t\t\t\temail: 'test@example.com',\n\t\t\t\tusername: 'testuser',\n\t\t\t\troles: { connect: { name: 'user' } },\n\t\t\t},\n\t\t})\n\n\t\t// Mock request\n\t\tconst request = new Request('http://localhost/my-route')\n\n\t\t// Execute loader\n\t\tconst result = await loader({ request, params: {}, context: {} })\n\n\t\t// Verify result\n\t\texpect(result.data).toBeDefined()\n\t})\n})\n```\n\n### Testing Actions\n\n**Test action:**\n\n```typescript\n// tests/e2e/notes.test.ts\ntest('User can create note', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\n\tconst formData = new FormData()\n\tformData.set('title', 'Test Note')\n\tformData.set('content', 'Test Content')\n\n\tawait page.getByRole('textbox', { name: /title/i }).fill('Test Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Test Content')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify that note was created\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### Testing Permissions\n\n**Test permissions:**\n\n```typescript\ntest('Only owner can delete note', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\tconst owner = await login()\n\tconst otherUser = await insertNewUser()\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test',\n\t\t\townerId: owner.id,\n\t\t},\n\t})\n\n\t// Login as other user\n\tconst session = await createSession(otherUser.id)\n\tawait page.context().addCookies([getCookie(session)])\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: owner.username,\n\t\tnoteId: note.id,\n\t})\n\n\t// Verify that can't delete\n\tawait expect(page.getByRole('button', { name: /delete/i })).not.toBeVisible()\n})\n```\n\n### DB Helpers\n\n**Create user:**\n\n```typescript\nimport { createUser } from '#tests/db-utils.ts'\n\nconst userData = createUser() // Generates unique random data\n```\n\n**Create password:**\n\n```typescript\nimport { createPassword } from '#tests/db-utils.ts'\n\nconst password = createPassword('mypassword') // { hash: '...' }\n```\n\n### Wait For Helper\n\nTo wait for async conditions:\n\n```typescript\nimport { waitFor } from '#tests/playwright-utils.ts'\n\nawait waitFor(\n\tasync () => {\n\t\tconst element = await page.getByText('Content loaded').first()\n\t\texpect(element).toBeVisible()\n\t\treturn element\n\t},\n\t{ timeout: 5000, errorMessage: 'Content never loaded' },\n)\n```\n\n### Testing GitHub OAuth\n\n**Prepare GitHub user:**\n\n```typescript\ntest('User can login with GitHub', async ({\n\tpage,\n\tnavigate,\n\tprepareGitHubUser,\n}) => {\n\tconst ghUser = await prepareGitHubUser()\n\n\tawait navigate('/login')\n\tawait page.getByRole('link', { name: /github/i }).click()\n\n\t// GitHub user is automatically prepared\n\tawait expect(page).toHaveURL('/onboarding/github')\n})\n```\n\n## Common examples\n\n### Example 1: Complete E2E test (resembling user workflow)\n\n```typescript\n// tests/e2e/notes.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { faker } from '@faker-js/faker'\n\ntest('Users can create, edit, and delete notes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n}) => {\n\t// User logs in (realistic workflow)\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\t// User creates a new note (clicking link, filling form, submitting)\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\tconst newNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(newNote.title)\n\tawait page.getByRole('textbox', { name: /content/i }).fill(newNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees their note with correct title and content\n\tawait expect(page.getByRole('heading', { name: newNote.title })).toBeVisible()\n\tawait expect(page.getByText(newNote.content)).toBeVisible()\n\tconst noteUrl = page.url()\n\tconst noteId = noteUrl.split('/').pop()\n\n\t// User edits the note (clicking edit, updating fields, saving)\n\tawait page.getByRole('link', { name: /edit/i }).click()\n\tconst updatedNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title)\n\tawait page\n\t\t.getByRole('textbox', { name: /content/i })\n\t\t.fill(updatedNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees updated content\n\tawait expect(\n\t\tpage.getByRole('heading', { name: updatedNote.title }),\n\t).toBeVisible()\n\tawait expect(page.getByText(updatedNote.content)).toBeVisible()\n\n\t// User deletes the note (clicking delete button)\n\tawait page.getByRole('button', { name: /delete/i }).click()\n\n\t// Specific assertion: user is redirected back to notes list\n\tawait expect(page).toHaveURL(`/users/${user.username}/notes`)\n\tawait expect(page.getByText(updatedNote.title)).not.toBeVisible()\n})\n```\n\n### Example 2: Unit test for utility\n\n```typescript\n// app/utils/misc.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { cn } from './misc.tsx'\n\ndescribe('cn', () => {\n\tit('should merge class names', () => {\n\t\texpect(cn('foo', 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', undefined, 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')\n\t})\n})\n```\n\n### Example 3: Form validation test\n\n```typescript\n// tests/e2e/signup.test.ts\ntest('Signup form validation', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit without filling\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify errors\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\n\t// Fill invalid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('invalid')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify email error\n\tawait expect(page.getByText(/email is invalid/i)).toBeVisible()\n\n\t// Fill valid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('test@example.com')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify redirect to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n})\n```\n\n### Example 4: Permissions test\n\n```typescript\n// tests/e2e/permissions.test.ts\ntest('Only admin can access admin routes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\t// Test with normal user\n\tconst normalUser = await login()\n\n\tawait navigate('/admin/users')\n\n\t// Should redirect or show error\n\tawait expect(page).toHaveURL('/') // Or verify error message\n\n\t// Test with admin\n\tawait page.context().clearCookies()\n\tconst admin = await insertNewUser()\n\tawait prisma.user.update({\n\t\twhere: { id: admin.id },\n\t\tdata: {\n\t\t\troles: {\n\t\t\t\tconnect: { name: 'admin' },\n\t\t\t},\n\t\t},\n\t})\n\n\t// Login as admin\n\tconst adminSession = await createSession(admin.id)\n\tawait page.context().addCookies([getCookie(adminSession)])\n\n\tawait navigate('/admin/users')\n\n\t// Now should work\n\tawait expect(page.getByText('All Users')).toBeVisible()\n})\n```\n\n## Common mistakes to avoid\n\n- ❌ **Testing implementation details instead of user workflows**: Write tests\n  that mirror how users actually use your app\n- ❌ **Vague assertions**: Use specific, meaningful assertions that clearly\n  communicate expected behavior\n- ❌ **Not cleaning data after tests**: Epic Stack cleans automatically, but\n  make sure not to depend on data between tests\n- ❌ **Assuming execution order**: Tests must be independent\n- ❌ **Not using fixtures**: Use `login`, `insertNewUser`, etc. instead of\n  creating everything manually\n- ❌ **Hardcoding data**: Use `faker` to generate unique data\n- ❌ **Not waiting for elements**: Use `expect` with `toBeVisible()` instead of\n  assuming it exists\n- ❌ **Not using type-safe navigation**: Use `navigate` helper instead of\n  `page.goto()` directly\n- ❌ **Forgetting MSW in tests**: External services are automatically mocked\n  when `MOCKS=true`\n- ❌ **Not testing error cases**: Test both happy path and errors\n- ❌ **Testing internal state instead of user-visible behavior**: Focus on what\n  users see and do\n\n## References\n\n- [Epic Stack Testing Docs](../epic-stack/docs/testing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Vitest Documentation](https://vitest.dev/)\n- [Playwright Documentation](https://playwright.dev/)\n- [Testing Library](https://testing-library.com/)\n- [MSW](https://mswjs.io/)\n- `tests/playwright-utils.ts` - Playwright fixtures and helpers\n- `tests/db-utils.ts` - DB helpers for tests\n- `tests/e2e/` - E2E test examples\n- `app/utils/*.test.ts` - Unit test examples\n","frontmatter":{"name":"epic-testing","description":"Guide on testing with Vitest and Playwright for Epic Stack","categories":["testing","vitest","playwright","e2e"]},"isInternal":false,"tokens":4167,"sizeBytes":17111},{"name":"SKILL.md","path":"docs/skills/epic-ui-guidelines/SKILL.md","rawUrl":"https://raw.githubusercontent.com/epicweb-dev/epic-stack/HEAD/docs/skills/epic-ui-guidelines/SKILL.md","title":"epic-ui-guidelines","category":"anthropic-skill","format":"markdown","content":"---\nname: epic-ui-guidelines\ndescription:\n  Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack\ncategories:\n  - ui\n  - accessibility\n  - tailwind\n  - radix-ui\n---\n\n# Epic Stack: UI Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create accessible UI components\n- Follow Epic Stack design patterns\n- Use Tailwind CSS effectively\n- Implement semantic HTML\n- Add ARIA attributes correctly\n- Create responsive layouts\n- Ensure proper form accessibility\n- Follow Epic Stack's UI component conventions\n\n## Patterns and conventions\n\n### UI Philosophy\n\nFollowing Epic Web principles:\n\n**Software is built for people, by people** - Accessibility isn't about checking\nboxes or meeting standards. It's about creating software that works for real\npeople with diverse needs, abilities, and contexts. Every UI decision should\nprioritize the human experience over technical convenience.\n\nAccessibility is not optional - it's how we ensure our software serves all\nusers, not just some. When you make UI accessible, you're making it better for\neveryone: clearer labels help all users, keyboard navigation helps power users,\nand semantic HTML helps search engines.\n\n**Example - Human-centered approach:**\n\n```typescript\n// ✅ Good - Built for people\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<Field\n\t\t\t\tlabelProps={{\n\t\t\t\t\thtmlFor: fields.title.id,\n\t\t\t\t\tchildren: 'Note Title', // Clear, human-readable label\n\t\t\t\t}}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.title),\n\t\t\t\t\tplaceholder: 'Enter a descriptive title', // Helpful guidance\n\t\t\t\t\tautoFocus: true, // Saves time for users\n\t\t\t\t}}\n\t\t\t\terrors={fields.title.errors} // Clear error messages\n\t\t\t/>\n\t\t</Form>\n\t)\n}\n\n// ❌ Avoid - Technical convenience over user experience\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<input name=\"title\" /> {/* No label, no guidance, no accessibility */}\n\t\t</Form>\n\t)\n}\n```\n\n### Semantic HTML\n\n**✅ Good - Use semantic elements:**\n\n```typescript\nfunction UserCard({ user }: { user: User }) {\n\treturn (\n\t\t<article>\n\t\t\t<header>\n\t\t\t\t<h2>{user.name}</h2>\n\t\t\t</header>\n\t\t\t<p>{user.bio}</p>\n\t\t\t<footer>\n\t\t\t\t<time dateTime={user.createdAt}>{formatDate(user.createdAt)}</time>\n\t\t\t</footer>\n\t\t</article>\n\t)\n}\n```\n\n**❌ Avoid - Generic divs:**\n\n```typescript\n// ❌ Don't use divs for everything\n<div>\n\t<div>{user.name}</div>\n\t<div>{user.bio}</div>\n\t<div>{formatDate(user.createdAt)}</div>\n</div>\n```\n\n### Form Accessibility\n\n**✅ Good - Always use labels:**\n\n```typescript\nimport { Field } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\nThe `Field` component automatically:\n\n- Associates labels with inputs using `htmlFor` and `id`\n- Adds `aria-invalid` when there are errors\n- Adds `aria-describedby` pointing to error messages\n- Ensures proper error announcement\n\n**❌ Avoid - Unlabeled inputs:**\n\n```typescript\n// ❌ Don't forget labels\n<input type=\"email\" name=\"email\" />\n```\n\n### ARIA Attributes\n\n**✅ Good - Use ARIA appropriately:**\n\n```typescript\n// Epic Stack's Field component handles this automatically\n<Field\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t// aria-invalid and aria-describedby are added automatically\n\t}}\n\terrors={fields.email.errors} // Error messages are linked via aria-describedby\n/>\n```\n\n**✅ Good - ARIA for custom components:**\n\n```typescript\nfunction LoadingButton({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) {\n\treturn (\n\t\t<button aria-busy={isLoading} disabled={isLoading}>\n\t\t\t{isLoading ? 'Loading...' : children}\n\t\t</button>\n\t)\n}\n```\n\n### Using Radix UI Components\n\nEpic Stack uses Radix UI for accessible, unstyled components.\n\n**✅ Good - Use Radix primitives:**\n\n```typescript\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button } from '#app/components/ui/button.tsx'\n\nfunction MyDialog() {\n\treturn (\n\t\t<Dialog.Root>\n\t\t\t<Dialog.Trigger asChild>\n\t\t\t\t<Button>Open Dialog</Button>\n\t\t\t</Dialog.Trigger>\n\t\t\t<Dialog.Portal>\n\t\t\t\t<Dialog.Overlay className=\"fixed inset-0 bg-black/50\" />\n\t\t\t\t<Dialog.Content className=\"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6\">\n\t\t\t\t\t<Dialog.Title>Dialog Title</Dialog.Title>\n\t\t\t\t\t<Dialog.Description>Dialog description</Dialog.Description>\n\t\t\t\t\t<Dialog.Close asChild>\n\t\t\t\t\t\t<Button>Close</Button>\n\t\t\t\t\t</Dialog.Close>\n\t\t\t\t</Dialog.Content>\n\t\t\t</Dialog.Portal>\n\t\t</Dialog.Root>\n\t)\n}\n```\n\nRadix components automatically handle:\n\n- Keyboard navigation\n- Focus management\n- ARIA attributes\n- Screen reader announcements\n\n### Tailwind CSS Patterns\n\n**✅ Good - Use Tailwind utility classes:**\n\n```typescript\nfunction Card({ children }: { children: React.ReactNode }) {\n\treturn (\n\t\t<div className=\"rounded-lg border border-gray-200 bg-white p-6 shadow-sm\">\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Tailwind responsive utilities:**\n\n```typescript\n<div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">\n\t{items.map(item => (\n\t\t<Card key={item.id}>{item.name}</Card>\n\t))}\n</div>\n```\n\n**✅ Good - Use Tailwind dark mode:**\n\n```typescript\n<div className=\"bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n### Error Handling in Forms\n\n**✅ Good - Display errors accessibly:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\tinputProps={getInputProps(fields.email, { type: 'email' })}\n\terrors={fields.email.errors} // Errors are displayed below input\n/>\n\n<ErrorList errors={form.errors} id={form.errorId} /> // Form-level errors\n```\n\nErrors are automatically:\n\n- Associated with inputs via `aria-describedby`\n- Announced to screen readers\n- Visually distinct with error styling\n\n### Focus Management\n\n**✅ Good - Visible focus indicators:**\n\n```typescript\n// Tailwind's default focus:ring handles this\n<button className=\"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Focus on form errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus() {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\tfirstErrorRef.current.focus()\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Keyboard Navigation\n\n**✅ Good - Keyboard accessible components:**\n\n```typescript\n// Radix components handle keyboard navigation automatically\n<Dialog.Trigger asChild>\n\t<Button>Open</Button>\n</Dialog.Trigger>\n\n// Custom components should support keyboard\n<button\n\tonKeyDown={(e) => {\n\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\thandleClick()\n\t\t}\n\t}}\n>\n\tCustom Button\n</button>\n```\n\n### Color Contrast\n\n**✅ Good - Use accessible color combinations:**\n\n```typescript\n// Use Tailwind's semantic colors that meet WCAG AA\n<div className=\"bg-white text-gray-900\"> // High contrast\n<div className=\"text-blue-600 hover:text-blue-700\"> // Accessible links\n```\n\n**❌ Avoid - Low contrast text:**\n\n```typescript\n// ❌ Don't use low contrast\n<div className=\"bg-gray-100 text-gray-200\"> // Very low contrast\n```\n\n### Responsive Design\n\n**✅ Good - Mobile-first approach:**\n\n```typescript\n<div className=\"\n\tflex flex-col gap-4\n\tmd:flex-row md:gap-8\n\tlg:gap-12\n\">\n\t{/* Content */}\n</div>\n```\n\n**✅ Good - Responsive typography:**\n\n```typescript\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">\n\tResponsive Heading\n</h1>\n```\n\n### Loading States\n\n**✅ Good - Accessible loading indicators:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SubmitButton() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"submit\"\n\t\t\tdisabled={isSubmitting}\n\t\t\taria-busy={isSubmitting}\n\t\t>\n\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t</button>\n\t)\n}\n```\n\n### Icon Usage\n\n**✅ Good - Decorative icons:**\n\n```typescript\nimport { Icon } from '#app/components/ui/icon.tsx'\n\n<button aria-label=\"Delete note\">\n\t<Icon name=\"trash\" />\n\t<span className=\"sr-only\">Delete note</span>\n</button>\n```\n\n**✅ Good - Semantic icons:**\n\n```typescript\n<button>\n\t<Icon name=\"check\" aria-hidden=\"true\" />\n\tSave\n</button>\n```\n\n### Skip Links\n\n**✅ Good - Add skip to main content:**\n\n```typescript\n// In your root layout\n<a href=\"#main-content\" className=\"sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white\">\n\tSkip to main content\n</a>\n\n<main id=\"main-content\">\n\t{/* Main content */}\n</main>\n```\n\n### Progressive Enhancement\n\n**✅ Good - Forms work without JavaScript:**\n\n```typescript\n// Conform forms work without JavaScript\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<Field {...props} />\n\t<StatusButton type=\"submit\">Submit</StatusButton>\n</Form>\n```\n\nForms automatically:\n\n- Submit via native HTML forms if JavaScript is disabled\n- Validate server-side\n- Show errors appropriately\n\n### Screen Reader Best Practices\n\n**✅ Good - Use semantic HTML first:**\n\n```typescript\n// ✅ Semantic HTML provides context automatically\n<nav aria-label=\"Main navigation\">\n\t<ul>\n\t\t<li><a href=\"/\">Home</a></li>\n\t\t<li><a href=\"/about\">About</a></li>\n\t</ul>\n</nav>\n```\n\n**✅ Good - Announce dynamic content:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SearchResults({ results }: { results: Result[] }) {\n\tconst navigation = useNavigation()\n\tconst isSearching = navigation.state === 'loading'\n\n\treturn (\n\t\t<div\n\t\t\trole=\"status\"\n\t\t\taria-live=\"polite\"\n\t\t\taria-atomic=\"true\"\n\t\t\tclassName=\"sr-only\"\n\t\t>\n\t\t\t{isSearching ? 'Searching...' : `${results.length} results found`}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Live regions for important updates:**\n\n```typescript\nfunction ToastContainer({ toasts }: { toasts: Toast[] }) {\n\treturn (\n\t\t<div aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n\t\t\t{toasts.map(toast => (\n\t\t\t\t<div key={toast.id} role=\"alert\">\n\t\t\t\t\t{toast.message}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n**ARIA live region options:**\n\n- `aria-live=\"polite\"` - For non-critical updates (search results, status\n  messages)\n- `aria-live=\"assertive\"` - For critical updates (errors, confirmations)\n- `aria-atomic=\"true\"` - Screen reader reads entire region on update\n- `aria-atomic=\"false\"` - Screen reader reads only changed parts\n\n### Keyboard Navigation Patterns\n\n**✅ Good - Tab order follows visual order:**\n\n```typescript\n// Elements appear in logical tab order\n<nav>\n\t<a href=\"/\">Home</a>\n\t<a href=\"/about\">About</a>\n\t<a href=\"/contact\">Contact</a>\n</nav>\n```\n\n**✅ Good - Keyboard shortcuts:**\n\n```typescript\nimport { useEffect } from 'react'\n\nfunction SearchDialog({ onClose }: { onClose: () => void }) {\n\tuseEffect(() => {\n\t\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tonClose()\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [onClose])\n\n\treturn <Dialog>{/* content */}</Dialog>\n}\n```\n\n**✅ Good - Focus trap in modals:**\n\n```typescript\n// Radix Dialog automatically handles focus trap\n<Dialog.Root>\n\t<Dialog.Content>\n\t\t{/* Focus is trapped inside dialog */}\n\t\t<Dialog.Close>Close</Dialog.Close>\n\t</Dialog.Content>\n</Dialog.Root>\n```\n\n### Focus Management for React Router\n\n**✅ Good - Focus on route changes:**\n\n```typescript\nimport { useEffect } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction RouteComponent() {\n\tconst navigation = useNavigation()\n\tconst mainRef = useRef<HTMLElement>(null)\n\n\tuseEffect(() => {\n\t\tif (navigation.state === 'idle' && mainRef.current) {\n\t\t\tmainRef.current.focus()\n\t\t}\n\t}, [navigation.state])\n\n\treturn (\n\t\t<main ref={mainRef} tabIndex={-1}>\n\t\t\t{/* Content */}\n\t\t</main>\n\t)\n}\n```\n\n**✅ Good - Focus on errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus({ actionData }: Route.ComponentProps) {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\t// Focus first error field\n\t\t\tfirstErrorRef.current.focus()\n\t\t\t// Announce error\n\t\t\tfirstErrorRef.current.setAttribute('aria-invalid', 'true')\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Typography and Readability\n\n**✅ Good - Readable text sizes:**\n\n```typescript\n// Use Tailwind's text size scale\n<p className=\"text-base md:text-lg\">Readable body text</p>\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">Clear headings</h1>\n```\n\n**✅ Good - Sufficient line height:**\n\n```typescript\n// Tailwind defaults provide good line height\n<p className=\"leading-relaxed\">Comfortable reading</p>\n```\n\n**❌ Avoid - Small or hard-to-read text:**\n\n```typescript\n// ❌ Don't use very small text\n<p className=\"text-xs\">Hard to read</p>\n```\n\n### Touch Target Sizes\n\n**✅ Good - Sufficient touch targets:**\n\n```typescript\n// Buttons should be at least 44x44px (touch target size)\n<button className=\"min-h-[44px] min-w-[44px] px-4 py-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Spacing between interactive elements:**\n\n```typescript\n<div className=\"flex gap-4\">\n\t<Button>Save</Button>\n\t<Button>Cancel</Button>\n</div>\n```\n\n### Internationalization (i18n) Considerations\n\n**✅ Good - Use semantic HTML for dates/times:**\n\n```typescript\n<time dateTime={note.createdAt.toISOString()}>\n\t{formatDate(note.createdAt)}\n</time>\n```\n\n**✅ Good - Use semantic HTML for numbers:**\n\n```typescript\n// Screen readers can pronounce numbers correctly\n<p>Total: <span aria-label={`${count} items`}>{count}</span></p>\n```\n\n**✅ Good - Language attributes:**\n\n```typescript\n// In root.tsx\n<html lang=\"en\">\n\t<body>\n\t\t{/* Content */}\n\t</body>\n</html>\n```\n\n### Dark Mode Accessibility\n\n**✅ Good - Maintain contrast in dark mode:**\n\n```typescript\n// Ensure sufficient contrast in both modes\n<div className=\"bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n**✅ Good - Respect user preference:**\n\n```typescript\n// Epic Stack automatically handles theme preference\n// Use semantic colors that work in both modes\n<button className=\"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600\">\n\tButton\n</button>\n```\n\n### Animation and Motion\n\n**✅ Good - Respect reduced motion:**\n\n```typescript\n// Tailwind automatically respects prefers-reduced-motion\n<div className=\"transition-transform duration-200 hover:scale-105 motion-reduce:transition-none\">\n\t{/* Animations disabled for users who prefer reduced motion */}\n</div>\n```\n\n**✅ Good - Use CSS for animations:**\n\n```typescript\n// ✅ CSS animations can be disabled via prefers-reduced-motion\n<div className=\"animate-fade-in\">\n\t{/* Content */}\n</div>\n\n// ❌ JavaScript animations may not respect user preferences\n```\n\n## Common mistakes to avoid\n\n- ❌ **Treating accessibility as a checklist**: Accessibility is about serving\n  real people, not just meeting standards\n- ❌ **Missing form labels**: Always use `Field` component which includes\n  labels - helps all users, not just screen reader users\n- ❌ **Using divs for semantic elements**: Use `<article>`, `<header>`, `<nav>`,\n  etc. - helps all users understand content structure\n- ❌ **Ignoring keyboard navigation**: Ensure all interactive elements are\n  keyboard accessible - helps power users and those who can't use a mouse\n- ❌ **Low color contrast**: Test color combinations for WCAG AA compliance -\n  helps users with visual impairments and in bright sunlight\n- ❌ **Missing ARIA attributes**: Use Epic Stack components which handle this\n  automatically\n- ❌ **Breaking focus management**: Let Radix components handle focus\n- ❌ **Not testing with screen readers**: Test with VoiceOver, NVDA, or JAWS -\n  understand how real users experience your UI\n- ❌ **Hiding content from screen readers**: Use `sr-only` instead of\n  `display: none` for screen reader only content\n- ❌ **Ignoring mobile users**: Always test on mobile devices - many users only\n  have mobile access\n- ❌ **Not using Tailwind's responsive utilities**: Use mobile-first responsive\n  design\n- ❌ **Not using live regions**: Use `aria-live` for dynamic content\n  announcements\n- ❌ **Small touch targets**: Ensure interactive elements are at least 44x44px -\n  helps users with motor impairments and on mobile\n- ❌ **Ignoring reduced motion**: Respect `prefers-reduced-motion` media query -\n  helps users with vestibular disorders\n- ❌ **Poor focus indicators**: Ensure focus is always visible - helps keyboard\n  users navigate\n- ❌ **Missing skip links**: Add skip to main content links for keyboard users\n\n## References\n\n- [Web Content Accessibility Guidelines (WCAG)](https://www.w3.org/WAI/WCAG21/quickref/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Radix UI Documentation](https://www.radix-ui.com/)\n- [Tailwind CSS Documentation](https://tailwindcss.com/)\n- [Epic Stack Forms Documentation](../epic-forms/SKILL.md)\n- `app/components/forms.tsx` - Accessible form components\n- `app/components/ui/` - Accessible UI components using Radix\n- `app/styles/tailwind.css` - Tailwind configuration\n","frontmatter":{"name":"epic-ui-guidelines","description":"Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack","categories":["ui","accessibility","tailwind","radix-ui"]},"isInternal":false,"tokens":4364,"sizeBytes":17382}],"systemPromptSnippet":"<yakaai_skills repo=\"epicweb-dev/epic-stack\">\n<!-- File: docs/routing.md (Tokens: ~1847 | Category: root-instruction) -->\n# Routing\n\nThe Epic Stack uses file-based routing with React Router. However, it's not\nusing the built-in routing convention of React Router and instead is using\n[react-router-auto-routes](https://github.com/kenn/react-router-auto-routes)\nwhich is a special implementation of the React Router convention that adds a few\nfeatures. You'll find it configured for the application in the `app/routes.ts`\nfile at the root of the app.\n\nWe'll defer to the `react-router-auto-routes` documentation for specifics, but\nan important thing for you to know as you get used to this convention is you can\nalways run `npx react-router routes` from the root of the app and it will output\nthe routes of your application in a JSX-like output that will reveal the routes\nthat will be generated based on your current file structure. Here's an example\nof the Epic Stack routes at the time of this writing:\n\n```\napp/routes\n├── $.tsx\n├── me.tsx\n├── _auth\n│   ├── forgot-password.tsx\n│   ├── login.tsx\n│   ├── logout.tsx\n│   ├── reset-password.tsx\n│   ├── signup.tsx\n│   ├── verify.tsx\n│   ├── auth.$provider\n│   │   ├── callback.ts\n│   │   └── index.ts\n│   ├── onboarding\n│   │   ├── $provider.tsx\n│   │   └── index.tsx\n│   └── webauthn\n│       ├── authentication.ts\n│       └── registration.ts\n├── _marketing\n│   ├── about.tsx\n│   ├── index.tsx\n│   ├── privacy.tsx\n│   ├── support.tsx\n│   ├── tos.tsx\n│   └── +logos\n│       ├── logos.ts\n│       └── ...\n├── _seo\n│   ├── robots[.]txt.ts\n│   └── sitemap[.]xml.ts\n├── admin\n│   └── cache\n│       ├── index.tsx\n│       ├── lru.$cacheKey.ts\n│       ├── sqlite.$cacheKey.ts\n│       └── sqlite.tsx\n├── resources\n│   ├── download-user-data.tsx\n│   ├── healthcheck.tsx\n│   ├── images.tsx\n│   └── theme-switch.tsx\n├── settings\n│   └── profile\n│       ├── _layout.tsx\n│       ├── change-email.tsx\n│       ├── connections.tsx\n│       ├── index.tsx\n│       ├── passkeys.tsx\n│       ├── password.tsx\n│       ├── password_.create.tsx\n│       ├── photo.tsx\n│       └── two-factor\n│           ├── _layout.tsx\n│           ├── disable.tsx\n│           ├── index.tsx\n│           └── verify.tsx\n└── users\n    ├── index.tsx\n    └── $username\n        ├── index.tsx\n        └── notes\n            ├── $noteId.tsx\n            ├── $noteId_.edit.tsx\n            ├── _layout.tsx\n            ├── index.tsx\n            └── new.tsx\n\n17 directories, 72 files\n```\n\n```tsx\n<Routes>\n\t<Route file=\"root.tsx\">\n\t\t<Route path=\"*\" file=\"routes/$.tsx\" />\n\t\t<Route\n\t\t\tpath=\"auth/:provider/callback\"\n\t\t\tfile=\"routes/_auth/auth.$provider/callback.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"auth/:provider\"\n\t\t\tindex\n\t\t\tfile=\"routes/_auth/auth.$provider/index.ts\"\n\t\t/>\n\t\t<Route path=\"forgot-password\" file=\"routes/_auth/forgot-password.tsx\" />\n\t\t<Route path=\"login\" file=\"routes/_auth/login.tsx\" />\n\t\t<Route path=\"logout\" file=\"routes/_auth/logout.tsx\" />\n\t\t<Route\n\t\t\tpath=\"onboarding/:provider\"\n\t\t\tfile=\"routes/_auth/onboarding/$provider.tsx\"\n\t\t/>\n\t\t<Route path=\"onboarding\" index file=\"routes/_auth/onboarding/index.tsx\" />\n\t\t<Route path=\"reset-password\" file=\"routes/_auth/reset-password.tsx\" />\n\t\t<Route path=\"signup\" file=\"routes/_auth/signup.tsx\" />\n\t\t<Route path=\"verify\" file=\"routes/_auth/verify.tsx\" />\n\t\t<Route\n\t\t\tpath=\"webauthn/authentication\"\n\t\t\tfile=\"routes/_auth/webauthn/authentication.ts\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"webauthn/registration\"\n\t\t\tfile=\"routes/_auth/webauthn/registration.ts\"\n\t\t/>\n\t\t<Route path=\"about\" file=\"routes/_marketing/about.tsx\" />\n\t\t<Route index file=\"routes/_marketing/index.tsx\" />\n\t\t<Route path=\"privacy\" file=\"routes/_marketing/privacy.tsx\" />\n\t\t<Route path=\"support\" file=\"routes/_marketing/support.tsx\" />\n\t\t<Route path=\"tos\" file=\"routes/_marketing/tos.tsx\" />\n\t\t<Route path=\"robots.txt\" file=\"routes/_seo/robots[.]txt.ts\" />\n\t\t<Route path=\"sitemap.xml\" file=\"routes/_seo/sitemap[.]xml.ts\" />\n\t\t<Route path=\"admin/cache\" index file=\"routes/admin/cache/index.tsx\" />\n\t\t<Route\n\t\t\tpath=\"admin/cache/lru/:cacheKey\"\n\t\t\tfile=\"routes/admin/cache/lru.$cacheKey.ts\"\n\t\t/>\n\t\t<Route path=\"admin/cache/sqlite\" file=\"routes/admin/cache/sqlite.tsx\">\n\t\t\t<Route path=\":cacheKey\" file=\"routes/admin/cache/sqlite.$cacheKey.ts\" />\n\t\t</Route>\n\t\t<Route path=\"me\" file=\"routes/me.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/download-user-data\"\n\t\t\tfile=\"routes/resources/download-user-data.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"resources/healthcheck\"\n\t\t\tfile=\"routes/resources/healthcheck.tsx\"\n\t\t/>\n\t\t<Route path=\"resources/images\" file=\"routes/resources/images.tsx\" />\n\t\t<Route\n\t\t\tpath=\"resources/theme-switch\"\n\t\t\tfile=\"routes/resources/theme-switch.tsx\"\n\t\t/>\n\t\t<Route path=\"settings/profile\" file=\"routes/settings/profile/_layout.tsx\">\n\t\t\t<Route\n\t\t\t\tpath=\"change-email\"\n\t\t\t\tfile=\"routes/settings/profile/change-email.tsx\"\n\t\t\t/>\n\t\t\t<Route\n\t\t\t\tpath=\"connections\"\n\t\t\t\tfile=\"routes/settings/profile/connections.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/settings/profile/index.tsx\" />\n\t\t\t<Route path=\"passkeys\" file=\"routes/settings/profile/passkeys.tsx\" />\n\t\t\t<Route path=\"password\" file=\"routes/settings/profile/password.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"password/create\"\n\t\t\t\tfile=\"routes/settings/profile/password_.create.tsx\"\n\t\t\t/>\n\t\t\t<Route path=\"photo\" file=\"routes/settings/profile/photo.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\"two-factor\"\n\t\t\t\tfile=\"routes/settings/profile/two-factor/_layout.tsx\"\n\t\t\t>\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"disable\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/disable.tsx\"\n\t\t\t\t/>\n\t\t\t\t<Route index file=\"routes/settings/profile/two-factor/index.tsx\" />\n\t\t\t\t<Route\n\t\t\t\t\tpath=\"verify\"\n\t\t\t\t\tfile=\"routes/settings/profile/two-factor/verify.tsx\"\n\t\t\t\t/>\n\t\t\t</Route>\n\t\t</Route>\n\t\t<Route\n\t\t\tpath=\"users/:username\"\n\t\t\tindex\n\t\t\tfile=\"routes/users/$username/index.tsx\"\n\t\t/>\n\t\t<Route\n\t\t\tpath=\"users/:username/notes\"\n\t\t\tfile=\"routes/users/$username/notes/_layout.tsx\"\n\t\t>\n\t\t\t<Route path=\":noteId\" file=\"routes/users/$username/notes/$noteId.tsx\" />\n\t\t\t<Route\n\t\t\t\tpath=\":noteId/edit\"\n\t\t\t\tfile=\"routes/users/$username/notes/$noteId_.edit.tsx\"\n\t\t\t/>\n\t\t\t<Route index file=\"routes/users/$username/notes/index.tsx\" />\n\t\t\t<Route path=\"new\" file=\"routes/users/$username/notes/new.tsx\" />\n\t\t</Route>\n\t\t<Route path=\"users\" index file=\"routes/users/index.tsx\" />\n\t</Route>\n</Routes>\n```\n\nBasically, react-router-auto-routes allows us to get the best of both worlds:\n\n- Colocation of routes to the code they use\n- Organized folder structure to keep routes together as needed\n\nIf you're familiar with the React Router routing convention, just think of it\nthis way, react-router-auto-routes takes `+` prefix for colocated modules.\n\n<!-- File: .cursor/rules/avoid-use-effect.mdc (Tokens: ~411 | Category: cursor-rule) -->\n---\ndescription: \nglobs: *.tsx,*.jsx\nalwaysApply: false\n---\n### Avoid useEffect\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use ref callbacks, event handlers with\n`flushSync`, css, `useSyncExternalStore`, etc.\n\n```tsx\n// This example was ripped from the docs:\n// ✅ Good\nfunction ProductPage({ product, addToCart }) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n\nuseEffect(() => {\n\tsetCount(count + 1)\n}, [count])\n\n// ❌ Avoid\nfunction ProductPage({ product, addToCart }) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to the shopping cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\taddToCart(product)\n\t\tnavigateTo('/checkout')\n\t}\n\t// ...\n}\n```\n\nThere are a lot more examples in the docs. `useEffect` is not banned or\nanything. There are just better ways to handle most cases.\n\nHere's an example of a situation where `useEffect` is appropriate:\n\n```tsx\n// ✅ Good\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\n\t\t\t// do something based on escape key being pressed\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n\n<!-- File: docs/skills/epic-auth/SKILL.md (Tokens: ~4463 | Category: anthropic-skill) -->\n---\nname: epic-auth\ndescription:\n  Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack\ncategories:\n  - authentication\n  - sessions\n  - oauth\n  - security\n---\n\n# Epic Stack: Authentication\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement user authentication\n- Work with sessions and cookies\n- Configure OAuth providers (GitHub, Google, etc.)\n- Implement 2FA (Two-Factor Authentication)\n- Implement WebAuthn/Passkeys\n- Handle login, signup, logout flows\n- Manage email verification\n- Implement password reset\n\n## Patterns and conventions\n\n### Authentication Philosophy\n\nFollowing Epic Web principles:\n\n**Least privilege** - Users should only have access to what they need, when they\nneed it. Sessions should have minimal permissions and expire appropriately.\nDon't grant more access than necessary.\n\n**Design to fail fast and early** - Validate authentication and authorization as\nearly as possible. Check session validity immediately, verify permissions before\nprocessing requests, and return clear errors quickly.\n\n**Example - Least privilege in sessions:**\n\n```typescript\n// ✅ Good - Minimal session data, explicit permissions\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId, // Only store user ID, not full user data\n\t},\n})\n\n// Session only grants access to this specific user\n// Permissions checked separately when needed\n\n// ❌ Avoid - Storing too much in session\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t\tuserRole: 'admin', // Don't store roles in session\n\t\tpermissions: ['all'], // Don't store permissions in session\n\t},\n})\n// Roles and permissions should be checked from database, not session\n```\n\n**Example - Fail fast authentication:**\n\n```typescript\n// ✅ Good - Validate authentication early\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check authentication immediately - fail fast\n\tconst userId = await requireUserId(request)\n\n\t// Check permissions early - fail fast\n\tawait requireUserWithPermission(request, 'read:note:own')\n\n\t// Only proceed if authenticated and authorized\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: userId },\n\t})\n\n\treturn { notes }\n}\n\n// ❌ Avoid - Delayed authentication check\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Process request first...\n\tconst notes = await prisma.note.findMany()\n\n\t// Check authentication at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed request\n\t\tthrow redirect('/login')\n\t}\n}\n```\n\n### Cookie-based Sessions\n\nEpic Stack uses cookie-based sessions for authentication. Sessions are stored in\nthe database and identified by signed cookies.\n\n**Session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nimport { createCookieSessionStorage } from 'react-router'\n\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true,\n\t\tsecrets: process.env.SESSION_SECRET.split(','),\n\t\tsecure: process.env.NODE_ENV === 'production',\n\t},\n})\n```\n\n### Get current user\n\n**Server-side:**\n\n```typescript\nimport { getUserId, requireUserId } from '#app/utils/auth.server.ts'\n\n// Get userId or null if not authenticated\nconst userId = await getUserId(request)\n\n// Require authenticated user (redirects to /login if not)\nconst userId = await requireUserId(request)\nconst userId = await requireUserId(request, { redirectTo: '/custom-login' })\n\n// Require that user is NOT authenticated\nimport { requireAnonymous } from '#app/utils/auth.server.ts'\nawait requireAnonymous(request) // Redirects to / if authenticated\n```\n\n**Client-side:**\n\n```typescript\nimport { useOptionalUser, useUser } from '#app/utils/user.ts'\n\n// Get user or undefined if not authenticated\nconst user = useOptionalUser()\n\n// Get authenticated user (throws error if not)\nconst user = useUser()\n```\n\n### Login with Email/Password\n\n**Validation schema:**\n\n```typescript\nconst LoginSchema = z.object({\n\tusername: UsernameSchema,\n\tpassword: z.string().min(1, 'Password is required'),\n\tredirectTo: z.string().optional(),\n\tremember: z.boolean().optional(),\n})\n```\n\n**Login action (fail fast):**\n\n```typescript\nimport { login } from '#app/utils/auth.server.ts'\nimport { handleNewSession } from './login.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate input early - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\t// Authenticate early - fail fast if invalid\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\t// Return error immediately - don't process further\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Only create session if authentication succeeded\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Signup with Email/Password\n\n**Signup action:**\n\n```typescript\nimport { signup } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate form...\n\n\tconst session = await signup({\n\t\temail,\n\t\tusername,\n\t\tpassword,\n\t\tname,\n\t})\n\n\t// Handle session and redirect...\n}\n```\n\n### OAuth Providers (GitHub, Google, etc.)\n\nEpic Stack uses `remix-auth` for OAuth providers.\n\n**Configure provider (GitHub example):**\n\n```typescript\n// app/utils/providers/github.server.ts\nimport { GitHubStrategy } from 'remix-auth-github'\n\nexport class GitHubProvider implements AuthProvider {\n\tgetAuthStrategy() {\n\t\treturn new GitHubStrategy(\n\t\t\t{\n\t\t\t\tclientID: process.env.GITHUB_CLIENT_ID,\n\t\t\t\tclientSecret: process.env.GITHUB_CLIENT_SECRET,\n\t\t\t\tcallbackURL: '/auth/github/callback',\n\t\t\t},\n\t\t\tasync ({ profile }) => {\n\t\t\t\t// Return user profile\n\t\t\t\treturn {\n\t\t\t\t\tid: profile.id,\n\t\t\t\t\temail: profile.emails[0].value,\n\t\t\t\t\tusername: profile.displayName,\n\t\t\t\t\tname: profile.displayName,\n\t\t\t\t}\n\t\t\t},\n\t\t)\n\t}\n}\n```\n\n**Callback handler:**\n\n```typescript\n// app/routes/_auth/auth.$provider/callback.ts\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst providerName = ProviderNameSchema.parse(params.provider)\n\tconst authResult = await authenticator.authenticate(providerName, request)\n\n\tif (!authResult.success) {\n\t\tthrow redirectWithToast('/login', {\n\t\t\ttitle: 'Auth Failed',\n\t\t\tdescription: `Error authenticating with ${providerName}`,\n\t\t\ttype: 'error',\n\t\t})\n\t}\n\n\tconst { data: profile } = authResult\n\n\t// Check if connection exists\n\tconst existingConnection = await prisma.connection.findUnique({\n\t\twhere: {\n\t\t\tproviderName_providerId: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t},\n\t\t},\n\t})\n\n\t// If exists, create session\n\tif (existingConnection) {\n\t\treturn makeSession({ request, userId: existingConnection.userId })\n\t}\n\n\t// If email exists, link account\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { email: profile.email.toLowerCase() },\n\t})\n\tif (user) {\n\t\tawait prisma.connection.create({\n\t\t\tdata: {\n\t\t\t\tproviderName,\n\t\t\t\tproviderId: String(profile.id),\n\t\t\t\tuserId: user.id,\n\t\t\t},\n\t\t})\n\t\treturn makeSession({ request, userId: user.id })\n\t}\n\n\t// New user, go to onboarding\n\t// ...\n}\n```\n\n### WebAuthn/Passkeys\n\nEpic Stack supports authentication with passkeys using WebAuthn.\n\n**Loader to generate options:**\n\n```typescript\n// app/routes/_auth/webauthn/authentication.ts\nimport { generateAuthenticationOptions } from '@simplewebauthn/server'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst config = getWebAuthnConfig(request)\n\tconst options = await generateAuthenticationOptions({\n\t\trpID: config.rpID,\n\t\tuserVerification: 'preferred',\n\t})\n\n\tconst cookieHeader = await passkeyCookie.serialize({\n\t\tchallenge: options.challenge,\n\t})\n\n\treturn Response.json(\n\t\t{ options },\n\t\t{\n\t\t\theaders: { 'Set-Cookie': cookieHeader },\n\t\t},\n\t)\n}\n```\n\n**Action to verify authentication:**\n\n```typescript\nimport { verifyAuthenticationResponse } from '@simplewebauthn/server'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst cookie = await passkeyCookie.parse(request.headers.get('Cookie'))\n\n\tif (!cookie?.challenge) {\n\t\tthrow new Error('Authentication challenge not found')\n\t}\n\n\tconst { authResponse } = await request.json()\n\tconst passkey = await prisma.passkey.findUnique({\n\t\twhere: { id: authResponse.id },\n\t\tinclude: { user: true },\n\t})\n\n\tconst verification = await verifyAuthenticationResponse({\n\t\tresponse: authResponse,\n\t\texpectedChallenge: cookie.challenge,\n\t\texpectedOrigin: config.origin,\n\t\texpectedRPID: config.rpID,\n\t\tcredential: {\n\t\t\tid: authResponse.id,\n\t\t\tpublicKey: passkey.publicKey,\n\t\t\tcounter: Number(passkey.counter),\n\t\t},\n\t})\n\n\tif (!verification.verified) {\n\t\tthrow new Error('Authentication verification failed')\n\t}\n\n\t// Actualizar counter\n\tawait prisma.passkey.update({\n\t\twhere: { id: passkey.id },\n\t\tdata: { counter: BigInt(verification.authenticationInfo.newCounter) },\n\t})\n\n\t// Create sesión\n\tconst session = await prisma.session.create({\n\t\tdata: {\n\t\t\texpirationDate: getSessionExpirationDate(),\n\t\t\tuserId: passkey.userId,\n\t\t},\n\t})\n\n\treturn handleNewSession({ request, session, remember: true })\n}\n```\n\n### Two-Factor Authentication (2FA) with TOTP\n\nEpic Stack uses TOTP (Time-based One-Time Password) para 2FA.\n\n**Check if user has 2FA:**\n\n```typescript\nconst verification = await prisma.verification.findUnique({\n\twhere: {\n\t\ttarget_type: {\n\t\t\ttarget: session.userId,\n\t\t\ttype: twoFAVerificationType,\n\t\t},\n\t},\n})\nconst userHasTwoFactor = Boolean(verification)\n```\n\n**Handle session with 2FA:**\n\n```typescript\nexport async function handleNewSession({\n\trequest,\n\tsession,\n\tredirectTo,\n\tremember,\n}: {\n\trequest: Request\n\tsession: { userId: string; id: string; expirationDate: Date }\n\tredirectTo?: string\n\tremember: boolean\n}) {\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: {\n\t\t\ttarget_type: {\n\t\t\t\ttarget: session.userId,\n\t\t\t\ttype: twoFAVerificationType,\n\t\t\t},\n\t\t},\n\t})\n\tconst userHasTwoFactor = Boolean(verification)\n\n\tif (userHasTwoFactor) {\n\t\t// Save unverified session\n\t\tconst verifySession = await verifySessionStorage.getSession()\n\t\tverifySession.set(unverifiedSessionIdKey, session.id)\n\t\tverifySession.set(rememberKey, remember)\n\n\t\t// Redirect to 2FA verification\n\t\tconst redirectUrl = getRedirectToUrl({\n\t\t\trequest,\n\t\t\ttype: twoFAVerificationType,\n\t\t\ttarget: session.userId,\n\t\t\tredirectTo,\n\t\t})\n\t\treturn redirect(redirectUrl.toString(), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await verifySessionStorage.commitSession(verifySession),\n\t\t\t},\n\t\t})\n\t} else {\n\t\t// User without 2FA, create session directly\n\t\tconst authSession = await authSessionStorage.getSession(\n\t\t\trequest.headers.get('cookie'),\n\t\t)\n\t\tauthSession.set(sessionKey, session.id)\n\t\treturn redirect(safeRedirect(redirectTo), {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.commitSession(authSession, {\n\t\t\t\t\texpires: remember ? session.expirationDate : undefined,\n\t\t\t\t}),\n\t\t\t},\n\t\t})\n\t}\n}\n```\n\n**Verify 2FA code:**\n\n```typescript\nimport { prepareTOTP, verifyTOTP } from '@epic-web/totp'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: VerifySchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { code } = submission.value\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst verification = await prisma.verification.findUnique({\n\t\twhere: { target_type: { target, type } },\n\t\tselect: {\n\t\t\tsecret: true,\n\t\t\talgorithm: true,\n\t\t\tperiod: true,\n\t\t\tdigits: true,\n\t\t},\n\t})\n\n\tif (!verification) {\n\t\tthrow redirect('/login')\n\t}\n\n\tconst isValid = verifyTOTP({\n\t\totp: code,\n\t\tsecret: verification.secret,\n\t\talgorithm: verification.algorithm as any,\n\t\tperiod: verification.period,\n\t\tdigits: verification.digits,\n\t})\n\n\tif (!isValid) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid code'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\t// Verify session and complete login\n\treturn handleVerification({ request, submission })\n}\n```\n\n### Email Verification\n\nEpic Stack uses TOTP codes sent via email for verification.\n\n**Prepare verification:**\n\n```typescript\nimport { prepareVerification } from './verify.server.ts'\n\nconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\tperiod: 10 * 60, // 10 minutes\n\trequest,\n\ttype: 'onboarding',\n\ttarget: email,\n})\n\n// Enviar email con código y URL\nawait sendEmail({\n\tto: email,\n\tsubject: 'Welcome!',\n\treact: <VerificationEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n})\n\nreturn redirect(redirectTo.toString())\n```\n\n**Verify code:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst verifySession = await verifySessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst target = verifySession.get(targetKey)\n\tconst type = verifySession.get(typeKey)\n\n\tif (!target || !type) {\n\t\tthrow redirect('/signup')\n\t}\n\n\treturn { target, type }\n}\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code (similar to 2FA)\n\t// ...\n}\n```\n\n### Password Reset\n\n**Request reset:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ForgotPasswordSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { email } = submission.value\n\n\t// Prepare verification\n\tconst { verifyUrl, redirectTo, otp } = await prepareVerification({\n\t\tperiod: 10 * 60,\n\t\trequest,\n\t\ttype: 'reset-password',\n\t\ttarget: email,\n\t})\n\n\t// Enviar email con código y URL\n\tawait sendEmail({\n\t\tto: email,\n\t\tsubject: 'Reset your password',\n\t\treact: <ResetPasswordEmail verifyUrl={verifyUrl.toString()} otp={otp} />,\n\t})\n\n\treturn redirect(redirectTo.toString())\n}\n```\n\n**Reset password:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\t// Verify code first (similar to email verification)\n\t// Then reset password\n\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: ResetPasswordSchema,\n\t})\n\n\t// Verify that code is valid\n\t// ...\n\n\tconst { password } = submission.value\n\n\tawait resetUserPassword({\n\t\tusername,\n\t\tpassword,\n\t})\n\n\t// Destroy verification session and redirect to login\n\t// ...\n}\n```\n\n### Logout\n\n```typescript\nimport { logout } from '#app/utils/auth.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\treturn logout({ request, redirectTo: '/' })\n}\n```\n\n### Session Management\n\n**Create session:**\n\n```typescript\nconst session = await prisma.session.create({\n\tdata: {\n\t\texpirationDate: getSessionExpirationDate(),\n\t\tuserId,\n\t},\n\tselect: { id: true, expirationDate: true },\n})\n```\n\n**Session expiration:**\n\n```typescript\nexport const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport const getSessionExpirationDate = () =>\n\tnew Date(Date.now() + SESSION_EXPIRATION_TIME)\n```\n\n**Destroy session:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { id: sessionId } })\n```\n\n**Destroy all user sessions:**\n\n```typescript\nawait prisma.session.deleteMany({ where: { userId } })\n```\n\n## Common examples\n\n### Example 1: Complete login\n\n```typescript\n// app/routes/_auth/login.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: LoginSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { username, password, redirectTo, remember } = submission.value\n\n\tconst session = await login({ username, password })\n\n\tif (!session) {\n\t\treturn data(\n\t\t\t{\n\t\t\t\tresult: submission.reply({\n\t\t\t\t\tformErrors: ['Invalid username or password'],\n\t\t\t\t}),\n\t\t\t},\n\t\t\t{ status: 400 },\n\t\t)\n\t}\n\n\treturn handleNewSession({\n\t\trequest,\n\t\tsession,\n\t\tredirectTo,\n\t\tremember: remember ?? false,\n\t})\n}\n```\n\n### Example 2: Protect route\n\n```typescript\n// app/routes/protected.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function ProtectedRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Datos protegidos */}</div>\n}\n```\n\n### Example 3: Route only for unauthenticated users\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireAnonymous(request)\n\treturn null\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed authentication checks**: Validate authentication and\n  authorization as early as possible - fail fast\n- ❌ **Granting excessive privileges**: Follow least privilege - only grant\n  access to what's needed, when it's needed\n- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user\n  ID), check permissions from database\n- ❌ **Not verifying session on each request**: Always use `getUserId` or\n  `requireUserId` in protected loaders/actions\n- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating\n  session\n- ❌ **Not destroying expired sessions**: Sessions must be verified against\n  `expirationDate` - check early\n- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and\n  cookie creation\n- ❌ **Forgetting to handle `remember`**: Make sure to respect user preference\n- ❌ **Not validating OAuth callbacks**: Always validate that provider exists\n  and result is successful - fail fast\n- ❌ **Not linking OAuth accounts**: If email exists, link the account instead\n  of creating duplicate\n- ❌ **Not updating counter in passkeys**: Always update counter after\n  successful verification\n\n## References\n\n- [Epic Stack Authentication Docs](../epic-stack/docs/authentication.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Remix Auth](https://github.com/sergiodxa/remix-auth)\n- [SimpleWebAuthn](https://simplewebauthn.dev/)\n- `app/utils/auth.server.ts` - Authentication utilities\n- `app/utils/session.server.ts` - Gestión de sesiones\n- `app/routes/_auth/login.server.ts` - Helpers de login\n- `app/routes/_auth/auth.$provider/callback.ts` - OAuth callback\n- `app/routes/_auth/webauthn/` - WebAuthn implementation\n\n<!-- File: docs/skills/epic-caching/SKILL.md (Tokens: ~3625 | Category: anthropic-skill) -->\n---\nname: epic-caching\ndescription:\n  Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack\ncategories:\n  - caching\n  - performance\n  - optimization\n---\n\n# Epic Stack: Caching\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Cache results of expensive queries\n- Cache responses from external APIs\n- Optimize performance of data that doesn't change frequently\n- Implement stale-while-revalidate\n- Manage cache invalidation\n- Integrate cache with server timing\n\n## Patterns and conventions\n\n### Caching Philosophy\n\nFollowing Epic Web principles:\n\n**Weigh the cost-benefit of performance optimizations** - Caching adds\ncomplexity. Only add cache when there's a clear, measurable benefit. Don't cache\n\"just in case\" - cache when you have a real performance problem that caching\nsolves.\n\n**When NOT to use cache:**\n\n- Data that changes frequently (cache invalidation becomes a problem)\n- Data that's already fast to fetch (no measurable benefit)\n- Data that's only fetched once (no benefit from caching)\n- Simple queries that don't need optimization\n- When cache invalidation logic becomes more complex than the problem it solves\n\n**Example - Evaluating cost-benefit:**\n\n```typescript\n// ✅ Good - Cache expensive external API call\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Expensive: External API call, rate limits, network latency\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour - reasonable for external data\n\t})\n}\n\n// ❌ Avoid - Caching simple, fast database query\nexport async function getUser({ userId }: { userId: string }) {\n\t// This query is already fast - caching adds complexity without benefit\n\treturn await cachified({\n\t\tkey: `user:${userId}`,\n\t\tcache,\n\t\tgetFreshValue: async () => {\n\t\t\t// Simple query, already fast\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: { id: true, username: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5,\n\t})\n\t// Better: Just query directly without cache\n}\n```\n\n### Two Types of Cache\n\nEpic Stack provides two types of cache:\n\n1. **SQLite Cache** - Long-lived, replicated with LiteFS\n   - Persistent across restarts\n   - Replicated across all instances\n   - Ideal for data that changes infrequently\n\n2. **LRU Cache** - Short-lived, in-memory\n   - Cleared on restart\n   - Not replicated (only on current instance)\n   - Ideal for deduplication and temporary cache\n\n### Using cachified\n\nEpic Stack uses `@epic-web/cachified` as an abstraction for cache management.\n\n**Basic import:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\n```\n\n**Basic structure:**\n\n```typescript\nexport async function getCachedData({\n\ttimings,\n}: {\n\ttimings?: Timings\n} = {}) {\n\treturn await cachified({\n\t\tkey: 'my-cache-key',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\t// Get fresh data\n\t\t\treturn await fetchDataFromAPI()\n\t\t},\n\t\tcheckValue: z.object({\n\t\t\t/* schema */\n\t\t}), // Validation with Zod\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days\n\t})\n}\n```\n\n### Cache Keys\n\n**Naming conventions:**\n\n- Use format: `entity:identifier:data`\n- Examples:\n  - `user:${userId}:profile`\n  - `note:${noteId}:full`\n  - `api:github:events`\n  - `tito:scheduled-events`\n\n**Avoid:**\n\n- Keys that are too long\n- Keys with special characters\n- Keys that don't clearly identify the content\n\n### TTL (Time To Live)\n\n**Define TTL:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours in milliseconds\n})\n```\n\n**Null TTL to never expire:**\n\n```typescript\nttl: null, // Never expires (not recommended unless necessary)\n```\n\n### Stale-While-Revalidate (SWR)\n\nSWR allows returning stale data while fresh data is fetched in the background.\n\n**Example:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: () => fetchData(),\n\tttl: 1000 * 60 * 60 * 24, // 24 hours - after this it's considered stale\n\tstaleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days - up to here returns stale while revalidating\n})\n```\n\n**Behavior:**\n\n- **Less than 24h**: Returns cache, no request made\n- **24h - 30 days**: Returns stale cache immediately, updates in background\n- **More than 30 days**: Waits for fresh data before returning\n\n### Validation with Zod\n\nAlways validate cached data with Zod:\n\n```typescript\nimport { z } from 'zod'\n\nconst EventSchema = z.object({\n\tid: z.string(),\n\ttitle: z.string(),\n\tdate: z.string(),\n})\n\nexport async function getEvents({ timings }: { timings?: Timings } = {}) {\n\treturn await cachified({\n\t\tkey: 'events:all',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch('https://api.example.com/events')\n\t\t\treturn await response.json()\n\t\t},\n\t\tcheckValue: EventSchema.array(), // Validates it's an array of events\n\t\tttl: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\nIf cached data doesn't pass validation, fresh data is fetched.\n\n### Server Timing Integration\n\nIntegrate cache with server timing for monitoring:\n\n```typescript\nimport { type Timings } from '#app/utils/timing.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\tconst events = await getEvents({ timings })\n\n\t// Timings are automatically added to headers\n\treturn json(\n\t\t{ events },\n\t\t{\n\t\t\theaders: combineServerTimings(timings),\n\t\t},\n\t)\n}\n```\n\n### Cache Invalidation\n\n**Invalidate by key:**\n\n```typescript\nimport { cache } from '#app/utils/cache.server.ts'\n\nawait cache.delete('user:123:profile')\n```\n\n**Invalidate multiple keys:**\n\n```typescript\n// Search and delete matching keys\nimport { searchCacheKeys } from '#app/utils/cache.server.ts'\n\nconst keys = await searchCacheKeys('user:123', 100)\nawait Promise.all(keys.map((key) => cache.delete(key)))\n```\n\n**Invalidate entire SQLite cache:**\n\n```typescript\n// Use admin dashboard or\nawait cache.clear() // If available\n```\n\n### Using LRU Cache\n\nFor temporary data, use LRU cache directly:\n\n```typescript\nimport { lru } from '#app/utils/cache.server.ts'\n\n// LRU cache is useful for:\n// - Request deduplication\n// - Very temporary cache (< 5 minutes)\n// - Data that doesn't need to persist\n\nconst cachedValue = lru.get('temp-key')\nif (!cachedValue) {\n\tconst freshValue = await computeExpensiveValue()\n\tlru.set('temp-key', freshValue, { ttl: 1000 * 60 * 5 }) // 5 minutes\n\treturn freshValue\n}\nreturn cachedValue\n```\n\n### Multi-Region Cache\n\nWith LiteFS, SQLite cache is automatically replicated:\n\n**Behavior:**\n\n- Only the primary instance writes to cache\n- Replicas can read from cache\n- Writes are automatically synchronized\n\n**Best practices:**\n\n- Don't assume all writes are immediate\n- Use `ensurePrimary()` if you need to guarantee writes\n\n```typescript\nimport { ensurePrimary } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tawait ensurePrimary() // Ensure we're on primary instance\n\n\t// Invalidate cache\n\tawait cache.delete('my-key')\n\n\t// ...\n}\n```\n\n### Error Handling\n\n**Handle errors in getFreshValue:**\n\n```typescript\nawait cachified({\n\tkey: 'my-key',\n\tcache,\n\tgetFreshValue: async () => {\n\t\ttry {\n\t\t\treturn await fetchData()\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to fetch fresh data:', error)\n\t\t\tthrow error // Re-throw so cachified handles it\n\t\t}\n\t},\n\t// If getFreshValue fails and there's stale cache, it returns it\n\tfallbackToCache: true, // Default: true\n})\n```\n\n### Cache Admin Dashboard\n\nEpic Stack includes a dashboard to manage cache:\n\n**Route:** `/admin/cache`\n\n**Features:**\n\n- View all cache keys\n- Search keys\n- View details of a key\n- Delete keys\n- Clear entire cache\n\n## Common examples\n\n### Example 1: Cache external API response\n\n```typescript\n// app/utils/api.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { type Timings } from '#app/utils/timing.server.ts'\nimport { z } from 'zod'\n\nconst GitHubEventSchema = z.object({\n\tid: z.string(),\n\ttype: z.string(),\n\tactor: z.object({\n\t\tlogin: z.string(),\n\t}),\n\tcreated_at: z.string(),\n})\n\nexport async function getGitHubEvents({\n\tusername,\n\ttimings,\n}: {\n\tusername: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `github:${username}:events`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst response = await fetch(\n\t\t\t\t`https://api.github.com/users/${username}/events/public`,\n\t\t\t)\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`GitHub API error: ${response.statusText}`)\n\t\t\t}\n\t\t\tconst data = await response.json()\n\t\t\treturn data\n\t\t},\n\t\tcheckValue: GitHubEventSchema.array(),\n\t\tttl: 1000 * 60 * 60, // 1 hour\n\t\tstaleWhileRevalidate: 1000 * 60 * 60 * 24, // 24 hours\n\t})\n}\n```\n\n### Example 2: Cache Prisma query\n\n```typescript\n// app/utils/user.server.ts\nimport { cachified, cache } from '#app/utils/cache.server.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { z } from 'zod'\n\nconst UserStatsSchema = z.object({\n\ttotalNotes: z.number(),\n\ttotalLikes: z.number(),\n\tjoinDate: z.string(),\n})\n\nexport async function getUserStats({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\treturn await cachified({\n\t\tkey: `user:${userId}:stats`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\tconst [totalNotes, totalLikes, user] = await Promise.all([\n\t\t\t\tprisma.note.count({ where: { ownerId: userId } }),\n\t\t\t\tprisma.like.count({ where: { userId } }),\n\t\t\t\tprisma.user.findUnique({\n\t\t\t\t\twhere: { id: userId },\n\t\t\t\t\tselect: { createdAt: true },\n\t\t\t\t}),\n\t\t\t])\n\n\t\t\treturn {\n\t\t\t\ttotalNotes,\n\t\t\t\ttotalLikes,\n\t\t\t\tjoinDate: user?.createdAt.toISOString() ?? '',\n\t\t\t}\n\t\t},\n\t\tcheckValue: UserStatsSchema,\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t\tstaleWhileRevalidate: 1000 * 60 * 60, // 1 hour\n\t})\n}\n```\n\n### Example 3: Invalidate cache after mutation\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// ... validate and create note\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle,\n\t\t\tcontent,\n\t\t\townerId: userId,\n\t\t},\n\t\tinclude: { owner: true },\n\t})\n\n\t// Invalidate related cache\n\tawait Promise.all([\n\t\tcache.delete(`user:${userId}:notes`),\n\t\tcache.delete(`user:${userId}:stats`),\n\t\tcache.delete(`note:${note.id}:full`),\n\t])\n\n\treturn redirect(`/users/${note.owner.username}/notes/${note.id}`)\n}\n```\n\n### Example 4: Cache with dependencies\n\n```typescript\nexport async function getUserWithNotes({\n\tuserId,\n\ttimings,\n}: {\n\tuserId: string\n\ttimings?: Timings\n}) {\n\tconst user = await cachified({\n\t\tkey: `user:${userId}:profile`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.findUnique({\n\t\t\t\twhere: { id: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\tusername: true,\n\t\t\t\t\tname: true,\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t\tcheckValue: z\n\t\t\t.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tusername: z.string(),\n\t\t\t\tname: z.string().nullable(),\n\t\t\t})\n\t\t\t.nullable(),\n\t\tttl: 1000 * 60 * 30, // 30 minutes\n\t})\n\n\tconst notes = await cachified({\n\t\tkey: `user:${userId}:notes`,\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.note.findMany({\n\t\t\t\twhere: { ownerId: userId },\n\t\t\t\tselect: {\n\t\t\t\t\tid: true,\n\t\t\t\t\ttitle: true,\n\t\t\t\t\tupdatedAt: true,\n\t\t\t\t},\n\t\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\t})\n\t\t},\n\t\tcheckValue: z.array(\n\t\t\tz.object({\n\t\t\t\tid: z.string(),\n\t\t\t\ttitle: z.string(),\n\t\t\t\tupdatedAt: z.date(),\n\t\t\t}),\n\t\t),\n\t\tttl: 1000 * 60 * 10, // 10 minutes\n\t})\n\n\treturn { user, notes }\n}\n```\n\n### Example 5: Use LRU for deduplication\n\n```typescript\n// Avoid multiple simultaneous requests to the same URL\nconst requestCache = new Map<string, Promise<any>>()\n\nexport async function fetchWithDedup(url: string) {\n\tif (requestCache.has(url)) {\n\t\treturn requestCache.get(url)\n\t}\n\n\tconst promise = fetch(url).then((res) => res.json())\n\trequestCache.set(url, promise)\n\n\t// Clean up after 1 second\n\tsetTimeout(() => {\n\t\trequestCache.delete(url)\n\t}, 1000)\n\n\treturn promise\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Caching without measuring benefit**: Only add cache when there's a clear,\n  measurable performance problem\n- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to\n  fetch - it adds complexity without benefit\n- ❌ **Caching frequently changing data**: Cache invalidation becomes more\n  complex than the problem it solves\n- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive\n  personal data\n- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely\n  necessary\n- ❌ **Not validating cached data**: Always use `checkValue` with Zod to\n  validate data\n- ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations\n- ❌ **Assuming cache always works**: Cache can fail, always handle errors\n- ❌ **Keys too long or ambiguous**: Use consistent and descriptive format\n- ❌ **Not using timings**: Integrate with server timing for monitoring\n- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when\n  appropriate\n- ❌ **Over-caching**: Too much caching makes the system harder to understand\n  and debug\n\n## References\n\n- [Epic Stack Caching Docs](../epic-stack/docs/caching.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [@epic-web/cachified](https://www.npmjs.com/package/@epic-web/cachified)\n- `app/utils/cache.server.ts` - Cache implementation\n- `app/routes/admin/cache/` - Admin dashboard\n- `app/utils/timing.server.ts` - Server timing utilities\n\n<!-- File: docs/skills/epic-database/SKILL.md (Tokens: ~4271 | Category: anthropic-skill) -->\n---\nname: epic-database\ndescription: Guide on Prisma, SQLite, and LiteFS for Epic Stack\ncategories:\n  - database\n  - prisma\n  - sqlite\n  - litefs\n---\n\n# Epic Stack: Database\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Design database schema with Prisma\n- Create migrations\n- Work with SQLite and LiteFS\n- Optimize queries and performance\n- Create seed scripts\n- Work with multi-region deployments\n- Manage backups and restores\n\n## Patterns and conventions\n\n### Database Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Only fetch the data you actually need. Use\n`select` to fetch specific fields instead of entire models. Avoid over-fetching\ndata \"just in case\" - fetch what you need, when you need it.\n\n**Pragmatism over purity** - Optimize queries when there's a measurable benefit,\nbut don't over-optimize prematurely. Simple, readable queries are often better\nthan complex optimized ones. Add indexes when queries are slow, not before.\n\n**Example - Fetch only what you need:**\n\n```typescript\n// ✅ Good - Fetch only needed fields\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only fetch what you actually use\n\t},\n})\n\n// ❌ Avoid - Fetching everything\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches all fields including password hash, email, etc.\n})\n```\n\n**Example - Pragmatic optimization:**\n\n```typescript\n// ✅ Good - Simple query first, optimize if needed\nconst notes = await prisma.note.findMany({\n\twhere: { ownerId: userId },\n\tselect: { id: true, title: true, updatedAt: true },\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n})\n\n// Only add indexes if this query is actually slow\n// Don't pre-optimize\n\n// ❌ Avoid - Over-optimizing before measuring\n// Adding complex indexes, joins, etc. before knowing if it's needed\n```\n\n### Prisma Schema\n\nEpic Stack uses Prisma with SQLite as the database.\n\n**Basic configuration:**\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"typedSql\"]\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n```\n\n**Basic model:**\n\n```prisma\nmodel User {\n  id        String   @id @default(cuid())\n  email     String   @unique\n  username  String   @unique\n  name      String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  notes     Note[]\n  roles     Role[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  title   String\n  content String\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n  @@index([ownerId, updatedAt])\n}\n```\n\n### CUID2 for IDs\n\nEpic Stack uses CUID2 to generate unique IDs.\n\n**Advantages:**\n\n- Globally unique\n- Sortable\n- Secure (no exposed information)\n- URL-friendly\n\n**Example:**\n\n```prisma\nmodel User {\n  id String @id @default(cuid()) // Automatically generates CUID2\n}\n```\n\n### Timestamps\n\n**Standard fields:**\n\n```prisma\nmodel User {\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt // Automatically updated\n}\n```\n\n### Relationships\n\n**One-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id])\n  ownerId String\n\n  @@index([ownerId])\n}\n```\n\n**One-to-One:**\n\n```prisma\nmodel User {\n  id      String  @id @default(cuid())\n  image   UserImage?\n}\n\nmodel UserImage {\n  id        String @id @default(cuid())\n  user      User   @relation(fields: [userId], references: [id])\n  userId    String @unique\n}\n```\n\n**Many-to-Many:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  roles Role[]\n}\n\nmodel Role {\n  id    String @id @default(cuid())\n  users User[]\n}\n```\n\n### Indexes\n\n**Create indexes:**\n\n```prisma\nmodel Note {\n  id      String @id @default(cuid())\n  ownerId String\n  updatedAt DateTime\n\n  @@index([ownerId])              // Simple index\n  @@index([ownerId, updatedAt])   // Composite index\n}\n```\n\n**Best practices:**\n\n- Index foreign keys\n- Index fields used in `where` frequently\n- Index fields used in `orderBy`\n- Use composite indexes for complex queries\n\n### Cascade Delete\n\n**Configure cascade:**\n\n```prisma\nmodel User {\n  id    String @id @default(cuid())\n  notes Note[]\n}\n\nmodel Note {\n  id      String @id @default(cuid())\n  owner   User   @relation(fields: [ownerId], references: [id], onDelete: Cascade)\n  ownerId String\n}\n```\n\n**Options:**\n\n- `onDelete: Cascade` - Deletes children when parent is deleted\n- `onDelete: SetNull` - Sets to null when parent is deleted\n- `onDelete: Restrict` - Prevents deletion if there are children\n\n### Migrations\n\n**Create migration:**\n\n```bash\nnpx prisma migrate dev --name add_user_field\n```\n\n**Apply migrations in production:**\n\n```bash\nnpx prisma migrate deploy\n```\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`.\n\n**\"Widen then Narrow\" strategy for zero-downtime:**\n\n1. **Widen app** - App accepts A or B\n2. **Widen db** - DB provides A and B, app writes to both\n3. **Narrow app** - App only uses B\n4. **Narrow db** - DB only provides B\n\n**Example: Rename field `name` to `firstName` and `lastName`:**\n\n```prisma\n// Step 1: Widen app (accepts both)\nmodel User {\n  id        String @id @default(cuid())\n  name      String?  // Deprecated\n  firstName String?  // New\n  lastName  String?  // New\n}\n\n// Step 2: Widen db (migration copies data)\n// In SQL migration:\nALTER TABLE User ADD COLUMN firstName TEXT;\nALTER TABLE User ADD COLUMN lastName TEXT;\nUPDATE User SET firstName = name;\n\n// Step 3: Narrow app (only uses new fields)\n// Code only uses firstName and lastName\n\n// Step 4: Narrow db (removes old field)\nALTER TABLE User DROP COLUMN name;\n```\n\n### Prisma Client\n\n**Import Prisma Client:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n```\n\n**Basic query:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n})\n```\n\n**Specific select:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\temail: true,\n\t\tusername: true,\n\t\t// Don't include password or sensitive data\n\t},\n})\n```\n\n**Include relations:**\n\n```typescript\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tinclude: {\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t},\n\t\troles: true,\n\t},\n})\n```\n\n**Complex queries:**\n\n```typescript\nconst notes = await prisma.note.findMany({\n\twhere: {\n\t\townerId: userId,\n\t\ttitle: { contains: searchTerm },\n\t},\n\tselect: {\n\t\tid: true,\n\t\ttitle: true,\n\t\tupdatedAt: true,\n\t},\n\torderBy: { updatedAt: 'desc' },\n\ttake: 20,\n\tskip: (page - 1) * 20,\n})\n```\n\n### Transactions\n\n**Use transactions:**\n\n```typescript\nawait prisma.$transaction(async (tx) => {\n\tconst user = await tx.user.create({\n\t\tdata: {\n\t\t\temail,\n\t\t\tusername,\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tawait tx.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Welcome',\n\t\t\tcontent: 'Welcome to the app!',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\treturn user\n})\n```\n\n### SQLite con LiteFS\n\n**Multi-region with LiteFS:**\n\n- Only the primary instance can write\n- Replicas can only read\n- Writes are automatically replicated\n\n**Check primary instance:**\n\n```typescript\nimport { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\t// Ensure we're on primary instance for writes\n\tawait ensurePrimary()\n\n\t// Now we can write safely\n\tawait prisma.user.create({\n\t\tdata: {\n\t\t\t/* ... */\n\t\t},\n\t})\n}\n```\n\n**Get instance information:**\n\n```typescript\nimport { getInstanceInfo } from '#app/utils/litefs.server.ts'\n\nconst { currentIsPrimary, primaryInstance } = await getInstanceInfo()\n\nif (currentIsPrimary) {\n\t// Can write\n} else {\n\t// Read-only, redirect to primary if necessary\n}\n```\n\n### Seed Scripts\n\n**Create seed:**\n\n```typescript\n// prisma/seed.ts\nimport { prisma } from '#app/utils/db.server.ts'\n\nasync function seed() {\n\t// Create roles\n\tawait prisma.role.createMany({\n\t\tdata: [\n\t\t\t{ name: 'user', description: 'Standard user' },\n\t\t\t{ name: 'admin', description: 'Administrator' },\n\t\t],\n\t})\n\n\t// Create users\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: { connect: { name: 'user' } },\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n\nseed()\n\t.catch((e) => {\n\t\tconsole.error(e)\n\t\tprocess.exit(1)\n\t})\n\t.finally(async () => {\n\t\tawait prisma.$disconnect()\n\t})\n```\n\n**Run seed:**\n\n```bash\nnpx prisma db seed\n# Or directly:\nnpx tsx prisma/seed.ts\n```\n\n### Query Optimization\n\n**Guidelines (pragmatic approach):**\n\n- Use `select` to fetch only needed fields - do as little as possible\n- Use selective `include` - only include relations you actually use\n- Index fields used in `where` and `orderBy` - but only if queries are slow\n- Use composite indexes for complex queries - when you have a real performance\n  problem\n- Avoid `select: true` (fetches everything) - be explicit about what you need\n- Measure first, optimize second - don't pre-optimize\n\n**Optimized example (do as little as possible):**\n\n```typescript\n// ❌ Avoid: Fetches everything unnecessarily\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\t// Fetches password hash, email, all relations, etc.\n})\n\n// ✅ Good: Only needed fields - do as little as possible\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tname: true,\n\t\t// Only what you actually use\n\t},\n})\n\n// ✅ Better: With selective relations (only if you need them)\nconst user = await prisma.user.findUnique({\n\twhere: { id: userId },\n\tselect: {\n\t\tid: true,\n\t\tusername: true,\n\t\tnotes: {\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t},\n\t\t\ttake: 10, // Only fetch what you need\n\t\t},\n\t},\n})\n```\n\n### Prisma Query Logging\n\n**Configure logging:**\n\n```typescript\n// app/utils/db.server.ts\nconst client = new PrismaClient({\n\tlog: [\n\t\t{ level: 'query', emit: 'event' },\n\t\t{ level: 'error', emit: 'stdout' },\n\t\t{ level: 'warn', emit: 'stdout' },\n\t],\n})\n\nclient.$on('query', async (e) => {\n\tif (e.duration < 20) return // Only log slow queries\n\n\tconsole.info(`prisma:query - ${e.duration}ms - ${e.query}`)\n})\n```\n\n### Database URL\n\n**Development:**\n\n```bash\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**Production (Fly.io):**\n\n```bash\nDATABASE_URL=file:/litefs/data/sqlite.db\n```\n\n### Connecting to DB in Production\n\n**SSH to Fly instance:**\n\n```bash\nfly ssh console --app [YOUR_APP_NAME]\n```\n\n**Connect to DB CLI:**\n\n```bash\nfly ssh console -C database-cli --app [YOUR_APP_NAME]\n```\n\n**Prisma Studio:**\n\n```bash\n# Terminal 1: Start Prisma Studio\nfly ssh console -C \"npx prisma studio\" -s --app [YOUR_APP_NAME]\n\n# Terminal 2: Local proxy\nfly proxy 5556:5555 --app [YOUR_APP_NAME]\n\n# Open in browser\n# http://localhost:5556\n```\n\n## Common examples\n\n### Example 1: Create model with relations\n\n```prisma\nmodel Post {\n  id        String   @id @default(cuid())\n  title     String\n  content   String\n  published Boolean  @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)\n  authorId String\n\n  comments Comment[]\n  tags     Tag[]\n\n  @@index([authorId])\n  @@index([authorId, published])\n  @@index([published, updatedAt])\n}\n\nmodel Comment {\n  id      String @id @default(cuid())\n  content String\n\n  createdAt DateTime @default(now())\n\n  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)\n  postId String\n\n  author   User   @relation(fields: [authorId], references: [id])\n  authorId String\n\n  @@index([postId])\n  @@index([authorId])\n}\n```\n\n### Example 2: Complex query with pagination\n\n```typescript\nexport async function getPosts({\n\tuserId,\n\tpage = 1,\n\tperPage = 20,\n\tpublished,\n}: {\n\tuserId?: string\n\tpage?: number\n\tperPage?: number\n\tpublished?: boolean\n}) {\n\tconst where: Prisma.PostWhereInput = {}\n\n\tif (userId) {\n\t\twhere.authorId = userId\n\t}\n\tif (published !== undefined) {\n\t\twhere.published = published\n\t}\n\n\tconst [posts, total] = await Promise.all([\n\t\tprisma.post.findMany({\n\t\t\twhere,\n\t\t\tselect: {\n\t\t\t\tid: true,\n\t\t\t\ttitle: true,\n\t\t\t\tupdatedAt: true,\n\t\t\t\tauthor: {\n\t\t\t\t\tselect: {\n\t\t\t\t\t\tid: true,\n\t\t\t\t\t\tusername: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\torderBy: { updatedAt: 'desc' },\n\t\t\ttake: perPage,\n\t\t\tskip: (page - 1) * perPage,\n\t\t}),\n\t\tprisma.post.count({ where }),\n\t])\n\n\treturn {\n\t\tposts,\n\t\ttotal,\n\t\tpages: Math.ceil(total / perPage),\n\t}\n}\n```\n\n### Example 3: Transaction with multiple operations\n\n```typescript\nexport async function createPostWithTags({\n\tauthorId,\n\ttitle,\n\tcontent,\n\ttagNames,\n}: {\n\tauthorId: string\n\ttitle: string\n\tcontent: string\n\ttagNames: string[]\n}) {\n\treturn await prisma.$transaction(async (tx) => {\n\t\t// Create tags if they don't exist\n\t\tawait Promise.all(\n\t\t\ttagNames.map((name) =>\n\t\t\t\ttx.tag.upsert({\n\t\t\t\t\twhere: { name },\n\t\t\t\t\tupdate: {},\n\t\t\t\t\tcreate: { name },\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\t// Create post\n\t\tconst post = await tx.post.create({\n\t\t\tdata: {\n\t\t\t\ttitle,\n\t\t\t\tcontent,\n\t\t\t\tauthorId,\n\t\t\t\ttags: {\n\t\t\t\t\tconnect: tagNames.map((name) => ({ name })),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\n\t\treturn post\n\t})\n}\n```\n\n### Example 4: Seed with related data\n\n```typescript\nasync function seed() {\n\t// Create permissions\n\tconst permissions = await Promise.all([\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'create',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can create own notes',\n\t\t\t},\n\t\t}),\n\t\tprisma.permission.create({\n\t\t\tdata: {\n\t\t\t\taction: 'read',\n\t\t\t\tentity: 'note',\n\t\t\t\taccess: 'own',\n\t\t\t\tdescription: 'Can read own notes',\n\t\t\t},\n\t\t}),\n\t])\n\n\t// Create roles with permissions\n\tconst userRole = await prisma.role.create({\n\t\tdata: {\n\t\t\tname: 'user',\n\t\t\tdescription: 'Standard user',\n\t\t\tpermissions: {\n\t\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t\t},\n\t\t},\n\t})\n\n\t// Create user with role\n\tconst user = await prisma.user.create({\n\t\tdata: {\n\t\t\temail: 'user@example.com',\n\t\t\tusername: 'testuser',\n\t\t\troles: {\n\t\t\t\tconnect: { id: userRole.id },\n\t\t\t},\n\t\t},\n\t})\n\n\tconsole.log('Seed complete!')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need -\n  do as little as possible\n- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add\n  indexes \"just in case\"\n- ❌ **Not using indexes when needed**: Index foreign keys and fields used in\n  frequent queries, but only if they're actually slow\n- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when\n  you need them\n- ❌ **Not using transactions for related operations**: Always use transactions\n  when multiple operations must be atomic\n- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in\n  production\n- ❌ **Breaking migrations without strategy**: Use \"widen then narrow\" for\n  zero-downtime\n- ❌ **Not validating data before inserting**: Always validate with Zod before\n  create/update\n- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when\n  parent is deleted\n- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or\n  others\n- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but\n  ensure in scripts\n- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries\n  over complex optimized ones unless there's a real problem\n\n## References\n\n- [Epic Stack Database Docs](../epic-stack/docs/database.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Prisma Documentation](https://www.prisma.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [SQLite Documentation](https://www.sqlite.org/docs.html)\n- `prisma/schema.prisma` - Complete schema\n- `prisma/seed.ts` - Seed example\n- `app/utils/db.server.ts` - Prisma Client setup\n- `app/utils/litefs.server.ts` - LiteFS utilities\n\n<!-- File: docs/skills/epic-deployment/SKILL.md (Tokens: ~4707 | Category: anthropic-skill) -->\n---\nname: epic-deployment\ndescription:\n  Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack\ncategories:\n  - deployment\n  - fly-io\n  - ci-cd\n  - docker\n---\n\n# Epic Stack: Deployment\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure deployment on Fly.io\n- Setup multi-region deployment\n- Configure CI/CD with GitHub Actions\n- Manage secrets in production\n- Configure healthchecks\n- Work with LiteFS and volumes\n- Local deployment with Docker\n\n## Patterns and conventions\n\n### Fly.io Configuration\n\nEpic Stack uses Fly.io for hosting with configuration in `fly.toml`.\n\n**Basic configuration:**\n\n```toml\n# fly.toml\napp = \"your-app-name\"\nprimary_region = \"sjc\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\n\n[build]\ndockerfile = \"/other/Dockerfile\"\nignorefile = \"/other/Dockerfile.dockerignore\"\n\n[mounts]\nsource = \"data\"\ndestination = \"/data\"\n```\n\n### Primary Region\n\n**Configure primary region:**\n\n```toml\nprimary_region = \"sjc\" # Change according to your location\n```\n\n**Important:** The primary region must be the same for:\n\n- `primary_region` en `fly.toml`\n- Region del volume `data`\n- `PRIMARY_REGION` en variables de entorno\n\n### LiteFS Configuration\n\n**Configuration in `other/litefs.yml`:**\n\n```yaml\nfuse:\n  dir: '${LITEFS_DIR}'\n\ndata:\n  dir: '/data/litefs'\n\nproxy:\n  addr: ':${INTERNAL_PORT}'\n  target: 'localhost:${PORT}'\n  db: '${DATABASE_FILENAME}'\n\nlease:\n  type: 'consul'\n  candidate: ${FLY_REGION == PRIMARY_REGION}\n  promote: true\n  advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'\n  consul:\n    url: '${FLY_CONSUL_URL}'\n    key: 'epic-stack-litefs_20250222/${FLY_APP_NAME}'\n\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n  - cmd: sqlite3 $DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: sqlite3 $CACHE_DATABASE_PATH \"PRAGMA journal_mode = WAL;\"\n    if-candidate: true\n  - cmd: npx prisma generate --sql\n  - cmd: npm start\n```\n\n### Healthchecks\n\n**Configuration in `fly.toml`:**\n\n```toml\n[[services.http_checks]]\ninterval = \"10s\"\ngrace_period = \"5s\"\nmethod = \"get\"\npath = \"/resources/healthcheck\"\nprotocol = \"http\"\ntimeout = \"2s\"\ntls_skip_verify = false\n```\n\n**Healthcheck implementation:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Verify DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\tconsole.log('healthcheck ❌', { error })\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Environment Variables\n\n**Secrets in Fly.io:**\n\n```bash\n# Generate secrets\nfly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\nfly secrets set HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]\n\n# List secrets\nfly secrets list --app [YOUR_APP_NAME]\n\n# Delete secret\nfly secrets unset SECRET_NAME --app [YOUR_APP_NAME]\n```\n\n**Common secrets:**\n\n- `SESSION_SECRET` - Secret for signing session cookies\n- `HONEYPOT_SECRET` - Secret for honeypot fields\n- `DATABASE_URL` - Automatically configured by LiteFS\n- `CACHE_DATABASE_PATH` - Automatically configured\n- `RESEND_API_KEY` - For sending emails (optional)\n- `TIGRIS_*` - For image storage (automatic)\n- `SENTRY_DSN` - For error monitoring (optional)\n\n### Volumes\n\n**Create volume:**\n\n```bash\nfly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]\n```\n\n**List volumes:**\n\n```bash\nfly volumes list --app [YOUR_APP_NAME]\n```\n\n**Expand volume:**\n\n```bash\nfly volumes extend <volume-id> --size 10 --app [YOUR_APP_NAME]\n```\n\n### Multi-Region Deployment\n\n**Deploy to multiple regions:**\n\n```bash\n# Deploy in primary region (more instances)\nfly scale count 2 --region sjc --app [YOUR_APP_NAME]\n\n# Deploy in secondary regions (read-only)\nfly scale count 1 --region ams --app [YOUR_APP_NAME]\nfly scale count 1 --region syd --app [YOUR_APP_NAME]\n```\n\n**Verify instances:**\n\n```bash\nfly status --app [YOUR_APP_NAME]\n# The ROLE column will show \"primary\" or \"replica\"\n```\n\n### Consul Setup\n\n**Attach Consul:**\n\n```bash\nfly consul attach --app [YOUR_APP_NAME]\n```\n\n**Consul manages:**\n\n- Which instance is primary\n- Automatic failover\n- Data replication\n\n### GitHub Actions CI/CD\n\n**Basic workflow:**\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy:\n    name: Deploy\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n**Complete configuration:**\n\n- Deploy to `production` from `main` branch\n- Deploy to `staging` from `dev` branch\n- Tests before deploy (optional)\n\n### Deployable Commits\n\nFollowing Epic Web principles:\n\n**Deployable commits** - Every commit to the main branch should be deployable.\nThis means:\n\n- The code should be in a working state\n- Tests should pass\n- The application should build successfully\n- No \"WIP\" or \"TODO\" commits that break the build\n\n**Example - Deployable commit workflow:**\n\n```bash\n# ✅ Good - Each commit is deployable\ngit commit -m \"Add user profile page\"\n# This commit is complete, tested, and deployable\n\ngit commit -m \"Fix login redirect bug\"\n# This commit fixes a bug and is deployable\n\n# ❌ Avoid - Non-deployable commits\ngit commit -m \"WIP: working on feature\"\n# This commit might not work, not deployable\n\ngit commit -m \"Add feature (tests failing)\"\n# This commit breaks the build, not deployable\n```\n\n**Benefits:**\n\n- Easy rollback - any commit can be deployed\n- Continuous deployment - deploy any time\n- Clear history - each commit represents a working state\n- Faster recovery - can deploy any previous commit\n\n### Small and Short Lived Merge Requests\n\nFollowing Epic Web principles:\n\n**Small and short lived merge requests** - Keep PRs small and merge them\nquickly. Large PRs are hard to review, risky to merge, and slow down the team.\n\n**Guidelines:**\n\n- **Small PRs** - Focus on one feature or fix per PR\n- **Short-lived** - Merge within a day or two, not weeks\n- **Reviewable** - PRs should be reviewable in 30 minutes or less\n- **Independent** - Each PR should be independently deployable\n\n**Example - Small, focused PR:**\n\n```bash\n# ✅ Good - Small, focused PR\n# PR: \"Add email validation to signup form\"\n# - Only changes signup validation\n# - Includes tests\n# - Can be reviewed quickly\n# - Can be merged and deployed independently\n\n# ❌ Avoid - Large, complex PR\n# PR: \"Refactor authentication system and add 2FA and OAuth\"\n# - Too many changes at once\n# - Hard to review\n# - Risky to merge\n# - Takes days to review\n```\n\n**Benefits:**\n\n- Faster reviews - easier to understand and review\n- Lower risk - smaller changes are less risky\n- Faster feedback - get feedback sooner\n- Easier rollback - smaller changes are easier to revert\n- Better collaboration - team can work in parallel on different small PRs\n\n**When PRs get too large:**\n\n- Split into multiple smaller PRs\n- Use feature flags to merge incrementally\n- Break down into logical pieces\n\n### Tigris Object Storage\n\n**Create storage:**\n\n```bash\nfly storage create --app [YOUR_APP_NAME]\n```\n\n**This creates:**\n\n- Tigris bucket\n- Automatic environment variables:\n  - `TIGRIS_ENDPOINT`\n  - `TIGRIS_ACCESS_KEY_ID`\n  - `TIGRIS_SECRET_ACCESS_KEY`\n  - `TIGRIS_BUCKET_NAME`\n\n### Database Migrations\n\n**Automatic migrations:** Migrations are automatically applied on deploy via\n`litefs.yml`:\n\n```yaml\nexec:\n  - cmd: npx prisma migrate deploy\n    if-candidate: true\n```\n\n**Note:** Only the primary instance runs migrations (`if-candidate: true`).\n\n### Database Backups\n\n**Create backup:**\n\n```bash\n# SSH to instance\nfly ssh console --app [YOUR_APP_NAME]\n\n# Create backup\nmkdir /backups\nlitefs export -name sqlite.db /backups/backup-$(date +%Y-%m-%d).db\nexit\n\n# Download backup\nfly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME]\n```\n\n**Restore backup:**\n\n```bash\n# Upload backup\nfly ssh sftp shell --app [YOUR_APP_NAME]\nput backup-2024-01-01.db\n# Ctrl+C to exit\n\n# SSH and restore\nfly ssh console --app [YOUR_APP_NAME]\nlitefs import -name sqlite.db /backup-2024-01-01.db\nexit\n```\n\n### Deployment Local\n\n**Deploy con Fly CLI:**\n\n```bash\nfly deploy\n```\n\n**Deploy con Docker:**\n\n```bash\n# Build\ndocker build -t epic-stack . -f other/Dockerfile \\\n  --build-arg COMMIT_SHA=$(git rev-parse --short HEAD)\n\n# Run\ndocker run -d \\\n  -p 8081:8081 \\\n  -e SESSION_SECRET='secret' \\\n  -e HONEYPOT_SECRET='secret' \\\n  -e FLY='false' \\\n  -v ~/litefs:/litefs \\\n  epic-stack\n```\n\n### Zero-Downtime Deploys\n\n**Strategy:**\n\n- Deploy to multiple instances\n- Automatic blue-green deployment\n- Healthchecks verify app is ready\n- Auto-rollback if healthcheck fails\n\n**Configuration:**\n\n```toml\n[experimental]\nauto_rollback = true\n```\n\n### Monitoring\n\n**View logs:**\n\n```bash\nfly logs --app [YOUR_APP_NAME]\n```\n\n**View metrics:**\n\n```bash\nfly dashboard --app [YOUR_APP_NAME]\n# Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring\n```\n\n**Sentry (opcional):**\n\n```bash\nfly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME]\n```\n\n## Common examples\n\n### Example 1: Complete initial setup\n\n```bash\n# 1. Create apps\nfly apps create my-app\nfly apps create my-app-staging\n\n# 2. Configure secrets\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  --app my-app\n\nfly secrets set \\\n  SESSION_SECRET=$(openssl rand -hex 32) \\\n  HONEYPOT_SECRET=$(openssl rand -hex 32) \\\n  ALLOW_INDEXING=false \\\n  --app my-app-staging\n\n# 3. Create volumes\nfly volumes create data --region sjc --size 1 --app my-app\nfly volumes create data --region sjc --size 1 --app my-app-staging\n\n# 4. Attach Consul\nfly consul attach --app my-app\nfly consul attach --app my-app-staging\n\n# 5. Create storage\nfly storage create --app my-app\nfly storage create --app my-app-staging\n\n# 6. Deploy\nfly deploy --app my-app\n```\n\n### Example 2: Multi-region setup\n\n```bash\n# First region (primary) - 2 instances\nfly scale count 2 --region sjc --app my-app\n\n# Secondary regions - 1 instance each\nfly scale count 1 --region ams --app my-app\nfly scale count 1 --region syd --app my-app\n\n# Verify\nfly status --app my-app\n```\n\n### Example 3: GitHub Actions workflow\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main, dev]\n\njobs:\n  deploy-production:\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\n  deploy-staging:\n    if: github.ref == 'refs/heads/dev'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only --app my-app-staging\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Example 4: Deploy with migrations\n\n```bash\n# Create migration\nnpx prisma migrate dev --name add_field\n\n# Commit and push\ngit add .\ngit commit -m \"Add field\"\ngit push origin main\n\n# GitHub Actions automatically runs:\n# 1. Build\n# 2. Deploy\n# 3. litefs.yml runs: npx prisma migrate deploy (only on primary)\n```\n\n## Common mistakes to avoid\n\n- ❌ **Non-deployable commits**: Every commit to main should be deployable - no\n  WIP or broken commits\n- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are\n  hard to review and risky\n- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml`\n  matches the volume region\n- ❌ **Secrets not configured**: Configure all secrets before first deploy\n- ❌ **Volume not created**: Create the `data` volume before deploy\n- ❌ **Consul not attached**: Attach Consul before first deploy\n- ❌ **Migrations on replicas**: Only the primary instance should run migrations\n- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime\n  deploys\n- ❌ **Deploy breaking changes without strategy**: Use \"widen then narrow\" for\n  migrations\n- ❌ **Secrets in code**: Never commit secrets, use `fly secrets`\n- ❌ **Not making backups**: Make regular database backups\n- ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets\n\n## References\n\n- [Epic Stack Deployment Docs](../epic-stack/docs/deployment.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Fly.io Documentation](https://fly.io/docs)\n- [LiteFS Documentation](https://fly.io/docs/litefs/)\n- [Fly.io CLI Reference](https://fly.io/docs/flyctl/)\n- `fly.toml` - Fly.io configuration\n- `other/litefs.yml` - LiteFS configuration\n- `other/Dockerfile` - Deployment Dockerfile\n- `.github/workflows/deploy.yml` - CI/CD workflow\n\n### Preview Deployments (Inspired by Vercel Deploy Claimable)\n\nEpic Stack can implement preview deployments similar to Vercel's deploy\nclaimable pattern.\n\n**✅ Good - Preview deployments for pull requests:**\n\n```yaml\n# .github/workflows/preview-deploy.yml\nname: Preview Deploy\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  preview:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Deploy preview\n        run: |\n          # Create or reuse preview app\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps list | grep \"$PREVIEW_APP\" || flyctl apps create \"$PREVIEW_APP\"\n\n          # Deploy to preview app\n          flyctl deploy --app \"$PREVIEW_APP\" --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n      - name: Comment preview URL\n        uses: actions/github-script@v7\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: context.issue.number,\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `🚀 Preview deployment: https://$PREVIEW_APP.fly.dev`\n            })\n```\n\n**✅ Good - Auto-cleanup preview deployments:**\n\n```yaml\n# .github/workflows/cleanup-preview.yml\nname: Cleanup Preview\n\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - name: Destroy preview app\n        run: |\n          PREVIEW_APP=\"my-app-pr-${{ github.event.pull_request.number }}\"\n          flyctl apps destroy \"$PREVIEW_APP\" --yes\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n```\n\n### Environment Detection\n\n**✅ Good - Detect deployment environment:**\n\n```typescript\n// app/utils/env.server.ts\nexport function getDeploymentEnv():\n\t| 'production'\n\t| 'staging'\n\t| 'preview'\n\t| 'development' {\n\tif (process.env.NODE_ENV === 'development') {\n\t\treturn 'development'\n\t}\n\n\t// Preview deployments\n\tif (process.env.FLY_APP_NAME?.includes('pr-')) {\n\t\treturn 'preview'\n\t}\n\n\t// Staging environment\n\tif (process.env.FLY_APP_NAME?.includes('staging')) {\n\t\treturn 'staging'\n\t}\n\n\t// Production\n\treturn 'production'\n}\n```\n\n**✅ Good - Environment-specific configuration:**\n\n```typescript\nconst env = getDeploymentEnv()\n\nexport const config = {\n\tproduction: env === 'production',\n\tstaging: env === 'staging',\n\tpreview: env === 'preview',\n\tdevelopment: env === 'development',\n\n\t// Preview deployments might have limited features\n\tfeatures: {\n\t\tanalytics: env === 'production',\n\t\tsentry: env !== 'development',\n\t\tindexing: env === 'production',\n\t},\n}\n```\n\n### Build Artifact Exclusion\n\n**✅ Good - Optimize Docker builds:**\n\n```dockerfile\n# other/Dockerfile\n# Multi-stage build for smaller image size\nFROM node:20-alpine AS base\nWORKDIR /app\n\n# Install dependencies\nFROM base AS deps\nCOPY package*.json ./\nRUN npm ci --only=production\n\n# Build application\nFROM base AS builder\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# Production image\nFROM base AS runner\nENV NODE_ENV=production\n\n# Copy only what's needed\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY --from=builder /app/build ./build\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/server ./server\nCOPY --from=builder /app/other ./other\nCOPY --from=builder /app/prisma ./prisma\nCOPY --from=builder /app/package.json ./\n\n# Exclude unnecessary files\n# node_modules/.cache, .git, etc. are already excluded via .dockerignore\n\nCMD [\"npm\", \"start\"]\n```\n\n**✅ Good - Docker ignore file:**\n\n```dockerignore\n# .dockerignore (in other/)\nnode_modules\n.git\n.env\n.env.*\n!.env.example\n*.log\n.DS_Store\ncoverage\n.vscode\n.idea\n*.swp\n*.swo\n*~\n.cache\ndist\nbuild\n```\n\n### Deployment Status and Monitoring\n\n**✅ Good - Deployment status tracking:**\n\n```typescript\n// app/routes/admin/deployment-status.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst deploymentInfo = {\n\t\tappName: process.env.FLY_APP_NAME,\n\t\tregion: process.env.FLY_REGION,\n\t\tenvironment: getDeploymentEnv(),\n\t\tcommitSha: process.env.COMMIT_SHA,\n\t\tdeployedAt: process.env.DEPLOYED_AT,\n\t}\n\n\treturn { deploymentInfo }\n}\n```\n\n### Rollback Strategies\n\n**✅ Good - Quick rollback with Fly.io:**\n\n```bash\n# List recent releases\nfly releases list --app my-app\n\n# Rollback to previous release\nfly releases rollback --app my-app\n```\n\n**✅ Good - Automated rollback on failure:**\n\n```toml\n# fly.toml\n[experimental]\n  auto_rollback = true\n  min_machines_running = 1\n```\n\n<!-- File: docs/skills/epic-forms/SKILL.md (Tokens: ~4826 | Category: anthropic-skill) -->\n---\nname: epic-forms\ndescription: Guide on forms with Conform and validation with Zod for Epic Stack\ncategories:\n  - forms\n  - conform\n  - zod\n  - validation\n---\n\n# Epic Stack: Forms\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create forms in an Epic Stack application\n- Implement form validation with Zod\n- Work with Conform for progressively enhanced forms\n- Handle file uploads\n- Implement honeypot fields for spam protection\n- Handle form errors\n- Work with complex forms (fieldsets, arrays)\n\n## Patterns and conventions\n\n### Validation Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Make validation rules clear and explicit\nusing Zod schemas. Every validation rule should be visible in the schema, not\nhidden in business logic. Error messages should be specific and helpful, telling\nusers exactly what went wrong and how to fix it.\n\n**Design to fail fast and early** - Validate input as early as possible, ideally\non the client side before submission, and always on the server side. Return\nclear, specific error messages immediately so users can fix issues without\nfrustration.\n\n**Example - Explicit validation:**\n\n```typescript\n// ✅ Good - Explicit validation with clear error messages\nconst SignupSchema = z.object({\n\temail: z\n\t\t.string({ required_error: 'Email is required' })\n\t\t.email({ message: 'Please enter a valid email address' })\n\t\t.min(3, { message: 'Email must be at least 3 characters' })\n\t\t.max(100, { message: 'Email must be less than 100 characters' })\n\t\t.transform((val) => val.toLowerCase().trim()),\n\tpassword: z\n\t\t.string({ required_error: 'Password is required' })\n\t\t.min(6, { message: 'Password must be at least 6 characters' })\n\t\t.max(72, { message: 'Password must be less than 72 characters' }),\n})\n\n// ❌ Avoid - Implicit validation\nconst SignupSchema = z.object({\n\temail: z.string().email(), // No clear error messages\n\tpassword: z.string().min(6), // Generic error\n})\n```\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate early and return specific errors immediately\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: 400 }, // Clear error status\n\t\t)\n\t}\n\n\t// Only proceed if validation passed\n\tconst { email, password } = submission.value\n\t// ... continue with signup\n}\n\n// ❌ Avoid - Delayed or unclear validation\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tconst email = formData.get('email')\n\tconst password = formData.get('password')\n\n\t// Validation scattered throughout the function\n\tif (!email) {\n\t\t// Generic error, not specific\n\t\treturn json({ error: 'Invalid' }, { status: 400 })\n\t}\n\t// ... more scattered validation\n}\n```\n\n### Basic setup with Conform\n\nEpic Stack uses [Conform](https://conform.guide/) to handle forms with\nprogressive enhancement.\n\n**Basic setup:**\n\n```typescript\nimport { getFormProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tconstraint: getZodConstraint(SignupSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Integration with Zod\n\nConform integrates seamlessly with Zod for validation.\n\n**Define schema:**\n\n```typescript\nimport { z } from 'zod'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: z.string().email('Invalid email'),\n\t\tpassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\tconfirmPassword: z.string(),\n\t})\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'Passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Validation in action (fail fast):**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Validate immediately - explicit and fail fast\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// Return explicit errors immediately if validation fails\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Only proceed if validation passed - submission.value is type-safe\n\tconst { email, password } = submission.value\n\t// ... process with validated data\n}\n```\n\n### Async validation\n\nFor validations that require querying the database:\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true, // Important: enable async validation\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// ...\n}\n```\n\n### Field Components\n\nEpic Stack provides pre-built field components:\n\n**Basic Field:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\n**TextareaField:**\n\n```typescript\nimport { TextareaField } from '#app/components/forms.tsx'\nimport { getTextareaProps } from '@conform-to/react'\n\n<TextareaField\n\tlabelProps={{\n\t\thtmlFor: fields.content.id,\n\t\tchildren: 'Content',\n\t}}\n\ttextareaProps={{\n\t\t...getTextareaProps(fields.content),\n\t\trows: 10,\n\t}}\n\terrors={fields.content.errors}\n/>\n```\n\n**CheckboxField:**\n\n```typescript\nimport { CheckboxField } from '#app/components/forms.tsx'\nimport { getInputProps } from '@conform-to/react'\n\n<CheckboxField\n\tlabelProps={{\n\t\thtmlFor: fields.remember.id,\n\t\tchildren: 'Remember me',\n\t}}\n\tbuttonProps={getInputProps(fields.remember, { type: 'checkbox' })}\n\terrors={fields.remember.errors}\n/>\n```\n\n**OTPField:**\n\n```typescript\nimport { OTPField } from '#app/components/forms.tsx'\n\n<OTPField\n\tlabelProps={{\n\t\thtmlFor: fields.code.id,\n\t\tchildren: 'Verification Code',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.code),\n\t\tmaxLength: 6,\n\t}}\n\terrors={fields.code.errors}\n/>\n```\n\n### Error Handling\n\n**Display field errors:**\n\n```typescript\n<Field\n\t// ... props\n\terrors={fields.email.errors} // Errores específicos del campo\n/>\n```\n\n**Display form errors:**\n\n```typescript\nimport { ErrorList } from '#app/components/forms.tsx'\n\n<ErrorList errors={form.errors} id={form.errorId} />\n```\n\n**Error structure:**\n\n- `fields.fieldName.errors` - Errors for a specific field\n- `form.errors` - General form errors (like `formErrors`)\n\n### Honeypot Fields\n\nEpic Stack includes spam protection with honeypot fields.\n\n**In the form:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Rest of fields */}\n</Form>\n```\n\n**In the action:**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Throws error if spam\n\n\t// ... rest of code\n}\n```\n\n### File Uploads\n\nFor forms with file uploads, use `encType=\"multipart/form-data\"`.\n\n**Schema for files:**\n\n```typescript\nconst MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB\n\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z\n\t\t.instanceof(File)\n\t\t.optional()\n\t\t.refine((file) => {\n\t\t\treturn !file || file.size <= MAX_UPLOAD_SIZE\n\t\t}, 'File must be less than 3MB'),\n\taltText: z.string().optional(),\n})\n\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**Form with file upload:**\n\n```typescript\n<Form\n\tmethod=\"POST\"\n\tencType=\"multipart/form-data\"\n\t{...getFormProps(form)}\n>\n\t{/* Fields */}\n</Form>\n```\n\n**Process files in action:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteEditorSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\tconst { images } = submission.value\n\n\t// Process files\n\tfor (const image of images ?? []) {\n\t\tif (image.file) {\n\t\t\t// Upload file, save to storage, etc.\n\t\t}\n\t}\n\n\t// ...\n}\n```\n\n### Fieldsets y Arrays\n\nFor forms with repetitive fields (like multiple images):\n\n**Schema:**\n\n```typescript\nconst ImageFieldsetSchema = z.object({\n\tid: z.string().optional(),\n\tfile: z.instanceof(File).optional(),\n\taltText: z.string().optional(),\n})\n\nconst FormSchema = z.object({\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n```\n\n**In the component:**\n\n```typescript\nimport { FormProvider, getFieldsetProps } from '@conform-to/react'\n\nconst [form, fields] = useForm({\n\t// ...\n\tdefaultValue: {\n\t\timages: note?.images ?? [{}],\n\t},\n})\n\nconst imageList = fields.images.getFieldList()\n\nreturn (\n\t<FormProvider context={form.context}>\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{imageList.map((image, index) => {\n\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\treturn (\n\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t</fieldset>\n\t\t\t\t)\n\t\t\t})}\n\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t>\n\t\t\t\tAdd Image\n\t\t\t</button>\n\t\t</Form>\n\t</FormProvider>\n)\n```\n\n### StatusButton\n\nUse `StatusButton` to display submission status:\n\n```typescript\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\nimport { useIsPending } from '#app/utils/misc.tsx'\n\nconst isPending = useIsPending()\n\n<StatusButton\n\tclassName=\"w-full\"\n\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\ttype=\"submit\"\n\tdisabled={isPending}\n>\n\tSubmit\n</StatusButton>\n```\n\n### Progressive Enhancement\n\nForms work without JavaScript thanks to Conform:\n\n- Native HTML5 validation\n- Server-side validation\n- Enhancements with JavaScript when available\n\n**You don't need to do anything special** - Conform handles this automatically.\n\n### Reusable Schema Validation\n\nCreate reusable schemas in `app/utils/user-validation.ts`:\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase())\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long',\n\t})\n\nexport const PasswordAndConfirmPasswordSchema = z\n\t.object({ password: PasswordSchema, confirmPassword: PasswordSchema })\n\t.superRefine(({ confirmPassword, password }, ctx) => {\n\t\tif (confirmPassword !== password) {\n\t\t\tctx.addIssue({\n\t\t\t\tpath: ['confirmPassword'],\n\t\t\t\tcode: 'custom',\n\t\t\t\tmessage: 'The passwords must match',\n\t\t\t})\n\t\t}\n\t})\n```\n\n**Use in forms:**\n\n```typescript\nimport {\n\tEmailSchema,\n\tPasswordAndConfirmPasswordSchema,\n} from '#app/utils/user-validation.ts'\n\nconst SignupSchema = z\n\t.object({\n\t\temail: EmailSchema,\n\t\tusername: UsernameSchema,\n\t})\n\t.and(PasswordAndConfirmPasswordSchema)\n```\n\n## Common examples\n\n### Example 1: Simple login form\n\n```typescript\n// app/routes/_auth/login.tsx\nimport { getFormProps, getInputProps, useForm } from '@conform-to/react'\nimport { getZodConstraint, parseWithZod } from '@conform-to/zod'\nimport { z } from 'zod'\nimport { Field, ErrorList } from '#app/components/forms.tsx'\nimport { StatusButton } from '#app/components/ui/status-button.tsx'\n\nconst LoginSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(1),\n})\n\nexport default function LoginRoute({ actionData }: Route.ComponentProps) {\n\tconst isPending = useIsPending()\n\n\tconst [form, fields] = useForm({\n\t\tid: 'login-form',\n\t\tconstraint: getZodConstraint(LoginSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: LoginSchema })\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t\t\t\tautoFocus: true,\n\t\t\t\t\tautoComplete: 'email',\n\t\t\t\t}}\n\t\t\t\terrors={fields.email.errors}\n\t\t\t/>\n\t\t\t<Field\n\t\t\t\tlabelProps={{ htmlFor: fields.password.id, children: 'Password' }}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.password, { type: 'password' }),\n\t\t\t\t\tautoComplete: 'current-password',\n\t\t\t\t}}\n\t\t\t\terrors={fields.password.errors}\n\t\t\t/>\n\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t<StatusButton\n\t\t\t\tstatus={isPending ? 'pending' : (form.status ?? 'idle')}\n\t\t\t\ttype=\"submit\"\n\t\t\t>\n\t\t\t\tLogin\n\t\t\t</StatusButton>\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Form with async validation\n\n```typescript\n// app/routes/_auth/signup.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\tawait checkHoneypot(formData)\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema.superRefine(async (data, ctx) => {\n\t\t\tconst existingUser = await prisma.user.findUnique({\n\t\t\t\twhere: { email: data.email },\n\t\t\t\tselect: { id: true },\n\t\t\t})\n\t\t\tif (existingUser) {\n\t\t\t\tctx.addIssue({\n\t\t\t\t\tpath: ['email'],\n\t\t\t\t\tcode: z.ZodIssueCode.custom,\n\t\t\t\t\tmessage: 'A user already exists with this email',\n\t\t\t\t})\n\t\t\t}\n\t\t}),\n\t\tasync: true,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data(\n\t\t\t{ result: submission.reply() },\n\t\t\t{ status: submission.status === 'error' ? 400 : 200 },\n\t\t)\n\t}\n\n\t// Procesar signup...\n}\n```\n\n### Example 3: Form with file upload\n\n```typescript\n// app/routes/users/$username/notes/new.tsx\nconst NoteEditorSchema = z.object({\n\ttitle: z.string().min(1).max(100),\n\tcontent: z.string().min(1).max(10000),\n\timages: z.array(ImageFieldsetSchema).max(5).optional(),\n})\n\nexport default function NewNoteRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'note-editor',\n\t\tconstraint: getZodConstraint(NoteEditorSchema),\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: NoteEditorSchema })\n\t\t},\n\t\tdefaultValue: {\n\t\t\timages: [{}],\n\t\t},\n\t\tshouldRevalidate: 'onBlur',\n\t})\n\n\tconst imageList = fields.images.getFieldList()\n\n\treturn (\n\t\t<FormProvider context={form.context}>\n\t\t\t<Form\n\t\t\t\tmethod=\"POST\"\n\t\t\t\tencType=\"multipart/form-data\"\n\t\t\t\t{...getFormProps(form)}\n\t\t\t>\n\t\t\t\t<Field\n\t\t\t\t\tlabelProps={{ children: 'Title' }}\n\t\t\t\t\tinputProps={{\n\t\t\t\t\t\tautoFocus: true,\n\t\t\t\t\t\t...getInputProps(fields.title, { type: 'text' }),\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.title.errors}\n\t\t\t\t/>\n\t\t\t\t<TextareaField\n\t\t\t\t\tlabelProps={{ children: 'Content' }}\n\t\t\t\t\ttextareaProps={{\n\t\t\t\t\t\t...getTextareaProps(fields.content),\n\t\t\t\t\t\trows: 10,\n\t\t\t\t\t}}\n\t\t\t\t\terrors={fields.content.errors}\n\t\t\t\t/>\n\n\t\t\t\t{imageList.map((image, index) => {\n\t\t\t\t\tconst imageFieldset = getFieldsetProps(fields.images[index])\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<fieldset key={image.key} {...imageFieldset}>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].file, { type: 'file' })}\n\t\t\t\t\t\t\t\taccept=\"image/*\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t{...getInputProps(fields.images[index].altText, { type: 'text' })}\n\t\t\t\t\t\t\t\tplaceholder=\"Alt text\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t)\n\t\t\t\t})}\n\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => fields.images.append()}\n\t\t\t\t\tdisabled={imageList.length >= 5}\n\t\t\t\t>\n\t\t\t\t\tAdd Image\n\t\t\t\t</button>\n\n\t\t\t\t<ErrorList errors={form.errors} id={form.errorId} />\n\t\t\t\t<StatusButton type=\"submit\">Create Note</StatusButton>\n\t\t\t</Form>\n\t\t</FormProvider>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit validation**: Always use explicit Zod schemas with clear error\n  messages, not hidden validation logic\n- ❌ **Delayed validation**: Validate input immediately and fail fast - don't\n  wait until the end of the function\n- ❌ **Generic error messages**: Use specific, helpful error messages that tell\n  users exactly what's wrong\n- ❌ **Forgetting `async: true` in async validation**: Always include\n  `async: true` when using `superRefine` with async\n- ❌ **Not including `HoneypotInputs` in public forms**: Always include honeypot\n  in forms accessible without authentication\n- ❌ **Forgetting `encType=\"multipart/form-data\"`**: Required for file uploads\n- ❌ **Not validating file sizes**: Always validate size in the schema\n- ❌ **Not using `getZodConstraint`**: Required for native HTML5 validation\n- ❌ **Forgetting `lastResult` in `useForm`**: Required to display server errors\n- ❌ **Not using `shouldRevalidate: 'onBlur'`**: Improves UX by validating on\n  field blur\n- ❌ **Not using pre-built field components**: `Field`, `TextareaField`, etc.\n  already handle accessibility and errors\n\n## References\n\n- [Conform Documentation](https://conform.guide/)\n- [Zod Documentation](https://zod.dev/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/components/forms.tsx` - Field components\n- `app/routes/_auth/signup.tsx` - Complete signup example\n- `app/routes/_auth/onboarding/index.tsx` - Complex form example\n- `app/routes/users/$username/notes/+shared/note-editor.tsx` - File uploads\n  example\n- `app/utils/user-validation.ts` - Reusable schemas\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n\n<!-- File: docs/skills/epic-permissions/SKILL.md (Tokens: ~3259 | Category: anthropic-skill) -->\n---\nname: epic-permissions\ndescription: Guide on RBAC system and permissions for Epic Stack\ncategories:\n  - permissions\n  - rbac\n  - access-control\n---\n\n# Epic Stack: Permissions\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Implement role-based access control (RBAC)\n- Validate permissions on server-side or client-side\n- Create new permissions or roles\n- Restrict access to routes or actions\n- Implement granular permissions (`own` vs `any`)\n\n## Patterns and conventions\n\n### Permissions Philosophy\n\nFollowing Epic Web principles:\n\n**Explicit is better than implicit** - Always explicitly check permissions.\nDon't assume a user has access based on implicit rules or hidden logic. Every\npermission check should be visible and clear in the code.\n\n**Example - Explicit permission checks:**\n\n```typescript\n// ✅ Good - Explicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly check permission - clear and visible\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Permission check is explicit and obvious\n\tawait prisma.note.delete({ where: { id: noteId } })\n}\n\n// ❌ Avoid - Implicit permission check\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst note = await prisma.note.findUnique({ where: { id: noteId } })\n\n\t// Implicit check - not clear what permission is being checked\n\tif (note.ownerId !== userId) {\n\t\tthrow new Response('Forbidden', { status: 403 })\n\t}\n\t// What permission does this represent? Not explicit\n}\n```\n\n**Example - Explicit permission strings:**\n\n```typescript\n// ✅ Good - Explicit permission string\nconst permission: PermissionString = 'delete:note:own'\n// Clear: action (delete), entity (note), access (own)\n\nawait requireUserWithPermission(request, permission)\n\n// ❌ Avoid - Implicit or unclear permissions\nconst canDelete = checkUserCanDelete(user, note)\n// What permission is this checking? Not explicit\n```\n\n### RBAC Model\n\nEpic Stack uses an RBAC (Role-Based Access Control) model where:\n\n- **Users** have **Roles**\n- **Roles** have **Permissions**\n- A user's permissions are the union of all permissions from their roles\n\n### Permission Structure\n\nPermissions follow the format: `action:entity:access`\n\n**Components:**\n\n- `action`: The allowed action (`create`, `read`, `update`, `delete`)\n- `entity`: The entity being acted upon (`user`, `note`, etc.)\n- `access`: The access level (`own`, `any`, `own,any`)\n\n**Examples:**\n\n- `create:note:own` - Can create own notes\n- `read:note:any` - Can read any note\n- `delete:user:any` - Can delete any user (admin)\n- `update:note:own` - Can update only own notes\n\n### Prisma Schema\n\n**Models:**\n\n```prisma\nmodel Permission {\n  id          String @id @default(cuid())\n  action      String // e.g. create, read, update, delete\n  entity      String // e.g. note, user, etc.\n  access      String // e.g. own or any\n  description String @default(\"\")\n\n  roles Role[]\n\n  @@unique([action, entity, access])\n}\n\nmodel Role {\n  id          String @id @default(cuid())\n  name        String @unique\n  description String @default(\"\")\n\n  users       User[]\n  permissions Permission[]\n}\n\nmodel User {\n  id    String @id @default(cuid())\n  // ...\n  roles Role[]\n}\n```\n\n### Validate Permissions Server-Side\n\n**Require specific permission:**\n\n```typescript\nimport { requireUserWithPermission } from '#app/utils/permissions.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserWithPermission(\n\t\trequest,\n\t\t'delete:note:own', // Throws 403 error if doesn't have permission\n\t)\n\n\t// User has the permission, continue...\n}\n```\n\n**Require specific role:**\n\n```typescript\nimport { requireUserWithRole } from '#app/utils/permissions.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserWithRole(request, 'admin')\n\n\t// User has admin role, continue...\n}\n```\n\n**Conditional permissions (own vs any) - explicit:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Explicitly determine ownership\n\tconst note = await prisma.note.findUnique({\n\t\twhere: { id: noteId },\n\t\tselect: { ownerId: true },\n\t})\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Explicitly check the appropriate permission based on ownership\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string\n\t)\n\n\t// Permission check is explicit and clear\n\t// Proceed with deletion...\n}\n```\n\n### Validate Permissions Client-Side\n\n**Check if user has permission:**\n\n```typescript\nimport { userHasPermission, useOptionalUser } from '#app/utils/user.ts'\n\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t{canDelete && (\n\t\t\t\t<button onClick={handleDelete}>Delete</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n**Check if user has role:**\n\n```typescript\nimport { userHasRole } from '#app/utils/user.ts'\n\nexport default function AdminRoute() {\n\tconst user = useOptionalUser()\n\tconst isAdmin = userHasRole(user, 'admin')\n\n\tif (!isAdmin) {\n\t\treturn <div>Access Denied</div>\n\t}\n\n\treturn <div>Admin Panel</div>\n}\n```\n\n### Create New Permissions\n\n**En Prisma Studio o seed:**\n\n```typescript\n// prisma/seed.ts\nawait prisma.permission.create({\n\tdata: {\n\t\taction: 'create',\n\t\tentity: 'post',\n\t\taccess: 'own',\n\t\tdescription: 'Can create their own posts',\n\t\troles: {\n\t\t\tconnect: { name: 'user' },\n\t\t},\n\t},\n})\n```\n\n**Permiso con múltiples niveles de acceso:**\n\n```typescript\nawait prisma.permission.createMany({\n\tdata: [\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own posts',\n\t\t},\n\t\t{\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t],\n})\n```\n\n### Assign Roles to Users\n\n**When creating user:**\n\n```typescript\nconst user = await prisma.user.create({\n\tdata: {\n\t\temail,\n\t\tusername,\n\t\troles: {\n\t\t\tconnect: { name: 'user' }, // Assign 'user' role\n\t\t},\n\t},\n})\n```\n\n**Assign multiple roles:**\n\n```typescript\nawait prisma.user.update({\n\twhere: { id: userId },\n\tdata: {\n\t\troles: {\n\t\t\tconnect: [{ name: 'user' }, { name: 'moderator' }],\n\t\t},\n\t},\n})\n```\n\n### Permissions and Roles Seed\n\n**Seed example:**\n\n```typescript\n// prisma/seed.ts\n\n// Create permissions\nconst permissions = await Promise.all([\n\t// User permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can read own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'update',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can update own notes',\n\t\t},\n\t}),\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'note',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can delete own notes',\n\t\t},\n\t}),\n\t// Admin permissions\n\tprisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'delete',\n\t\t\tentity: 'user',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can delete any user',\n\t\t},\n\t}),\n])\n\n// Create roles\nconst userRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'user',\n\t\tdescription: 'Standard user',\n\t\tpermissions: {\n\t\t\tconnect: permissions.slice(0, 4).map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n\nconst adminRole = await prisma.role.create({\n\tdata: {\n\t\tname: 'admin',\n\t\tdescription: 'Administrator',\n\t\tpermissions: {\n\t\t\tconnect: permissions.map((p) => ({ id: p.id })),\n\t\t},\n\t},\n})\n```\n\n### Permission Type\n\n**Type-safe permission strings:**\n\n```typescript\nimport { type PermissionString } from '#app/utils/user.ts'\n\n// Tipo: 'create:note:own' | 'read:note:own' | etc.\nconst permission: PermissionString = 'delete:note:own'\n```\n\n**Parsear permission string:**\n\n```typescript\nimport { parsePermissionString } from '#app/utils/user.ts'\n\nconst { action, entity, access } = parsePermissionString('delete:note:own')\n// action: 'delete'\n// entity: 'note'\n// access: ['own']\n```\n\n## Common examples\n\n### Example 1: Proteger action con permiso\n\n```typescript\n// app/routes/users/$username/notes/$noteId.tsx\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\tconst { noteId } = Object.fromEntries(formData)\n\n\tconst note = await prisma.note.findFirst({\n\t\tselect: { id: true, ownerId: true, owner: { select: { username: true } } },\n\t\twhere: { id: noteId },\n\t})\n\n\tif (!note) {\n\t\tthrow new Response('Not found', { status: 404 })\n\t}\n\n\tconst isOwner = note.ownerId === userId\n\n\t// Validate permiso según si es propietario o no\n\tawait requireUserWithPermission(\n\t\trequest,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\n\tawait prisma.note.delete({ where: { id: note.id } })\n\n\treturn redirect(`/users/${note.owner.username}/notes`)\n}\n```\n\n### Example 2: Mostrar UI condicional basada en permisos\n\n```typescript\nexport default function NoteRoute({ loaderData }: Route.ComponentProps) {\n\tconst user = useOptionalUser()\n\tconst isOwner = user?.id === loaderData.note.ownerId\n\n\tconst canDelete = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'delete:note:own' : 'delete:note:any',\n\t)\n\tconst canEdit = userHasPermission(\n\t\tuser,\n\t\tisOwner ? 'update:note:own' : 'update:note:any',\n\t)\n\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.note.title}</h1>\n\t\t\t<p>{loaderData.note.content}</p>\n\n\t\t\t{(canEdit || canDelete) && (\n\t\t\t\t<div className=\"flex gap-2\">\n\t\t\t\t\t{canEdit && (\n\t\t\t\t\t\t<Link to=\"edit\">\n\t\t\t\t\t\t\t<Button>Edit</Button>\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t)}\n\t\t\t\t\t{canDelete && (\n\t\t\t\t\t\t<DeleteNoteButton noteId={loaderData.note.id} />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n```\n\n### Example 3: Ruta solo para admin\n\n```typescript\n// app/routes/admin/users.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tawait requireUserWithRole(request, 'admin')\n\n\tconst users = await prisma.user.findMany({\n\t\tselect: {\n\t\t\tid: true,\n\t\t\temail: true,\n\t\t\tusername: true,\n\t\t},\n\t})\n\n\treturn { users }\n}\n\nexport default function AdminUsersRoute({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>All Users</h1>\n\t\t\t{loaderData.users.map(user => (\n\t\t\t\t<div key={user.id}>{user.username}</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n### Example 4: Create new permission and assign it\n\n```typescript\n// Migración o seed\nasync function setupPostPermissions() {\n\t// Create post permissions\n\tconst createOwn = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'create',\n\t\t\tentity: 'post',\n\t\t\taccess: 'own',\n\t\t\tdescription: 'Can create own posts',\n\t\t},\n\t})\n\n\tconst readAny = await prisma.permission.create({\n\t\tdata: {\n\t\t\taction: 'read',\n\t\t\tentity: 'post',\n\t\t\taccess: 'any',\n\t\t\tdescription: 'Can read any post',\n\t\t},\n\t})\n\n\t// Assign to user role\n\tawait prisma.role.update({\n\t\twhere: { name: 'user' },\n\t\tdata: {\n\t\t\tpermissions: {\n\t\t\t\tconnect: [{ id: createOwn.id }, { id: readAny.id }],\n\t\t\t},\n\t\t},\n\t})\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Implicit permission checks**: Always explicitly check permissions - make\n  permission requirements visible in code\n- ❌ **Not validating permissions on server-side**: Always validate permissions\n  in action/loader, never trust client-side only\n- ❌ **Forgetting to verify `own` vs `any`**: Explicitly determine if user is\n  owner before validating permission\n- ❌ **Not using correct helpers**: Use `requireUserWithPermission` for\n  server-side and `userHasPermission` for client-side - explicit helpers\n- ❌ **Not creating unique permissions**: Use\n  `@@unique([action, entity, access])` in schema - explicit permission structure\n- ❌ **Assuming permissions instead of verifying**: Always verify explicitly,\n  even if you think user has the permission\n- ❌ **Not handling 403 errors**: Helpers throw errors that must be handled by\n  ErrorBoundary\n- ❌ **Not using types**: Use `PermissionString` type for type-safety - explicit\n  types\n- ❌ **Hidden permission logic**: Don't hide permission checks in utility\n  functions - make them explicit at the call site\n\n## References\n\n- [Epic Stack Permissions Docs](../epic-stack/docs/permissions.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [RBAC Explained](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac)\n- `app/utils/permissions.server.ts` - Server-side permission utilities\n- `app/utils/user.ts` - Client-side permission utilities\n- `prisma/schema.prisma` - Permission and Role models\n- `prisma/seed.ts` - Permission seed examples\n\n<!-- File: docs/skills/epic-react-patterns/SKILL.md (Tokens: ~4110 | Category: anthropic-skill) -->\n---\nname: epic-react-patterns\ndescription:\n  Guide on React patterns, performance optimization, and code quality for Epic\n  Stack\ncategories:\n  - react\n  - performance\n  - patterns\n  - optimization\n---\n\n# Epic Stack: React Patterns and Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write efficient React components in Epic Stack applications\n- Optimize performance and bundle size\n- Follow React Router patterns and conventions\n- Avoid common React anti-patterns\n- Implement proper code splitting\n- Optimize re-renders and data fetching\n- Use React hooks correctly\n\n## Philosophy\n\nFollowing Epic Web principles:\n\n- **Make it work, make it right, make it fast** - In that order. First make it\n  functional, then refactor for clarity, then optimize for performance.\n- **Pragmatism over purity** - Choose practical solutions that work well in your\n  context rather than theoretically perfect ones.\n- **Optimize for sustainable velocity** - Write code that's easy to maintain and\n  extend, not just fast to write initially.\n- **Do as little as possible** - Only add complexity when it provides real\n  value.\n\n## Patterns and conventions\n\n### Data Fetching in React Router\n\nEpic Stack uses React Router loaders for data fetching, not `useEffect`.\n\n**✅ Good - Use loaders:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n**❌ Avoid - Don't fetch in useEffect:**\n\n```typescript\n// ❌ Don't do this\nexport default function UserRoute({ params }: Route.ComponentProps) {\n\tconst [user, setUser] = useState(null)\n\n\tuseEffect(() => {\n\t\tfetch(`/api/users/${params.username}`)\n\t\t\t.then(res => res.json())\n\t\t\t.then(setUser)\n\t}, [params.username])\n\n\treturn user ? <div>{user.name}</div> : <div>Loading...</div>\n}\n```\n\n### Avoid useEffect for Side Effects\n\n[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect)\n\nInstead of using `useEffect`, use event handlers, CSS, ref callbacks, or\n`useSyncExternalStore`.\n\n**✅ Good - Use event handlers:**\n\n```typescript\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tfunction buyProduct() {\n\t\taddToCart(product)\n\t\tshowNotification(`Added ${product.name} to cart!`)\n\t}\n\n\tfunction handleBuyClick() {\n\t\tbuyProduct()\n\t}\n\n\tfunction handleCheckoutClick() {\n\t\tbuyProduct()\n\t\tnavigate('/checkout')\n\t}\n\n\treturn (\n\t\t<div>\n\t\t\t<button onClick={handleBuyClick}>Buy Now</button>\n\t\t\t<button onClick={handleCheckoutClick}>Checkout</button>\n\t\t</div>\n\t)\n}\n```\n\n**❌ Avoid - Side effects in useEffect:**\n\n```typescript\n// ❌ Don't do this\nfunction ProductPage({ product, addToCart }: Route.ComponentProps) {\n\tuseEffect(() => {\n\t\tif (product.isInCart) {\n\t\t\tshowNotification(`Added ${product.name} to cart!`)\n\t\t}\n\t}, [product])\n\n\tfunction handleBuyClick() {\n\t\taddToCart(product)\n\t}\n\n\t// ...\n}\n```\n\n**✅ Appropriate use of useEffect:**\n\n```typescript\n// ✅ Good - Event listeners are appropriate\nuseEffect(() => {\n\tconst controller = new AbortController()\n\n\twindow.addEventListener(\n\t\t'keydown',\n\t\t(event: KeyboardEvent) => {\n\t\t\tif (event.key !== 'Escape') return\n\t\t\t// handle escape key\n\t\t},\n\t\t{ signal: controller.signal },\n\t)\n\n\treturn () => {\n\t\tcontroller.abort()\n\t}\n}, [])\n```\n\n### Code Splitting with React Router\n\nReact Router automatically code-splits by route. Use dynamic imports for heavy\ncomponents.\n\n**✅ Good - Dynamic imports:**\n\n```typescript\n// app/routes/admin/dashboard.tsx\nimport { lazy } from 'react'\n\nconst AdminChart = lazy(() => import('#app/components/admin/chart.tsx'))\n\nexport default function AdminDashboard() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading chart...</div>}>\n\t\t\t<AdminChart />\n\t\t</Suspense>\n\t)\n}\n```\n\n### Optimizing Re-renders\n\n**✅ Good - Memoize expensive computations:**\n\n```typescript\nimport { useMemo } from 'react'\n\nfunction UserList({ users }: { users: User[] }) {\n\tconst sortedUsers = useMemo(() => {\n\t\treturn [...users].sort((a, b) => a.name.localeCompare(b.name))\n\t}, [users])\n\n\treturn (\n\t\t<ul>\n\t\t\t{sortedUsers.map(user => (\n\t\t\t\t<li key={user.id}>{user.name}</li>\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**✅ Good - Memoize callbacks:**\n\n```typescript\nimport { useCallback } from 'react'\n\nfunction NoteEditor({ noteId, onSave }: { noteId: string; onSave: (note: Note) => void }) {\n\tconst handleSave = useCallback((note: Note) => {\n\t\tonSave(note)\n\t}, [onSave])\n\n\treturn <Editor onSave={handleSave} />\n}\n```\n\n**❌ Avoid - Unnecessary memoization:**\n\n```typescript\n// ❌ Don't memoize simple values\nconst count = useMemo(() => items.length, [items]) // Just use items.length directly\n\n// ❌ Don't memoize simple callbacks\nconst handleClick = useCallback(() => {\n\tconsole.log('clicked')\n}, []) // Just define the function normally if it doesn't need memoization\n```\n\n### Bundle Size Optimization\n\n**✅ Good - Import only what you need:**\n\n```typescript\n// ✅ Import specific functions\nimport { useSearchParams } from 'react-router'\nimport { parseWithZod } from '@conform-to/zod'\n```\n\n**❌ Avoid - Barrel imports:**\n\n```typescript\n// ❌ Don't import entire libraries if you only need one thing\nimport * as ReactRouter from 'react-router'\nimport * as Conform from '@conform-to/zod'\n```\n\n### Form Handling with Conform\n\n**✅ Good - Use Conform for forms:**\n\n```typescript\nimport { useForm, getFormProps } from '@conform-to/react'\nimport { parseWithZod } from '@conform-to/zod'\nimport { Form } from 'react-router'\n\nconst SignupSchema = z.object({\n\temail: z.string().email(),\n\tpassword: z.string().min(6),\n})\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\tconst [form, fields] = useForm({\n\t\tid: 'signup-form',\n\t\tlastResult: actionData?.result,\n\t\tonValidate({ formData }) {\n\t\t\treturn parseWithZod(formData, { schema: SignupSchema })\n\t\t},\n\t})\n\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t{/* form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Component Composition\n\n**✅ Good - Compose components:**\n\n```typescript\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<Card>\n\t\t\t<UserHeader user={user} />\n\t\t\t<UserDetails user={user} />\n\t\t\t<UserActions userId={user.id} />\n\t\t</Card>\n\t)\n}\n```\n\n**❌ Avoid - Large monolithic components:**\n\n```typescript\n// ❌ Don't put everything in one component\nfunction UserProfile({ user }: { user: User }) {\n\treturn (\n\t\t<div className=\"card\">\n\t\t\t<div className=\"header\">\n\t\t\t\t<img src={user.avatar} alt={user.name} />\n\t\t\t\t<h1>{user.name}</h1>\n\t\t\t</div>\n\t\t\t<div className=\"details\">\n\t\t\t\t<p>{user.email}</p>\n\t\t\t\t<p>{user.bio}</p>\n\t\t\t</div>\n\t\t\t<div className=\"actions\">\n\t\t\t\t<button>Edit</button>\n\t\t\t\t<button>Delete</button>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n```\n\n### Error Boundaries\n\n**✅ Good - Use error boundaries:**\n\n```typescript\n// app/routes/users/$username.tsx\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>User \"{params.username}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### TypeScript Guidelines\n\n**✅ Good - Type props explicitly:**\n\n```typescript\ninterface UserCardProps {\n\tuser: {\n\t\tid: string\n\t\tname: string\n\t\temail: string\n\t}\n\tonEdit?: (userId: string) => void\n}\n\nfunction UserCard({ user, onEdit }: UserCardProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h2>{user.name}</h2>\n\t\t\t<p>{user.email}</p>\n\t\t\t{onEdit && <button onClick={() => onEdit(user.id)}>Edit</button>}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Route types:**\n\n```typescript\nimport type { Route } from './+types/users.$username'\n\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params is type-safe!\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\t// loaderData is type-safe!\n\treturn <div>{loaderData.user.name}</div>\n}\n```\n\n### Loading States\n\n**✅ Good - Use React Router's pending states:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction NoteForm() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<button type=\"submit\" disabled={isSubmitting}>\n\t\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t\t</button>\n\t\t</Form>\n\t)\n}\n```\n\n### Preventing Data Fetching Waterfalls\n\nReact Router loaders can prevent waterfalls by fetching data in parallel.\n\n**❌ Avoid - Sequential data fetching (waterfall):**\n\n```typescript\n// ❌ Don't do this - creates a waterfall\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t})\n\t// Second fetch waits for first to complete\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t})\n\treturn { user, notes }\n}\n```\n\n**✅ Good - Parallel data fetching:**\n\n```typescript\n// ✅ Fetch data in parallel\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\n\t// Fetch notes in parallel with user data\n\tconst [notes, stats] = await Promise.all([\n\t\tuser\n\t\t\t? prisma.note.findMany({\n\t\t\t\t\twhere: { ownerId: user.id },\n\t\t\t\t\tselect: { id: true, title: true, updatedAt: true },\n\t\t\t\t})\n\t\t\t: Promise.resolve([]),\n\t\tuser\n\t\t\t? prisma.note.count({ where: { ownerId: user.id } })\n\t\t\t: Promise.resolve(0),\n\t])\n\n\treturn { user, notes, stats }\n}\n```\n\n**✅ Good - Nested route parallel loading:**\n\n```typescript\n// Parent route loader\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\n// Child route loader runs in parallel\n// app/routes/users/$username/notes.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true },\n\t})\n\n\tif (!user) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\tconst notes = await prisma.note.findMany({\n\t\twhere: { ownerId: user.id },\n\t\tselect: { id: true, title: true, updatedAt: true },\n\t})\n\n\treturn { notes }\n}\n```\n\n### Server-Side Rendering (SSR) Performance\n\nReact Router provides SSR by default. Optimize by:\n\n**✅ Good - Selective data fetching:**\n\n```typescript\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Only fetch what's needed for initial render\n\tconst searchParams = new URL(request.url).searchParams\n\tconst page = Number(searchParams.get('page') || '1')\n\n\tconst [items, total] = await Promise.all([\n\t\tprisma.item.findMany({\n\t\t\ttake: 20,\n\t\t\tskip: (page - 1) * 20,\n\t\t\tselect: { id: true, title: true }, // Only needed fields\n\t\t}),\n\t\tprisma.item.count(),\n\t])\n\n\treturn { items, total, page }\n}\n```\n\n**✅ Good - Use caching for expensive operations:**\n\n```typescript\nimport { cachified, cache } from '#app/utils/cache.server.ts'\n\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst timings: Timings = {}\n\n\t// Cache expensive database queries\n\tconst stats = await cachified({\n\t\tkey: 'user-stats',\n\t\tcache,\n\t\ttimings,\n\t\tgetFreshValue: async () => {\n\t\t\treturn await prisma.user.aggregate({\n\t\t\t\t_count: { id: true },\n\t\t\t})\n\t\t},\n\t\tttl: 1000 * 60 * 5, // 5 minutes\n\t})\n\n\treturn { stats }\n}\n```\n\n### Rendering Performance\n\n**✅ Good - Use React.memo for expensive components:**\n\n```typescript\nimport { memo } from 'react'\n\nconst ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data[] }) {\n\t// Expensive rendering logic\n\treturn <Chart data={data} />\n})\n\n// Only re-renders when data changes\nexport default function Dashboard({ chartData }: { chartData: Data[] }) {\n\treturn <ExpensiveChart data={chartData} />\n}\n```\n\n**✅ Good - Optimize list rendering:**\n\n```typescript\nimport { memo } from 'react'\n\nconst UserItem = memo(function UserItem({ user }: { user: User }) {\n\treturn (\n\t\t<li>\n\t\t\t<h3>{user.name}</h3>\n\t\t\t<p>{user.email}</p>\n\t\t</li>\n\t)\n}, (prev, next) => prev.user.id === next.user.id)\n\nfunction UserList({ users }: { users: User[] }) {\n\treturn (\n\t\t<ul>\n\t\t\t{users.map(user => (\n\t\t\t\t<UserItem key={user.id} user={user} />\n\t\t\t))}\n\t\t</ul>\n\t)\n}\n```\n\n**❌ Avoid - Creating new objects/arrays in render:**\n\n```typescript\n// ❌ Don't create new objects on every render\nfunction UserProfile({ user }: { user: User }) {\n\treturn <Card user={{ ...user, fullName: `${user.firstName} ${user.lastName}` }} />\n}\n\n// ✅ Good - Compute in loader or memoize\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { firstName: true, lastName: true },\n\t})\n\n\treturn {\n\t\tuser: {\n\t\t\t...user,\n\t\t\tfullName: `${user.firstName} ${user.lastName}`,\n\t\t},\n\t}\n}\n```\n\n### Bundle Size Optimization Strategies\n\n**✅ Good - Route-based code splitting:** React Router automatically splits code\nby route. Leverage this:\n\n```typescript\n// Heavy dependencies are automatically split by route\n// app/routes/admin/dashboard.tsx\nimport { Chart } from 'chart.js' // Only loaded on /admin/dashboard route\n```\n\n**✅ Good - Dynamic imports for heavy components:**\n\n```typescript\nimport { lazy, Suspense } from 'react'\n\nconst HeavyComponent = lazy(() => import('#app/components/heavy-component.tsx'))\n\nexport default function Route() {\n\treturn (\n\t\t<Suspense fallback={<div>Loading...</div>}>\n\t\t\t<HeavyComponent />\n\t\t</Suspense>\n\t)\n}\n```\n\n**✅ Good - Tree-shakeable imports:**\n\n```typescript\n// ✅ Tree-shakeable - only imports what you use\nimport { format } from 'date-fns/format'\nimport { addDays } from 'date-fns/addDays'\n\n// ❌ Avoid - imports entire library\nimport * as dateFns from 'date-fns'\n```\n\n### React 18+ Features for Performance\n\n**✅ Good - Use transitions for non-urgent updates:**\n\n```typescript\nimport { useTransition } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction SearchInput() {\n\tconst [isPending, startTransition] = useTransition()\n\tconst navigation = useNavigation()\n\n\tfunction handleSearch(query: string) {\n\t\tstartTransition(() => {\n\t\t\t// Update search results (non-urgent)\n\t\t\tnavigation.navigate(`/search?q=${query}`)\n\t\t})\n\t}\n\n\treturn (\n\t\t<input\n\t\t\tonChange={(e) => handleSearch(e.target.value)}\n\t\t\tplaceholder={isPending ? 'Searching...' : 'Search'}\n\t\t/>\n\t)\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Fetching data in useEffect**: Use React Router loaders instead\n- ❌ **Overusing useEffect**: Prefer event handlers, CSS, or ref callbacks\n- ❌ **Premature memoization**: Only memoize when there's a measurable\n  performance benefit\n- ❌ **Barrel imports**: Import only what you need\n- ❌ **Ignoring TypeScript types**: Use Route types for type safety\n- ❌ **Not handling loading states**: Use React Router's navigation states\n- ❌ **Large monolithic components**: Break components into smaller, focused\n  pieces\n- ❌ **Not using error boundaries**: Always add error boundaries to routes\n- ❌ **Client-side routing when server-side works**: Prefer server-side data\n  fetching\n- ❌ **Data fetching waterfalls**: Use `Promise.all()` to fetch data in parallel\n- ❌ **Fetching unnecessary data**: Only fetch what's needed for the initial\n  render\n- ❌ **Creating new objects in render**: Compute derived data in loaders or\n  memoize\n- ❌ **Not using React.memo for expensive lists**: Memoize list items for better\n  performance\n- ❌ **Not leveraging route-based code splitting**: React Router splits by route\n  automatically\n\n## References\n\n- [React Router Documentation](https://reactrouter.com/)\n- [React Documentation - You Might Not Need useEffect](https://react.dev/learn/you-might-not-need-an-effect)\n- [Conform Documentation](https://conform.guide/)\n- [Epic Stack Docs](https://www.epicweb.dev/docs)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- `app/routes/` - Example routes using these patterns\n- `.cursor/rules/avoid-use-effect.mdc` - Epic Stack rule for avoiding useEffect\n\n<!-- File: docs/skills/epic-routing/SKILL.md (Tokens: ~2914 | Category: anthropic-skill) -->\n---\nname: epic-routing\ndescription:\n  Guide on routing with React Router and react-router-auto-routes for Epic Stack\ncategories:\n  - routing\n  - react-router\n  - file-based-routing\n---\n\n# Epic Stack: Routing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create new routes or pages in an Epic Stack application\n- Implement nested layouts\n- Configure resource routes (routes without UI)\n- Work with route parameters and search params\n- Understand Epic Stack's file-based routing conventions\n- Implement loaders and actions in routes\n\n## Patterns and conventions\n\n### Routing Philosophy\n\nFollowing Epic Web principles:\n\n**Do as little as possible** - Keep your route structure simple. Don't create\ncomplex nested routes unless you actually need them. Start simple and add\ncomplexity only when there's a clear benefit.\n\n**Avoid over-engineering** - Don't create abstractions or complex route\nstructures \"just in case\". Use the simplest structure that works for your\ncurrent needs.\n\n**Example - Simple route structure:**\n\n```typescript\n// ✅ Good - Simple, straightforward route\n// app/routes/users/$username.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username: params.username },\n\t\tselect: { id: true, username: true, name: true },\n\t})\n\treturn { user }\n}\n\nexport default function UserRoute({ loaderData }: Route.ComponentProps) {\n\treturn <div>{loaderData.user.name}</div>\n}\n\n// ❌ Avoid - Over-engineered route structure\n// app/routes/users/$username/_layout.tsx\n// app/routes/users/$username/index.tsx\n// app/routes/users/$username/_components/UserHeader.tsx\n// app/routes/users/$username/_components/UserDetails.tsx\n// Unnecessary complexity for a simple user page\n```\n\n**Example - Add complexity only when needed:**\n\n```typescript\n// ✅ Good - Add nested routes only when you actually need them\n// If you have user notes, then nested routes make sense:\n// app/routes/users/$username/notes/_layout.tsx\n// app/routes/users/$username/notes/index.tsx\n// app/routes/users/$username/notes/$noteId.tsx\n\n// ❌ Avoid - Creating nested routes \"just in case\"\n// Don't create complex structures before you need them\n```\n\n### File-based routing with react-router-auto-routes\n\nEpic Stack uses `react-router-auto-routes` instead of React Router's standard\nconvention. This enables better organization and code co-location.\n\n**Basic structure:**\n\n```\napp/routes/\n├── _layout.tsx        # Layout for child routes\n├── index.tsx          # Root route (/)\n├── about.tsx          # Route /about\n└── users/\n    ├── _layout.tsx    # Layout for user routes\n    ├── index.tsx      # Route /users\n    └── $username/\n        └── index.tsx  # Route /users/:username\n```\n\n**Configuration in `app/routes.ts`:**\n\n```typescript\nimport { type RouteConfig } from '@react-router/dev/routes'\nimport { autoRoutes } from 'react-router-auto-routes'\n\nexport default autoRoutes({\n\tignoredRouteFiles: [\n\t\t'.*',\n\t\t'**/*.css',\n\t\t'**/*.test.{js,jsx,ts,tsx}',\n\t\t'**/__*.*',\n\t\t'**/*.server.*', // Co-located server utilities\n\t\t'**/*.client.*', // Co-located client utilities\n\t],\n}) satisfies RouteConfig\n```\n\n### Route Groups\n\nRoute groups are folders that start with `_` and don't affect the URL but help\norganize related code.\n\n**Common examples:**\n\n- `_auth/` - Authentication routes (login, signup, etc.)\n- `_marketing/` - Marketing pages (home, about, etc.)\n- `_seo/` - SEO routes (sitemap, robots.txt)\n\n**Example:**\n\n```\napp/routes/\n├── _auth/\n│   ├── login.tsx          # URL: /login\n│   ├── signup.tsx         # URL: /signup\n│   └── forgot-password.tsx # URL: /forgot-password\n└── _marketing/\n    ├── index.tsx          # URL: /\n    └── about.tsx          # URL: /about\n```\n\n### Route Parameters\n\nUse `$` to indicate route parameters:\n\n**Syntax:**\n\n- `$param.tsx` → `:param` in URL\n- `$username.tsx` → `:username` in URL\n\n**Example route with parameter:**\n\n```typescript\n// app/routes/users/$username/index.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst username = params.username // Type-safe!\n\n\tconst user = await prisma.user.findUnique({\n\t\twhere: { username },\n\t})\n\n\treturn { user }\n}\n```\n\n### Nested Layouts with `_layout.tsx`\n\nUse `_layout.tsx` to create shared layouts for child routes.\n\n**Example:**\n\n```typescript\n// app/routes/users/$username/notes/_layout.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst owner = await prisma.user.findFirst({\n\t\twhere: { username: params.username },\n\t})\n\treturn { owner }\n}\n\nexport default function NotesLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<main className=\"container\">\n\t\t\t<h1>{loaderData.owner.name}'s Notes</h1>\n\t\t\t<Outlet /> {/* Child routes render here */}\n\t\t</main>\n\t)\n}\n```\n\nChild routes (`$noteId.tsx`, `index.tsx`, etc.) will render where `<Outlet />`\nis.\n\n### Resource Routes (Routes without UI)\n\nResource routes don't render UI; they only return data or perform actions.\n\n**Characteristics:**\n\n- Don't export a `default` component\n- Export `loader` or `action` or both\n- Useful for APIs, downloads, webhooks, etc.\n\n**Example:**\n\n```typescript\n// app/routes/resources/healthcheck.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\t// Check application health\n\tconst host =\n\t\trequest.headers.get('X-Forwarded-Host') ?? request.headers.get('host')\n\n\ttry {\n\t\tawait Promise.all([\n\t\t\tprisma.$queryRaw`SELECT 1`, // Check DB\n\t\t\tfetch(`${new URL(request.url).protocol}${host}`, {\n\t\t\t\tmethod: 'HEAD',\n\t\t\t\theaders: { 'X-Healthcheck': 'true' },\n\t\t\t}),\n\t\t])\n\t\treturn new Response('OK')\n\t} catch (error) {\n\t\treturn new Response('ERROR', { status: 500 })\n\t}\n}\n```\n\n### Loaders and Actions\n\n**Loaders** - Load data before rendering (GET requests) **Actions** - Handle\ndata mutations (POST, PUT, DELETE)\n\n**Loader pattern:**\n\n```typescript\nexport async function loader({ request, params }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst data = await prisma.something.findMany({\n\t\twhere: { userId },\n\t})\n\n\treturn { data }\n}\n\nexport default function RouteComponent({ loaderData }: Route.ComponentProps) {\n\treturn <div>{/* Use loaderData.data */}</div>\n}\n```\n\n**Action pattern:**\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\tconst formData = await request.formData()\n\n\t// Validate and process data\n\tawait prisma.something.create({\n\t\tdata: { /* ... */ },\n\t})\n\n\treturn redirect('/success')\n}\n\nexport default function RouteComponent() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t{/* Form fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Search Params\n\nAccess query parameters using `useSearchParams`:\n\n```typescript\nimport { useSearchParams } from 'react-router'\n\nexport default function SearchPage() {\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\tconst query = searchParams.get('q') || ''\n\tconst page = Number(searchParams.get('page') || '1')\n\n\treturn (\n\t\t<div>\n\t\t\t<input\n\t\t\t\tvalue={query}\n\t\t\t\tonChange={(e) => setSearchParams({ q: e.target.value })}\n\t\t\t/>\n\t\t\t{/* Results */}\n\t\t</div>\n\t)\n}\n```\n\n### Code Co-location\n\nEpic Stack encourages placing related code close to where it's used.\n\n**Typical structure:**\n\n```\napp/routes/users/$username/notes/\n├── _layout.tsx              # Layout with loader\n├── index.tsx                # Notes list\n├── $noteId.tsx              # Note view\n├── $noteId_.edit.tsx        # Edit note\n├── +shared/                 # Code shared between routes\n│   └── note-editor.tsx      # Shared editor\n└── $noteId.server.ts        # Server-side utilities\n```\n\nThe `+` prefix indicates co-located modules that are not routes.\n\n### Naming Conventions\n\n- `_layout.tsx` - Layout for child routes\n- `index.tsx` - Root route of the segment\n- `$param.tsx` - Route parameter\n- `$param_.action.tsx` - Route with parameter + action (using `_`)\n- `[.]ext.tsx` - Resource route (e.g., `robots[.]txt.ts`)\n\n## Common examples\n\n### Example 1: Create a basic route with layout\n\n```typescript\n// app/routes/products/_layout.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst categories = await prisma.category.findMany()\n\treturn { categories }\n}\n\nexport default function ProductsLayout({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<nav>\n\t\t\t\t{loaderData.categories.map(cat => (\n\t\t\t\t\t<Link key={cat.id} to={`/products/${cat.slug}`}>\n\t\t\t\t\t\t{cat.name}\n\t\t\t\t\t</Link>\n\t\t\t\t))}\n\t\t\t</nav>\n\t\t\t<Outlet />\n\t\t</div>\n\t)\n}\n\n// app/routes/products/index.tsx\nexport default function ProductsIndex() {\n\treturn <div>Products list</div>\n}\n```\n\n### Example 2: Route with dynamic parameter\n\n```typescript\n// app/routes/products/$slug.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\tconst product = await prisma.product.findUnique({\n\t\twhere: { slug: params.slug },\n\t})\n\n\tif (!product) {\n\t\tthrow new Response('Not Found', { status: 404 })\n\t}\n\n\treturn { product }\n}\n\nexport default function ProductPage({ loaderData }: Route.ComponentProps) {\n\treturn (\n\t\t<div>\n\t\t\t<h1>{loaderData.product.name}</h1>\n\t\t\t<p>{loaderData.product.description}</p>\n\t\t</div>\n\t)\n}\n\nexport function ErrorBoundary() {\n\treturn (\n\t\t<GeneralErrorBoundary\n\t\t\tstatusHandlers={{\n\t\t\t\t404: ({ params }) => (\n\t\t\t\t\t<p>Product \"{params.slug}\" not found</p>\n\t\t\t\t),\n\t\t\t}}\n\t\t/>\n\t)\n}\n```\n\n### Example 3: Resource route for download\n\n```typescript\n// app/routes/resources/download-report.tsx\nexport async function loader({ request }: Route.LoaderArgs) {\n\tconst userId = await requireUserId(request)\n\n\tconst report = await generateReport(userId)\n\n\treturn new Response(report, {\n\t\theaders: {\n\t\t\t'Content-Type': 'application/pdf',\n\t\t\t'Content-Disposition': 'attachment; filename=\"report.pdf\"',\n\t\t},\n\t})\n}\n```\n\n### Example 4: Route with multiple nested parameters\n\n```typescript\n// app/routes/users/$username/posts/$postId/comments/$commentId.tsx\nexport async function loader({ params }: Route.LoaderArgs) {\n\t// params contains: { username, postId, commentId }\n\tconst comment = await prisma.comment.findUnique({\n\t\twhere: { id: params.commentId },\n\t\tinclude: {\n\t\t\tpost: {\n\t\t\t\tinclude: { author: true },\n\t\t\t},\n\t\t},\n\t})\n\n\treturn { comment }\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Over-engineering route structure**: Keep routes simple - don't create\n  complex nested structures unless you actually need them\n- ❌ **Creating abstractions prematurely**: Start with simple routes, add\n  complexity only when there's a clear benefit\n- ❌ **Using React Router's standard convention**: Epic Stack uses\n  `react-router-auto-routes`, not the standard convention\n- ❌ **Exporting default component in resource routes**: Resource routes should\n  not export components\n- ❌ **Not using nested layouts when needed**: Use `_layout.tsx` when you have\n  shared UI, but don't create layouts unnecessarily\n- ❌ **Forgetting `<Outlet />` in layouts**: Without `<Outlet />`, child routes\n  won't render\n- ❌ **Using incorrect names for parameters**: Should be `$param.tsx`, not\n  `:param.tsx` or `[param].tsx`\n- ❌ **Mixing route groups with URLs**: Groups (`_auth/`) don't appear in the\n  URL\n- ❌ **Not validating params**: Always validate that parameters exist before\n  using them\n- ❌ **Duplicating route logic**: Use layouts and shared components, but only\n  when it reduces duplication\n\n## References\n\n- [Epic Stack Routing Docs](../epic-stack/docs/routing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [React Router Auto Routes](https://github.com/kenn/react-router-auto-routes)\n- `app/routes.ts` - Auto-routes configuration\n- `app/routes/users/$username/notes/_layout.tsx` - Example of nested layout\n- `app/routes/resources/healthcheck.tsx` - Example of resource route\n- `app/routes/_auth/login.tsx` - Example of route in route group\n\n<!-- File: docs/skills/epic-security/SKILL.md (Tokens: ~3882 | Category: anthropic-skill) -->\n---\nname: epic-security\ndescription:\n  Guide on security practices including CSP, rate limiting, and session security\n  for Epic Stack\ncategories:\n  - security\n  - csp\n  - rate-limiting\n  - headers\n---\n\n# Epic Stack: Security\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Configure Content Security Policy (CSP)\n- Implement spam protection (honeypot)\n- Configure rate limiting\n- Manage session security\n- Implement input validation\n- Configure secure headers\n- Manage secrets\n\n## Patterns and conventions\n\n### Security Philosophy\n\nFollowing Epic Web principles:\n\n**Design to fail fast and early** - Validate security constraints as early as\npossible. Check authentication, authorization, and input validation before\nprocessing requests. Fail immediately with clear error messages rather than\nallowing potentially malicious data to flow through the system.\n\n**Optimize for the debugging experience** - When security checks fail, provide\nclear, actionable error messages that help developers understand what went\nwrong. Log security events with enough context to debug issues without exposing\nsensitive information.\n\n**Example - Fail fast validation:**\n\n```typescript\n// ✅ Good - Validate security constraints early\nexport async function action({ request }: Route.ActionArgs) {\n\t// 1. Authenticate immediately - fail fast if not authenticated\n\tconst userId = await requireUserId(request)\n\n\t// 2. Validate input early - fail fast if invalid\n\tconst formData = await request.formData()\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: NoteSchema,\n\t})\n\n\tif (submission.status !== 'success') {\n\t\treturn data({ result: submission.reply() }, { status: 400 })\n\t}\n\n\t// 3. Check permissions early - fail fast if unauthorized\n\tawait requireUserWithPermission(request, 'create:note:own')\n\n\t// Only proceed if all security checks pass\n\tconst { title, content } = submission.value\n\t// ... create note\n}\n\n// ❌ Avoid - Security checks scattered or delayed\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\t// ... process data first\n\n\t// Security check at the end - too late!\n\tconst userId = await getUserId(request)\n\tif (!userId) {\n\t\t// Already processed potentially malicious data\n\t\treturn json({ error: 'Unauthorized' }, { status: 401 })\n\t}\n}\n```\n\n**Example - Debugging-friendly error messages:**\n\n```typescript\n// ✅ Good - Clear error messages for debugging\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log with context for debugging\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tuserAgent: formData.get('user-agent'),\n\t\t\t\t// Don't log sensitive data\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n\n// ❌ Avoid - Generic or unhelpful errors\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\t// No context, hard to debug\n\t\tthrow new Response('Error', { status: 400 })\n\t}\n}\n```\n\n### Content Security Policy (CSP)\n\nEpic Stack uses CSP to prevent XSS and other attacks.\n\n**Configuration in `server/index.ts`:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Note:** By default, CSP is in `report-only` mode to avoid blocking resources\nduring development. In production, remove `reportOnly: true` to enable it fully.\n\n### Honeypot Fields\n\nEpic Stack uses honeypot fields to protect against spam bots.\n\n**En formularios públicos:**\n\n```typescript\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\n\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<HoneypotInputs /> {/* Always include in public forms */}\n\t{/* Resto de campos */}\n</Form>\n```\n\n**En el action (fail fast):**\n\n```typescript\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\t// Check honeypot first - fail fast if spam detected\n\tawait checkHoneypot(formData) // Lanza error si es spam\n\n\t// Only proceed if honeypot check passes\n\t// ... resto del código\n}\n```\n\n**Configuration:**\n\n```typescript\n// app/utils/honeypot.server.ts\nimport { Honeypot, SpamError } from 'remix-utils/honeypot/server'\n\nexport const honeypot = new Honeypot({\n\tvalidFromFieldName: process.env.NODE_ENV === 'test' ? null : undefined,\n\tencryptionSeed: process.env.HONEYPOT_SECRET,\n})\n\nexport async function checkHoneypot(formData: FormData) {\n\ttry {\n\t\tawait honeypot.check(formData)\n\t} catch (error) {\n\t\tif (error instanceof SpamError) {\n\t\t\t// Log for debugging (without sensitive data)\n\t\t\tconsole.error('Honeypot triggered', {\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t})\n\t\t\tthrow new Response('Form not submitted properly', { status: 400 })\n\t\t}\n\t\tthrow error\n\t}\n}\n```\n\n### Rate Limiting\n\nEpic Stack uses `express-rate-limit` para prevenir abuso.\n\n**Basic configuration:**\n\n```typescript\n// server/index.ts\nimport rateLimit from 'express-rate-limit'\n\nconst rateLimitDefault = {\n\twindowMs: 60 * 1000, // 1 minute\n\tlimit: 1000, // 1000 requests per minute\n\tstandardHeaders: true,\n\tlegacyHeaders: false,\n\tvalidate: { trustProxy: false },\n\tkeyGenerator: (req: express.Request) => {\n\t\treturn req.get('fly-client-ip') ?? `${req.ip}`\n\t},\n}\n\nconst generalRateLimit = rateLimit(rateLimitDefault)\n```\n\n**Different levels of rate limiting:**\n\n```typescript\n// Stricter rate limit for sensitive routes\nconst strongestRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 10, // Only 10 requests per minute\n})\n\n// Strong rate limit for important actions\nconst strongRateLimit = rateLimit({\n\t...rateLimitDefault,\n\tlimit: 100, // 100 requests per minute\n})\n```\n\n**Apply to specific routes:**\n\n```typescript\napp.use((req, res, next) => {\n\tconst strongPaths = [\n\t\t'/login',\n\t\t'/signup',\n\t\t'/verify',\n\t\t'/admin',\n\t\t'/reset-password',\n\t]\n\n\tif (req.method !== 'GET' && req.method !== 'HEAD') {\n\t\tif (strongPaths.some((p) => req.path.includes(p))) {\n\t\t\treturn strongestRateLimit(req, res, next)\n\t\t}\n\t\treturn strongRateLimit(req, res, next)\n\t}\n\n\treturn generalRateLimit(req, res, next)\n})\n```\n\n**Note:** In tests and development, rate limiting is effectively disabled to\nallow fast tests.\n\n### Session Security\n\n**Secure session configuration:**\n\n```typescript\n// app/utils/session.server.ts\nexport const authSessionStorage = createCookieSessionStorage({\n\tcookie: {\n\t\tname: 'en_session',\n\t\tsameSite: 'lax', // CSRF protection advised if changing to 'none'\n\t\tpath: '/',\n\t\thttpOnly: true, // Prevents access from JavaScript\n\t\tsecrets: process.env.SESSION_SECRET.split(','), // Secret rotation\n\t\tsecure: process.env.NODE_ENV === 'production', // HTTPS only in production\n\t},\n})\n```\n\n**Security features:**\n\n- `httpOnly: true` - Prevents access from JavaScript (XSS protection)\n- `secure: true` - Only sends cookies over HTTPS in production\n- `sameSite: 'lax'` - CSRF protection\n- Secret rotation using array\n\n### Password Security\n\n**Hashing de passwords:**\n\n```typescript\nimport bcrypt from 'bcryptjs'\n\nexport async function getPasswordHash(password: string) {\n\tconst hash = await bcrypt.hash(password, 10) // 10 rounds\n\treturn hash\n}\n\nexport async function verifyUserPassword(\n\twhere: Pick<User, 'username'> | Pick<User, 'id'>,\n\tpassword: string,\n) {\n\tconst userWithPassword = await prisma.user.findUnique({\n\t\twhere,\n\t\tselect: { id: true, password: { select: { hash: true } } },\n\t})\n\n\tif (!userWithPassword || !userWithPassword.password) {\n\t\treturn null\n\t}\n\n\tconst isValid = await bcrypt.compare(password, userWithPassword.password.hash)\n\treturn isValid ? { id: userWithPassword.id } : null\n}\n```\n\n**Check common passwords (Have I Been Pwned):**\n\n```typescript\nimport { checkIsCommonPassword } from '#app/utils/auth.server.ts'\n\nconst isCommonPassword = await checkIsCommonPassword(password)\nif (isCommonPassword) {\n\tctx.addIssue({\n\t\tpath: ['password'],\n\t\tcode: 'custom',\n\t\tmessage: 'Password is too common',\n\t})\n}\n```\n\n### Input Validation y Sanitization\n\n**Always validate inputs with Zod:**\n\n```typescript\nimport { z } from 'zod'\n\nconst UserSchema = z.object({\n\temail: z\n\t\t.string()\n\t\t.email()\n\t\t.min(3)\n\t\t.max(100)\n\t\t.transform((val) => val.toLowerCase()),\n\tusername: z\n\t\t.string()\n\t\t.min(3)\n\t\t.max(20)\n\t\t.regex(/^[a-zA-Z0-9_]+$/),\n\tpassword: z.string().min(6).max(72), // bcrypt limit\n})\n\n// Validate before using\nconst result = UserSchema.safeParse(data)\nif (!result.success) {\n\treturn json({ errors: result.error.flatten() }, { status: 400 })\n}\n```\n\n**Sanitization:**\n\n- Use `.transform()` from Zod to sanitize data\n- Normalize emails to lowercase\n- Normalize usernames to lowercase\n- Clean whitespace\n\n### XSS Prevention\n\nReact prevents XSS automatically by escaping all values.\n\n**Never use `dangerouslySetInnerHTML` with user data:**\n\n```typescript\n// ❌ NEVER do this with user data\n<div dangerouslySetInnerHTML={{ __html: userContent }} />\n\n// ✅ React escapa automáticamente\n<div>{userContent}</div>\n```\n\n### Secure Headers\n\nEpic Stack uses Helmet for secure headers.\n\n**Configuration:**\n\n```typescript\nimport { helmet } from '@nichtsam/helmet/node-http'\n\napp.use((_, res, next) => {\n\thelmet(res, { general: { referrerPolicy: false } })\n\tnext()\n})\n```\n\n**Included headers:**\n\n- X-Content-Type-Options: nosniff\n- X-Frame-Options: DENY\n- X-XSS-Protection: 1; mode=block\n- Referrer-Policy (configurable)\n\n### HTTPS Only\n\n**Redirect HTTP to HTTPS:**\n\n```typescript\napp.use((req, res, next) => {\n\tif (req.method !== 'GET') return next()\n\tconst proto = req.get('X-Forwarded-Proto')\n\tconst host = getHost(req)\n\tif (proto === 'http') {\n\t\tres.set('X-Forwarded-Proto', 'https')\n\t\tres.redirect(`https://${host}${req.originalUrl}`)\n\t\treturn\n\t}\n\tnext()\n})\n```\n\n### Secrets Management\n\n**Variables de entorno:**\n\n```bash\n# .env\nSESSION_SECRET=secret1,secret2,secret3 # Secret rotation\nHONEYPOT_SECRET=your-honeypot-secret\nDATABASE_URL=file:./data/db.sqlite\n```\n\n**En Fly.io:**\n\n```bash\nfly secrets set SESSION_SECRET=\"secret1,secret2,secret3\"\nfly secrets set HONEYPOT_SECRET=\"your-secret\"\n```\n\n**Never commit secrets:**\n\n- Use `.env.example` to document required variables\n- `.env` is in `.gitignore`\n- Use `fly secrets` for production\n\n### Validación de Session Expiration (Fail Fast)\n\n**Always verify expiration early:**\n\n```typescript\nexport async function getUserId(request: Request) {\n\tconst authSession = await authSessionStorage.getSession(\n\t\trequest.headers.get('cookie'),\n\t)\n\tconst sessionId = authSession.get(sessionKey)\n\n\t// Fail fast - return null immediately if no session\n\tif (!sessionId) return null\n\n\t// Verify expiration early - fail fast if expired\n\tconst session = await prisma.session.findUnique({\n\t\tselect: { userId: true },\n\t\twhere: {\n\t\t\tid: sessionId,\n\t\t\texpirationDate: { gt: new Date() }, // Verify expiration\n\t\t},\n\t})\n\n\t// Fail fast - destroy invalid session immediately\n\tif (!session?.userId) {\n\t\tthrow redirect('/', {\n\t\t\theaders: {\n\t\t\t\t'set-cookie': await authSessionStorage.destroySession(authSession),\n\t\t\t},\n\t\t})\n\t}\n\treturn session.userId\n}\n```\n\n## Common examples\n\n### Example 1: Public form with honeypot\n\n```typescript\n// app/routes/_auth/signup.tsx\nimport { HoneypotInputs } from 'remix-utils/honeypot/react'\nimport { checkHoneypot } from '#app/utils/honeypot.server.ts'\n\nexport async function action({ request }: Route.ActionArgs) {\n\tconst formData = await request.formData()\n\n\tawait checkHoneypot(formData) // Spam protection\n\n\tconst submission = await parseWithZod(formData, {\n\t\tschema: SignupSchema,\n\t})\n\n\t// ... rest of code\n}\n\nexport default function SignupRoute({ actionData }: Route.ComponentProps) {\n\treturn (\n\t\t<Form method=\"POST\" {...getFormProps(form)}>\n\t\t\t<HoneypotInputs /> {/* Include in form */}\n\t\t\t{/* Rest of fields */}\n\t\t</Form>\n\t)\n}\n```\n\n### Example 2: Custom rate limiting\n\n```typescript\n// server/index.ts\nconst apiRateLimit = rateLimit({\n\t...rateLimitDefault,\n\twindowMs: 60 * 1000,\n\tlimit: 100, // 100 requests per minute for API\n\tkeyGenerator: (req) => {\n\t\tconst apiKey = req.get('X-API-Key')\n\t\treturn apiKey ?? req.get('fly-client-ip') ?? req.ip\n\t},\n})\n\napp.use('/api', apiRateLimit)\n```\n\n### Example 3: Strict input validation\n\n```typescript\n// app/utils/user-validation.ts\nimport { z } from 'zod'\n\nexport const EmailSchema = z\n\t.string({ required_error: 'Email is required' })\n\t.email({ message: 'Email is invalid' })\n\t.min(3, { message: 'Email is too short' })\n\t.max(100, { message: 'Email is too long' })\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const UsernameSchema = z\n\t.string({ required_error: 'Username is required' })\n\t.min(3, { message: 'Username is too short' })\n\t.max(20, { message: 'Username is too long' })\n\t.regex(/^[a-zA-Z0-9_]+$/, {\n\t\tmessage: 'Username can only include letters, numbers, and underscores',\n\t})\n\t.transform((value) => value.toLowerCase().trim()) // Sanitization\n\nexport const PasswordSchema = z\n\t.string({ required_error: 'Password is required' })\n\t.min(6, { message: 'Password is too short' })\n\t.refine((val) => new TextEncoder().encode(val).length <= 72, {\n\t\tmessage: 'Password is too long', // bcrypt limit\n\t})\n```\n\n### Example 4: Permission verification before actions\n\n```typescript\nexport async function action({ request }: Route.ActionArgs) {\n\tconst userId = await requireUserId(request)\n\n\t// Validate that user has permission\n\tawait requireUserWithPermission(request, 'delete:note:own')\n\n\t// Only after validating permissions\n\tawait prisma.note.delete({ where: { id: noteId } })\n\n\treturn redirect('/notes')\n}\n```\n\n## Common mistakes to avoid\n\n- ❌ **Delayed security checks**: Validate authentication, authorization, and\n  input as early as possible - fail fast\n- ❌ **Generic error messages**: Provide clear, actionable error messages that\n  help with debugging (without exposing sensitive data)\n- ❌ **Forgetting honeypot in public forms**: Always include `HoneypotInputs` in\n  forms accessible without authentication\n- ❌ **Not validating session expiration**: Always verify `expirationDate` when\n  getting sessions - check early\n- ❌ **Using `dangerouslySetInnerHTML` with user data**: Never render user HTML\n  without sanitizing\n- ❌ **Not using rate limiting**: Protect sensitive routes with rate limiting\n- ❌ **Secrets in code**: Never hardcode secrets, use environment variables\n- ❌ **Not sanitizing inputs**: Always sanitize inputs with `.transform()` from\n  Zod\n- ❌ **Not validating common passwords**: Check passwords against Have I Been\n  Pwned\n- ❌ **Sessions without httpOnly**: Always use `httpOnly: true` in session\n  cookies\n- ❌ **Not using HTTPS in production**: Make sure to redirect HTTP to HTTPS\n- ❌ **CSP too permissive**: Review and adjust CSP according to your needs\n- ❌ **Not logging security events**: Log security failures with context for\n  debugging (without sensitive data)\n\n## References\n\n- [Epic Stack Security Docs](../epic-stack/docs/security.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [OWASP Top 10](https://owasp.org/www-project-top-ten/)\n- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)\n- `app/utils/honeypot.server.ts` - Honeypot implementation\n- `server/index.ts` - Server security configuration\n- `docs/decisions/033-honeypot.md` - Honeypot documentation\n- `docs/decisions/025-rate-limiting.md` - Rate limiting documentation\n- `docs/decisions/008-content-security-policy.md` - CSP documentation\n\n<!-- File: docs/skills/epic-testing/SKILL.md (Tokens: ~4167 | Category: anthropic-skill) -->\n---\nname: epic-testing\ndescription: Guide on testing with Vitest and Playwright for Epic Stack\ncategories:\n  - testing\n  - vitest\n  - playwright\n  - e2e\n---\n\n# Epic Stack: Testing\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Write unit tests for utilities and components\n- Create E2E tests with Playwright\n- Test forms and validation\n- Test routes and loaders\n- Mock external services with MSW\n- Test authentication and permissions\n- Configure test database\n\n## Patterns and conventions\n\n### Testing Philosophy\n\nFollowing Epic Web principles:\n\n**Tests should resemble users** - Write tests that mirror how real users\ninteract with your application. Test user workflows, not implementation details.\nIf a user would click a button, your test should click that button. If a user\nwould see an error message, your test should check for that specific message.\n\n**Make assertions specific** - Be explicit about what you're testing. Instead of\nvague assertions, use specific, meaningful checks that clearly communicate the\nexpected behavior. This makes tests easier to understand and debug when they\nfail.\n\n**Example - Tests that resemble users:**\n\n```typescript\n// ✅ Good - Tests user workflow\ntest('User can sign up and create their first note', async ({ page, navigate }) => {\n\t// User visits signup page\n\tawait navigate('/signup')\n\n\t// User fills out form like a real person would\n\tawait page.getByRole('textbox', { name: /email/i }).fill('newuser@example.com')\n\tawait page.getByRole('textbox', { name: /username/i }).fill('newuser')\n\tawait page.getByRole('textbox', { name: /^password$/i }).fill('securepassword123')\n\tawait page.getByRole('textbox', { name: /confirm/i }).fill('securepassword123')\n\n\t// User submits form\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// User is redirected to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n\n\t// User creates their first note\n\tawait navigate('/notes/new')\n\tawait page.getByRole('textbox', { name: /title/i }).fill('My First Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('This is my first note!')\n\tawait page.getByRole('button', { name: /create/i }).click()\n\n\t// User sees their note\n\tawait expect(page.getByRole('heading', { name: 'My First Note' })).toBeVisible()\n\tawait expect(page.getByText('This is my first note!')).toBeVisible()\n})\n\n// ❌ Avoid - Testing implementation details\ntest('Signup form calls API endpoint', async ({ page }) => {\n\t// This tests implementation, not user experience\n\tconst response = await page.request.post('/signup', { data: {...} })\n\texpect(response.status()).toBe(200)\n})\n```\n\n**Example - Specific assertions:**\n\n```typescript\n// ✅ Good - Specific assertions\ntest('Form shows specific validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Specific error messages that users would see\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/username must be at least 3 characters/i),\n\t).toBeVisible()\n\tawait expect(\n\t\tpage.getByText(/password must be at least 6 characters/i),\n\t).toBeVisible()\n})\n\n// ❌ Avoid - Vague assertions\ntest('Form shows errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\tawait page.getByRole('button', { name: /sign up/i }).click()\n\n\t// Too vague - what errors? where?\n\texpect(page.locator('.error')).toBeVisible()\n})\n```\n\n### Two Types of Tests\n\nEpic Stack uses two types of tests:\n\n1. **Unit Tests with Vitest** - Tests for individual components and utilities\n2. **E2E Tests with Playwright** - End-to-end tests of the complete flow\n\n### Unit Tests with Vitest\n\n**Basic setup:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { myUtil } from './my-util.ts'\n\ndescribe('myUtil', () => {\n\tit('should do something', () => {\n\t\texpect(myUtil('input')).toBe('expected')\n\t})\n})\n```\n\n**Testing con DOM:**\n\n```typescript\nimport { describe, expect, it } from 'vitest'\nimport { render, screen } from '@testing-library/react'\nimport { MyComponent } from './my-component.tsx'\n\ndescribe('MyComponent', () => {\n\tit('should render correctly', () => {\n\t\trender(<MyComponent />)\n\t\texpect(screen.getByText('Hello')).toBeInTheDocument()\n\t})\n})\n```\n\n### E2E Tests with Playwright\n\n**Basic setup:**\n\n```typescript\n// tests/e2e/my-feature.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\n\ntest('Users can do something', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/my-page')\n\n\t// Interact with the page\n\tawait page.getByRole('button', { name: /Submit/i }).click()\n\n\t// Verificar resultado\n\tawait expect(page).toHaveURL('/success')\n})\n```\n\n### Login Fixture\n\nEpic Stack provides a `login` fixture for authenticated tests.\n\n**Use login fixture:**\n\n```typescript\ntest('Protected route', async ({ page, navigate, login }) => {\n\tconst user = await login() // Creates user and session automatically\n\tawait navigate('/protected')\n\n\t// User is authenticated\n\tawait expect(page.getByText(`Welcome ${user.username}`)).toBeVisible()\n})\n```\n\n**Login with options:**\n\n```typescript\nconst user = await login({\n\tusername: 'testuser',\n\temail: 'test@example.com',\n\tpassword: 'password123',\n})\n```\n\n**Note:** The user is automatically deleted when the test completes.\n\n### Insert User without Login\n\nTo create user without authentication:\n\n```typescript\ntest('Public content', async ({ page, navigate, insertNewUser }) => {\n\tconst user = await insertNewUser({\n\t\tusername: 'publicuser',\n\t\temail: 'public@example.com',\n\t})\n\n\tawait navigate(`/users/${user.username}`)\n\tawait expect(page.getByText(user.username)).toBeVisible()\n})\n```\n\n### Navigate Helper\n\nUse the `navigate` helper to navigate with type-safety:\n\n```typescript\n// Type-safe navigation\nawait navigate('/users/:username/notes', { username: user.username })\nawait navigate('/users/:username/notes/:noteId', {\n\tusername: user.username,\n\tnoteId: note.id,\n})\n\n// Also works with routes without parameters\nawait navigate('/login')\n```\n\n### Test Database\n\nEpic Stack uses a separate test database.\n\n**Automatic configuration:**\n\n- The test database is configured automatically\n- It's cleaned between tests\n- Data created in tests is automatically deleted\n\n**Create data in tests:**\n\n```typescript\nimport { prisma } from '#app/utils/db.server.ts'\n\ntest('User can see notes', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\n\t// Create note in database\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test Content',\n\t\t\townerId: user.id,\n\t\t},\n\t})\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: user.username,\n\t\tnoteId: note.id,\n\t})\n\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### MSW (Mock Service Worker)\n\nEpic Stack uses MSW to mock external services.\n\n**Mock example:**\n\n```typescript\n// tests/mocks/github.ts\nimport { http, HttpResponse } from 'msw'\n\nexport const handlers = [\n\thttp.get('https://api.github.com/user', () => {\n\t\treturn HttpResponse.json({\n\t\t\tid: '123',\n\t\t\tlogin: 'testuser',\n\t\t\temail: 'test@example.com',\n\t\t})\n\t}),\n]\n```\n\n**Use in tests:** Mocks are automatically applied when `MOCKS=true` is\nconfigured.\n\n### Testing Forms\n\n**Test form:**\n\n```typescript\ntest('User can submit form', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/notes/new')\n\n\t// Fill form\n\tawait page.getByRole('textbox', { name: /title/i }).fill('New Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Note content')\n\n\t// Submit\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar redirect\n\tawait expect(page).toHaveURL(new RegExp('/users/.*/notes/.*'))\n})\n```\n\n**Test validation:**\n\n```typescript\ntest('Form shows validation errors', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit sin llenar\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verificar errores\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n})\n```\n\n### Testing Loaders\n\n**Test loader:**\n\n```typescript\n// app/utils/my-util.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { loader } from '../routes/my-route.ts'\nimport { prisma } from '../utils/db.server.ts'\n\ndescribe('loader', () => {\n\tit('should load data', async () => {\n\t\t// Create data\n\t\tconst user = await prisma.user.create({\n\t\t\tdata: {\n\t\t\t\temail: 'test@example.com',\n\t\t\t\tusername: 'testuser',\n\t\t\t\troles: { connect: { name: 'user' } },\n\t\t\t},\n\t\t})\n\n\t\t// Mock request\n\t\tconst request = new Request('http://localhost/my-route')\n\n\t\t// Execute loader\n\t\tconst result = await loader({ request, params: {}, context: {} })\n\n\t\t// Verify result\n\t\texpect(result.data).toBeDefined()\n\t})\n})\n```\n\n### Testing Actions\n\n**Test action:**\n\n```typescript\n// tests/e2e/notes.test.ts\ntest('User can create note', async ({ page, navigate, login }) => {\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\n\tconst formData = new FormData()\n\tformData.set('title', 'Test Note')\n\tformData.set('content', 'Test Content')\n\n\tawait page.getByRole('textbox', { name: /title/i }).fill('Test Note')\n\tawait page.getByRole('textbox', { name: /content/i }).fill('Test Content')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify that note was created\n\tawait expect(page.getByText('Test Note')).toBeVisible()\n})\n```\n\n### Testing Permissions\n\n**Test permissions:**\n\n```typescript\ntest('Only owner can delete note', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\tconst owner = await login()\n\tconst otherUser = await insertNewUser()\n\n\tconst note = await prisma.note.create({\n\t\tdata: {\n\t\t\ttitle: 'Test Note',\n\t\t\tcontent: 'Test',\n\t\t\townerId: owner.id,\n\t\t},\n\t})\n\n\t// Login as other user\n\tconst session = await createSession(otherUser.id)\n\tawait page.context().addCookies([getCookie(session)])\n\n\tawait navigate('/users/:username/notes/:noteId', {\n\t\tusername: owner.username,\n\t\tnoteId: note.id,\n\t})\n\n\t// Verify that can't delete\n\tawait expect(page.getByRole('button', { name: /delete/i })).not.toBeVisible()\n})\n```\n\n### DB Helpers\n\n**Create user:**\n\n```typescript\nimport { createUser } from '#tests/db-utils.ts'\n\nconst userData = createUser() // Generates unique random data\n```\n\n**Create password:**\n\n```typescript\nimport { createPassword } from '#tests/db-utils.ts'\n\nconst password = createPassword('mypassword') // { hash: '...' }\n```\n\n### Wait For Helper\n\nTo wait for async conditions:\n\n```typescript\nimport { waitFor } from '#tests/playwright-utils.ts'\n\nawait waitFor(\n\tasync () => {\n\t\tconst element = await page.getByText('Content loaded').first()\n\t\texpect(element).toBeVisible()\n\t\treturn element\n\t},\n\t{ timeout: 5000, errorMessage: 'Content never loaded' },\n)\n```\n\n### Testing GitHub OAuth\n\n**Prepare GitHub user:**\n\n```typescript\ntest('User can login with GitHub', async ({\n\tpage,\n\tnavigate,\n\tprepareGitHubUser,\n}) => {\n\tconst ghUser = await prepareGitHubUser()\n\n\tawait navigate('/login')\n\tawait page.getByRole('link', { name: /github/i }).click()\n\n\t// GitHub user is automatically prepared\n\tawait expect(page).toHaveURL('/onboarding/github')\n})\n```\n\n## Common examples\n\n### Example 1: Complete E2E test (resembling user workflow)\n\n```typescript\n// tests/e2e/notes.test.ts\nimport { expect, test } from '#tests/playwright-utils.ts'\nimport { prisma } from '#app/utils/db.server.ts'\nimport { faker } from '@faker-js/faker'\n\ntest('Users can create, edit, and delete notes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n}) => {\n\t// User logs in (realistic workflow)\n\tconst user = await login()\n\tawait navigate('/users/:username/notes', { username: user.username })\n\n\t// User creates a new note (clicking link, filling form, submitting)\n\tawait page.getByRole('link', { name: /new note/i }).click()\n\tconst newNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(newNote.title)\n\tawait page.getByRole('textbox', { name: /content/i }).fill(newNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees their note with correct title and content\n\tawait expect(page.getByRole('heading', { name: newNote.title })).toBeVisible()\n\tawait expect(page.getByText(newNote.content)).toBeVisible()\n\tconst noteUrl = page.url()\n\tconst noteId = noteUrl.split('/').pop()\n\n\t// User edits the note (clicking edit, updating fields, saving)\n\tawait page.getByRole('link', { name: /edit/i }).click()\n\tconst updatedNote = {\n\t\ttitle: faker.lorem.words(3),\n\t\tcontent: faker.lorem.paragraphs(2),\n\t}\n\tawait page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title)\n\tawait page\n\t\t.getByRole('textbox', { name: /content/i })\n\t\t.fill(updatedNote.content)\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Specific assertions: user sees updated content\n\tawait expect(\n\t\tpage.getByRole('heading', { name: updatedNote.title }),\n\t).toBeVisible()\n\tawait expect(page.getByText(updatedNote.content)).toBeVisible()\n\n\t// User deletes the note (clicking delete button)\n\tawait page.getByRole('button', { name: /delete/i }).click()\n\n\t// Specific assertion: user is redirected back to notes list\n\tawait expect(page).toHaveURL(`/users/${user.username}/notes`)\n\tawait expect(page.getByText(updatedNote.title)).not.toBeVisible()\n})\n```\n\n### Example 2: Unit test for utility\n\n```typescript\n// app/utils/misc.test.ts\nimport { describe, expect, it } from 'vitest'\nimport { cn } from './misc.tsx'\n\ndescribe('cn', () => {\n\tit('should merge class names', () => {\n\t\texpect(cn('foo', 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', undefined, 'bar')).toBe('foo bar')\n\t\texpect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')\n\t})\n})\n```\n\n### Example 3: Form validation test\n\n```typescript\n// tests/e2e/signup.test.ts\ntest('Signup form validation', async ({ page, navigate }) => {\n\tawait navigate('/signup')\n\n\t// Submit without filling\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify errors\n\tawait expect(page.getByText(/email is required/i)).toBeVisible()\n\n\t// Fill invalid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('invalid')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify email error\n\tawait expect(page.getByText(/email is invalid/i)).toBeVisible()\n\n\t// Fill valid email\n\tawait page.getByRole('textbox', { name: /email/i }).fill('test@example.com')\n\tawait page.getByRole('button', { name: /submit/i }).click()\n\n\t// Verify redirect to onboarding\n\tawait expect(page).toHaveURL(/\\/onboarding/)\n})\n```\n\n### Example 4: Permissions test\n\n```typescript\n// tests/e2e/permissions.test.ts\ntest('Only admin can access admin routes', async ({\n\tpage,\n\tnavigate,\n\tlogin,\n\tinsertNewUser,\n}) => {\n\t// Test with normal user\n\tconst normalUser = await login()\n\n\tawait navigate('/admin/users')\n\n\t// Should redirect or show error\n\tawait expect(page).toHaveURL('/') // Or verify error message\n\n\t// Test with admin\n\tawait page.context().clearCookies()\n\tconst admin = await insertNewUser()\n\tawait prisma.user.update({\n\t\twhere: { id: admin.id },\n\t\tdata: {\n\t\t\troles: {\n\t\t\t\tconnect: { name: 'admin' },\n\t\t\t},\n\t\t},\n\t})\n\n\t// Login as admin\n\tconst adminSession = await createSession(admin.id)\n\tawait page.context().addCookies([getCookie(adminSession)])\n\n\tawait navigate('/admin/users')\n\n\t// Now should work\n\tawait expect(page.getByText('All Users')).toBeVisible()\n})\n```\n\n## Common mistakes to avoid\n\n- ❌ **Testing implementation details instead of user workflows**: Write tests\n  that mirror how users actually use your app\n- ❌ **Vague assertions**: Use specific, meaningful assertions that clearly\n  communicate expected behavior\n- ❌ **Not cleaning data after tests**: Epic Stack cleans automatically, but\n  make sure not to depend on data between tests\n- ❌ **Assuming execution order**: Tests must be independent\n- ❌ **Not using fixtures**: Use `login`, `insertNewUser`, etc. instead of\n  creating everything manually\n- ❌ **Hardcoding data**: Use `faker` to generate unique data\n- ❌ **Not waiting for elements**: Use `expect` with `toBeVisible()` instead of\n  assuming it exists\n- ❌ **Not using type-safe navigation**: Use `navigate` helper instead of\n  `page.goto()` directly\n- ❌ **Forgetting MSW in tests**: External services are automatically mocked\n  when `MOCKS=true`\n- ❌ **Not testing error cases**: Test both happy path and errors\n- ❌ **Testing internal state instead of user-visible behavior**: Focus on what\n  users see and do\n\n## References\n\n- [Epic Stack Testing Docs](../epic-stack/docs/testing.md)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Vitest Documentation](https://vitest.dev/)\n- [Playwright Documentation](https://playwright.dev/)\n- [Testing Library](https://testing-library.com/)\n- [MSW](https://mswjs.io/)\n- `tests/playwright-utils.ts` - Playwright fixtures and helpers\n- `tests/db-utils.ts` - DB helpers for tests\n- `tests/e2e/` - E2E test examples\n- `app/utils/*.test.ts` - Unit test examples\n\n<!-- File: docs/skills/epic-ui-guidelines/SKILL.md (Tokens: ~4364 | Category: anthropic-skill) -->\n---\nname: epic-ui-guidelines\ndescription:\n  Guide on UI/UX guidelines, accessibility, and component usage for Epic Stack\ncategories:\n  - ui\n  - accessibility\n  - tailwind\n  - radix-ui\n---\n\n# Epic Stack: UI Guidelines\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- Create accessible UI components\n- Follow Epic Stack design patterns\n- Use Tailwind CSS effectively\n- Implement semantic HTML\n- Add ARIA attributes correctly\n- Create responsive layouts\n- Ensure proper form accessibility\n- Follow Epic Stack's UI component conventions\n\n## Patterns and conventions\n\n### UI Philosophy\n\nFollowing Epic Web principles:\n\n**Software is built for people, by people** - Accessibility isn't about checking\nboxes or meeting standards. It's about creating software that works for real\npeople with diverse needs, abilities, and contexts. Every UI decision should\nprioritize the human experience over technical convenience.\n\nAccessibility is not optional - it's how we ensure our software serves all\nusers, not just some. When you make UI accessible, you're making it better for\neveryone: clearer labels help all users, keyboard navigation helps power users,\nand semantic HTML helps search engines.\n\n**Example - Human-centered approach:**\n\n```typescript\n// ✅ Good - Built for people\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<Field\n\t\t\t\tlabelProps={{\n\t\t\t\t\thtmlFor: fields.title.id,\n\t\t\t\t\tchildren: 'Note Title', // Clear, human-readable label\n\t\t\t\t}}\n\t\t\t\tinputProps={{\n\t\t\t\t\t...getInputProps(fields.title),\n\t\t\t\t\tplaceholder: 'Enter a descriptive title', // Helpful guidance\n\t\t\t\t\tautoFocus: true, // Saves time for users\n\t\t\t\t}}\n\t\t\t\terrors={fields.title.errors} // Clear error messages\n\t\t\t/>\n\t\t</Form>\n\t)\n}\n\n// ❌ Avoid - Technical convenience over user experience\nfunction NoteForm() {\n\treturn (\n\t\t<Form method=\"POST\">\n\t\t\t<input name=\"title\" /> {/* No label, no guidance, no accessibility */}\n\t\t</Form>\n\t)\n}\n```\n\n### Semantic HTML\n\n**✅ Good - Use semantic elements:**\n\n```typescript\nfunction UserCard({ user }: { user: User }) {\n\treturn (\n\t\t<article>\n\t\t\t<header>\n\t\t\t\t<h2>{user.name}</h2>\n\t\t\t</header>\n\t\t\t<p>{user.bio}</p>\n\t\t\t<footer>\n\t\t\t\t<time dateTime={user.createdAt}>{formatDate(user.createdAt)}</time>\n\t\t\t</footer>\n\t\t</article>\n\t)\n}\n```\n\n**❌ Avoid - Generic divs:**\n\n```typescript\n// ❌ Don't use divs for everything\n<div>\n\t<div>{user.name}</div>\n\t<div>{user.bio}</div>\n\t<div>{formatDate(user.createdAt)}</div>\n</div>\n```\n\n### Form Accessibility\n\n**✅ Good - Always use labels:**\n\n```typescript\nimport { Field } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{\n\t\thtmlFor: fields.email.id,\n\t\tchildren: 'Email',\n\t}}\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\tautoFocus: true,\n\t\tautoComplete: 'email',\n\t}}\n\terrors={fields.email.errors}\n/>\n```\n\nThe `Field` component automatically:\n\n- Associates labels with inputs using `htmlFor` and `id`\n- Adds `aria-invalid` when there are errors\n- Adds `aria-describedby` pointing to error messages\n- Ensures proper error announcement\n\n**❌ Avoid - Unlabeled inputs:**\n\n```typescript\n// ❌ Don't forget labels\n<input type=\"email\" name=\"email\" />\n```\n\n### ARIA Attributes\n\n**✅ Good - Use ARIA appropriately:**\n\n```typescript\n// Epic Stack's Field component handles this automatically\n<Field\n\tinputProps={{\n\t\t...getInputProps(fields.email, { type: 'email' }),\n\t\t// aria-invalid and aria-describedby are added automatically\n\t}}\n\terrors={fields.email.errors} // Error messages are linked via aria-describedby\n/>\n```\n\n**✅ Good - ARIA for custom components:**\n\n```typescript\nfunction LoadingButton({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) {\n\treturn (\n\t\t<button aria-busy={isLoading} disabled={isLoading}>\n\t\t\t{isLoading ? 'Loading...' : children}\n\t\t</button>\n\t)\n}\n```\n\n### Using Radix UI Components\n\nEpic Stack uses Radix UI for accessible, unstyled components.\n\n**✅ Good - Use Radix primitives:**\n\n```typescript\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button } from '#app/components/ui/button.tsx'\n\nfunction MyDialog() {\n\treturn (\n\t\t<Dialog.Root>\n\t\t\t<Dialog.Trigger asChild>\n\t\t\t\t<Button>Open Dialog</Button>\n\t\t\t</Dialog.Trigger>\n\t\t\t<Dialog.Portal>\n\t\t\t\t<Dialog.Overlay className=\"fixed inset-0 bg-black/50\" />\n\t\t\t\t<Dialog.Content className=\"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6\">\n\t\t\t\t\t<Dialog.Title>Dialog Title</Dialog.Title>\n\t\t\t\t\t<Dialog.Description>Dialog description</Dialog.Description>\n\t\t\t\t\t<Dialog.Close asChild>\n\t\t\t\t\t\t<Button>Close</Button>\n\t\t\t\t\t</Dialog.Close>\n\t\t\t\t</Dialog.Content>\n\t\t\t</Dialog.Portal>\n\t\t</Dialog.Root>\n\t)\n}\n```\n\nRadix components automatically handle:\n\n- Keyboard navigation\n- Focus management\n- ARIA attributes\n- Screen reader announcements\n\n### Tailwind CSS Patterns\n\n**✅ Good - Use Tailwind utility classes:**\n\n```typescript\nfunction Card({ children }: { children: React.ReactNode }) {\n\treturn (\n\t\t<div className=\"rounded-lg border border-gray-200 bg-white p-6 shadow-sm\">\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Use Tailwind responsive utilities:**\n\n```typescript\n<div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">\n\t{items.map(item => (\n\t\t<Card key={item.id}>{item.name}</Card>\n\t))}\n</div>\n```\n\n**✅ Good - Use Tailwind dark mode:**\n\n```typescript\n<div className=\"bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n### Error Handling in Forms\n\n**✅ Good - Display errors accessibly:**\n\n```typescript\nimport { Field, ErrorList } from '#app/components/forms.tsx'\n\n<Field\n\tlabelProps={{ htmlFor: fields.email.id, children: 'Email' }}\n\tinputProps={getInputProps(fields.email, { type: 'email' })}\n\terrors={fields.email.errors} // Errors are displayed below input\n/>\n\n<ErrorList errors={form.errors} id={form.errorId} /> // Form-level errors\n```\n\nErrors are automatically:\n\n- Associated with inputs via `aria-describedby`\n- Announced to screen readers\n- Visually distinct with error styling\n\n### Focus Management\n\n**✅ Good - Visible focus indicators:**\n\n```typescript\n// Tailwind's default focus:ring handles this\n<button className=\"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Focus on form errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus() {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\tfirstErrorRef.current.focus()\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Keyboard Navigation\n\n**✅ Good - Keyboard accessible components:**\n\n```typescript\n// Radix components handle keyboard navigation automatically\n<Dialog.Trigger asChild>\n\t<Button>Open</Button>\n</Dialog.Trigger>\n\n// Custom components should support keyboard\n<button\n\tonKeyDown={(e) => {\n\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\thandleClick()\n\t\t}\n\t}}\n>\n\tCustom Button\n</button>\n```\n\n### Color Contrast\n\n**✅ Good - Use accessible color combinations:**\n\n```typescript\n// Use Tailwind's semantic colors that meet WCAG AA\n<div className=\"bg-white text-gray-900\"> // High contrast\n<div className=\"text-blue-600 hover:text-blue-700\"> // Accessible links\n```\n\n**❌ Avoid - Low contrast text:**\n\n```typescript\n// ❌ Don't use low contrast\n<div className=\"bg-gray-100 text-gray-200\"> // Very low contrast\n```\n\n### Responsive Design\n\n**✅ Good - Mobile-first approach:**\n\n```typescript\n<div className=\"\n\tflex flex-col gap-4\n\tmd:flex-row md:gap-8\n\tlg:gap-12\n\">\n\t{/* Content */}\n</div>\n```\n\n**✅ Good - Responsive typography:**\n\n```typescript\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">\n\tResponsive Heading\n</h1>\n```\n\n### Loading States\n\n**✅ Good - Accessible loading indicators:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SubmitButton() {\n\tconst navigation = useNavigation()\n\tconst isSubmitting = navigation.state === 'submitting'\n\n\treturn (\n\t\t<button\n\t\t\ttype=\"submit\"\n\t\t\tdisabled={isSubmitting}\n\t\t\taria-busy={isSubmitting}\n\t\t>\n\t\t\t{isSubmitting ? 'Saving...' : 'Save'}\n\t\t</button>\n\t)\n}\n```\n\n### Icon Usage\n\n**✅ Good - Decorative icons:**\n\n```typescript\nimport { Icon } from '#app/components/ui/icon.tsx'\n\n<button aria-label=\"Delete note\">\n\t<Icon name=\"trash\" />\n\t<span className=\"sr-only\">Delete note</span>\n</button>\n```\n\n**✅ Good - Semantic icons:**\n\n```typescript\n<button>\n\t<Icon name=\"check\" aria-hidden=\"true\" />\n\tSave\n</button>\n```\n\n### Skip Links\n\n**✅ Good - Add skip to main content:**\n\n```typescript\n// In your root layout\n<a href=\"#main-content\" className=\"sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white\">\n\tSkip to main content\n</a>\n\n<main id=\"main-content\">\n\t{/* Main content */}\n</main>\n```\n\n### Progressive Enhancement\n\n**✅ Good - Forms work without JavaScript:**\n\n```typescript\n// Conform forms work without JavaScript\n<Form method=\"POST\" {...getFormProps(form)}>\n\t<Field {...props} />\n\t<StatusButton type=\"submit\">Submit</StatusButton>\n</Form>\n```\n\nForms automatically:\n\n- Submit via native HTML forms if JavaScript is disabled\n- Validate server-side\n- Show errors appropriately\n\n### Screen Reader Best Practices\n\n**✅ Good - Use semantic HTML first:**\n\n```typescript\n// ✅ Semantic HTML provides context automatically\n<nav aria-label=\"Main navigation\">\n\t<ul>\n\t\t<li><a href=\"/\">Home</a></li>\n\t\t<li><a href=\"/about\">About</a></li>\n\t</ul>\n</nav>\n```\n\n**✅ Good - Announce dynamic content:**\n\n```typescript\nimport { useNavigation } from 'react-router'\n\nfunction SearchResults({ results }: { results: Result[] }) {\n\tconst navigation = useNavigation()\n\tconst isSearching = navigation.state === 'loading'\n\n\treturn (\n\t\t<div\n\t\t\trole=\"status\"\n\t\t\taria-live=\"polite\"\n\t\t\taria-atomic=\"true\"\n\t\t\tclassName=\"sr-only\"\n\t\t>\n\t\t\t{isSearching ? 'Searching...' : `${results.length} results found`}\n\t\t</div>\n\t)\n}\n```\n\n**✅ Good - Live regions for important updates:**\n\n```typescript\nfunction ToastContainer({ toasts }: { toasts: Toast[] }) {\n\treturn (\n\t\t<div aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n\t\t\t{toasts.map(toast => (\n\t\t\t\t<div key={toast.id} role=\"alert\">\n\t\t\t\t\t{toast.message}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n```\n\n**ARIA live region options:**\n\n- `aria-live=\"polite\"` - For non-critical updates (search results, status\n  messages)\n- `aria-live=\"assertive\"` - For critical updates (errors, confirmations)\n- `aria-atomic=\"true\"` - Screen reader reads entire region on update\n- `aria-atomic=\"false\"` - Screen reader reads only changed parts\n\n### Keyboard Navigation Patterns\n\n**✅ Good - Tab order follows visual order:**\n\n```typescript\n// Elements appear in logical tab order\n<nav>\n\t<a href=\"/\">Home</a>\n\t<a href=\"/about\">About</a>\n\t<a href=\"/contact\">Contact</a>\n</nav>\n```\n\n**✅ Good - Keyboard shortcuts:**\n\n```typescript\nimport { useEffect } from 'react'\n\nfunction SearchDialog({ onClose }: { onClose: () => void }) {\n\tuseEffect(() => {\n\t\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\t\tif (e.key === 'Escape') {\n\t\t\t\tonClose()\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [onClose])\n\n\treturn <Dialog>{/* content */}</Dialog>\n}\n```\n\n**✅ Good - Focus trap in modals:**\n\n```typescript\n// Radix Dialog automatically handles focus trap\n<Dialog.Root>\n\t<Dialog.Content>\n\t\t{/* Focus is trapped inside dialog */}\n\t\t<Dialog.Close>Close</Dialog.Close>\n\t</Dialog.Content>\n</Dialog.Root>\n```\n\n### Focus Management for React Router\n\n**✅ Good - Focus on route changes:**\n\n```typescript\nimport { useEffect } from 'react'\nimport { useNavigation } from 'react-router'\n\nfunction RouteComponent() {\n\tconst navigation = useNavigation()\n\tconst mainRef = useRef<HTMLElement>(null)\n\n\tuseEffect(() => {\n\t\tif (navigation.state === 'idle' && mainRef.current) {\n\t\t\tmainRef.current.focus()\n\t\t}\n\t}, [navigation.state])\n\n\treturn (\n\t\t<main ref={mainRef} tabIndex={-1}>\n\t\t\t{/* Content */}\n\t\t</main>\n\t)\n}\n```\n\n**✅ Good - Focus on errors:**\n\n```typescript\nimport { useEffect, useRef } from 'react'\n\nfunction FormWithErrorFocus({ actionData }: Route.ComponentProps) {\n\tconst firstErrorRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (actionData?.errors && firstErrorRef.current) {\n\t\t\t// Focus first error field\n\t\t\tfirstErrorRef.current.focus()\n\t\t\t// Announce error\n\t\t\tfirstErrorRef.current.setAttribute('aria-invalid', 'true')\n\t\t}\n\t}, [actionData?.errors])\n\n\treturn <Field inputProps={{ ref: firstErrorRef, ... }} />\n}\n```\n\n### Typography and Readability\n\n**✅ Good - Readable text sizes:**\n\n```typescript\n// Use Tailwind's text size scale\n<p className=\"text-base md:text-lg\">Readable body text</p>\n<h1 className=\"text-2xl md:text-3xl lg:text-4xl\">Clear headings</h1>\n```\n\n**✅ Good - Sufficient line height:**\n\n```typescript\n// Tailwind defaults provide good line height\n<p className=\"leading-relaxed\">Comfortable reading</p>\n```\n\n**❌ Avoid - Small or hard-to-read text:**\n\n```typescript\n// ❌ Don't use very small text\n<p className=\"text-xs\">Hard to read</p>\n```\n\n### Touch Target Sizes\n\n**✅ Good - Sufficient touch targets:**\n\n```typescript\n// Buttons should be at least 44x44px (touch target size)\n<button className=\"min-h-[44px] min-w-[44px] px-4 py-2\">\n\tClick me\n</button>\n```\n\n**✅ Good - Spacing between interactive elements:**\n\n```typescript\n<div className=\"flex gap-4\">\n\t<Button>Save</Button>\n\t<Button>Cancel</Button>\n</div>\n```\n\n### Internationalization (i18n) Considerations\n\n**✅ Good - Use semantic HTML for dates/times:**\n\n```typescript\n<time dateTime={note.createdAt.toISOString()}>\n\t{formatDate(note.createdAt)}\n</time>\n```\n\n**✅ Good - Use semantic HTML for numbers:**\n\n```typescript\n// Screen readers can pronounce numbers correctly\n<p>Total: <span aria-label={`${count} items`}>{count}</span></p>\n```\n\n**✅ Good - Language attributes:**\n\n```typescript\n// In root.tsx\n<html lang=\"en\">\n\t<body>\n\t\t{/* Content */}\n\t</body>\n</html>\n```\n\n### Dark Mode Accessibility\n\n**✅ Good - Maintain contrast in dark mode:**\n\n```typescript\n// Ensure sufficient contrast in both modes\n<div className=\"bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100\">\n\t{content}\n</div>\n```\n\n**✅ Good - Respect user preference:**\n\n```typescript\n// Epic Stack automatically handles theme preference\n// Use semantic colors that work in both modes\n<button className=\"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600\">\n\tButton\n</button>\n```\n\n### Animation and Motion\n\n**✅ Good - Respect reduced motion:**\n\n```typescript\n// Tailwind automatically respects prefers-reduced-motion\n<div className=\"transition-transform duration-200 hover:scale-105 motion-reduce:transition-none\">\n\t{/* Animations disabled for users who prefer reduced motion */}\n</div>\n```\n\n**✅ Good - Use CSS for animations:**\n\n```typescript\n// ✅ CSS animations can be disabled via prefers-reduced-motion\n<div className=\"animate-fade-in\">\n\t{/* Content */}\n</div>\n\n// ❌ JavaScript animations may not respect user preferences\n```\n\n## Common mistakes to avoid\n\n- ❌ **Treating accessibility as a checklist**: Accessibility is about serving\n  real people, not just meeting standards\n- ❌ **Missing form labels**: Always use `Field` component which includes\n  labels - helps all users, not just screen reader users\n- ❌ **Using divs for semantic elements**: Use `<article>`, `<header>`, `<nav>`,\n  etc. - helps all users understand content structure\n- ❌ **Ignoring keyboard navigation**: Ensure all interactive elements are\n  keyboard accessible - helps power users and those who can't use a mouse\n- ❌ **Low color contrast**: Test color combinations for WCAG AA compliance -\n  helps users with visual impairments and in bright sunlight\n- ❌ **Missing ARIA attributes**: Use Epic Stack components which handle this\n  automatically\n- ❌ **Breaking focus management**: Let Radix components handle focus\n- ❌ **Not testing with screen readers**: Test with VoiceOver, NVDA, or JAWS -\n  understand how real users experience your UI\n- ❌ **Hiding content from screen readers**: Use `sr-only` instead of\n  `display: none` for screen reader only content\n- ❌ **Ignoring mobile users**: Always test on mobile devices - many users only\n  have mobile access\n- ❌ **Not using Tailwind's responsive utilities**: Use mobile-first responsive\n  design\n- ❌ **Not using live regions**: Use `aria-live` for dynamic content\n  announcements\n- ❌ **Small touch targets**: Ensure interactive elements are at least 44x44px -\n  helps users with motor impairments and on mobile\n- ❌ **Ignoring reduced motion**: Respect `prefers-reduced-motion` media query -\n  helps users with vestibular disorders\n- ❌ **Poor focus indicators**: Ensure focus is always visible - helps keyboard\n  users navigate\n- ❌ **Missing skip links**: Add skip to main content links for keyboard users\n\n## References\n\n- [Web Content Accessibility Guidelines (WCAG)](https://www.w3.org/WAI/WCAG21/quickref/)\n- [Epic Web Principles](https://www.epicweb.dev/principles)\n- [Radix UI Documentation](https://www.radix-ui.com/)\n- [Tailwind CSS Documentation](https://tailwindcss.com/)\n- [Epic Stack Forms Documentation](../epic-forms/SKILL.md)\n- `app/components/forms.tsx` - Accessible form components\n- `app/components/ui/` - Accessible UI components using Radix\n- `app/styles/tailwind.css` - Tailwind configuration\n</yakaai_skills>"}