diff --git a/app/routes/_auth/auth.$provider/callback.test.ts b/app/routes/_auth/auth.$provider/callback.test.ts index 2f611c4a8..a0da9755f 100644 --- a/app/routes/_auth/auth.$provider/callback.test.ts +++ b/app/routes/_auth/auth.$provider/callback.test.ts @@ -19,6 +19,11 @@ import { loader } from './callback.ts' const ROUTE_PATH = '/auth/github/callback' const PARAMS = { provider: 'github' } +const LOADER_ARGS_BASE = { + params: PARAMS, + context: {} as AppLoadContext, + unstable_pattern: ROUTE_PATH, +} afterEach(async () => { await deleteGitHubUsers() @@ -28,8 +33,7 @@ test('a new user goes to onboarding', async () => { const request = await setupRequest() const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }).catch((e) => e) expect(response).toHaveRedirect('/onboarding/github') }) @@ -44,8 +48,7 @@ test('when auth fails, send the user to login with a toast', async () => { const request = await setupRequest() const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }).catch((e) => e) invariant(response instanceof Response, 'response should be a Response') expect(response).toHaveRedirect('/login') @@ -67,8 +70,7 @@ test('when a user is logged in, it creates the connection', async () => { }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( @@ -107,8 +109,7 @@ test(`when a user is logged in and has already connected, it doesn't do anything }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( @@ -126,8 +127,7 @@ test('when a user exists with the same email, create connection and make session const request = await setupRequest({ code: githubUser.code }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) expect(response).toHaveRedirect('/') @@ -174,8 +174,7 @@ test('gives an error if the account is already connected to another user', async }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) expect(response).toHaveRedirect('/settings/profile/connections') await expect(response).toSendToast( @@ -201,8 +200,7 @@ test('if a user is not logged in, but the connection exists, make a session', as const request = await setupRequest({ code: githubUser.code }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) expect(response).toHaveRedirect('/') await expect(response).toHaveSessionForUser(userId) @@ -229,8 +227,7 @@ test('if a user is not logged in, but the connection exists and they have enable const request = await setupRequest({ code: githubUser.code }) const response = await loader({ request, - params: PARAMS, - context: {} as AppLoadContext, + ...LOADER_ARGS_BASE, }) const searchParams = new URLSearchParams({ type: twoFAVerificationType, diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index 6e26fb93e..9f0ff6601 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -24,10 +24,15 @@ const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH const cacheDb = remember('cacheDb', createDatabase) function createDatabase(tryAgain = true): DatabaseSync { - const parentDir = path.dirname(CACHE_DATABASE_PATH) + const databasePath = CACHE_DATABASE_PATH + if (!databasePath) { + throw new Error('CACHE_DATABASE_PATH is not set') + } + + const parentDir = path.dirname(databasePath) fs.mkdirSync(parentDir, { recursive: true }) - const db = new DatabaseSync(CACHE_DATABASE_PATH) + const db = new DatabaseSync(databasePath) const { currentIsPrimary } = getInstanceInfoSync() if (!currentIsPrimary) return db @@ -41,10 +46,21 @@ function createDatabase(tryAgain = true): DatabaseSync { ) `) } catch (error: unknown) { - fs.unlinkSync(CACHE_DATABASE_PATH) + try { + fs.rmSync(databasePath, { force: true }) + } catch (unlinkError) { + if ( + typeof unlinkError !== 'object' || + unlinkError === null || + !('code' in unlinkError) || + unlinkError.code !== 'ENOENT' + ) { + throw unlinkError + } + } if (tryAgain) { console.error( - `Error creating cache database, deleting the file at "${CACHE_DATABASE_PATH}" and trying again...`, + `Error creating cache database, deleting the file at "${databasePath}" and trying again...`, ) return createDatabase(false) } diff --git a/app/utils/storage.server.ts b/app/utils/storage.server.ts index 2fd7360a9..dc18534e3 100644 --- a/app/utils/storage.server.ts +++ b/app/utils/storage.server.ts @@ -14,7 +14,7 @@ async function uploadToStorage(file: File | FileUpload, key: string) { const uploadResponse = await fetch(url, { method: 'PUT', headers, - body: file instanceof File ? file : file.stream(), + body: file instanceof File ? file : (file as FileUpload).stream(), }) if (!uploadResponse.ok) { diff --git a/docs/authentication.md b/docs/authentication.md index af7bc3a52..9e17cadab 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -43,13 +43,12 @@ is a precondition for a "Mock GitHub server" to be installed (with the help of [module](../tests/mocks/github.ts) for more details and pay attention to how the calls to `https://github.com/login/oauth/access_token` are being intercepted. But once deployed to an environment where `process.env.MOCKS` is not set to -`'true'` (see how this is done when launching the -[server](../server/index.ts) and checked in the -[entrypoint](../index.ts)), or even when developing _locally_ but not setting -`GITHUB_CLIENT_ID` to `MOCK_...`, the requests will actually reach the GitHub -auth server. This is where you will want to have a GitHub OAuth application -properly set up, otherwise the logging in with GitHub will fail and a -corresponding toast will appear on the screen. +`'true'` (see how this is done when launching the [server](../server/index.ts) +and checked in the [entrypoint](../index.ts)), or even when developing _locally_ +but not setting `GITHUB_CLIENT_ID` to `MOCK_...`, the requests will actually +reach the GitHub auth server. This is where you will want to have a GitHub OAuth +application properly set up, otherwise the logging in with GitHub will fail and +a corresponding toast will appear on the screen. To set up a real OAuth application, log in to GitHub, go to `Settings -> Developer settings -> OAuth Apps`, and hit the diff --git a/docs/database.md b/docs/database.md index 746714d37..f9d69c322 100644 --- a/docs/database.md +++ b/docs/database.md @@ -300,7 +300,6 @@ You've got a few options: re-generating the migration after fixing the error. 3. If you do care about the data and don't have a backup, you can follow these steps: - 1. Comment out the [`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37). diff --git a/docs/decisions/031-imports.md b/docs/decisions/031-imports.md index 83ad00bf4..599b2b637 100644 --- a/docs/decisions/031-imports.md +++ b/docs/decisions/031-imports.md @@ -28,9 +28,9 @@ and manually modify. Despite the magic of Path aliases, they are actually a standard `package.json` supported feature. Sort of. [The `"imports"` field](https://nodejs.org/api/packages.html#imports) in -`package.json` allows you to configure aliases for your imports. -TypeScript also uses this for its own Path aliases since version 5.4 -so you get autocomplete and type checking for your imports. +`package.json` allows you to configure aliases for your imports. TypeScript also +uses this for its own Path aliases since version 5.4 so you get autocomplete and +type checking for your imports. By using the `"imports"` field, you don't have to do any special configuration for `vitest` or `eslint` to be able to resolve imports. They just resolve them @@ -44,7 +44,8 @@ again it's just a matter of familiarity. So it's no big deal. ## Decision -We're going to configure `"imports"` in the `package.json` to use path aliases for imports. +We're going to configure `"imports"` in the `package.json` to use path aliases +for imports. We'll set it to `"#*": "./*"` which will allow us to import anything in the root of the repo with `#/`. diff --git a/docs/decisions/039-passkeys.md b/docs/decisions/039-passkeys.md index af37ca62e..32916b52f 100644 --- a/docs/decisions/039-passkeys.md +++ b/docs/decisions/039-passkeys.md @@ -11,7 +11,6 @@ username/password and OAuth providers. While these methods are widely used, they come with various security challenges: 1. Password-based authentication: - - Users often reuse passwords across services - Passwords can be phished or stolen - Password management is a burden for users @@ -39,14 +38,12 @@ using: The authentication flow works as follows: 1. Registration: - - Server generates a challenge and sends registration options - Client creates a new key pair and signs the challenge with the private key - Public key and metadata are sent to the server for storage - Private key remains securely stored in the authenticator 2. Authentication: - - Server generates a new challenge - Client signs it with the stored private key - Server verifies the signature using the stored public key @@ -64,19 +61,16 @@ While passkeys represent the future of authentication, we maintain support for password and OAuth authentication because: 1. Adoption and Transition: - - Passkey support is still rolling out across platforms and browsers - Users need time to become comfortable with the new technology - Organizations may have existing requirements for specific auth methods 2. Fallback Options: - - Some users may not have compatible devices - Enterprise environments might restrict biometric authentication - Backup authentication methods provide reliability 3. User Choice: - - Different users have different security/convenience preferences - Some scenarios may require specific authentication types - Supporting multiple methods maximizes accessibility @@ -112,20 +106,17 @@ We chose SimpleWebAuthn because: ### Positive: 1. Enhanced Security for Users: - - Phishing-resistant authentication adds protection against common attacks - Hardware-backed security provides stronger guarantees than passwords alone - Biometric authentication reduces risk of credential sharing 2. Improved User Experience Options: - - Users can choose between password, OAuth, or passkey based on their needs - Native biometric flows provide fast and familiar authentication - Password manager integration enables seamless cross-device access - Multiple authentication methods increase accessibility 3. Future-Proofing Authentication: - - Adoption of web standard - Gradual transition path as passkey support grows - Meeting evolving security best practices @@ -133,14 +124,12 @@ We chose SimpleWebAuthn because: ### Negative: 1. Implementation Complexity: - - WebAuthn is a complex specification - Need to handle various device capabilities - Must maintain backward compatibility - Need to maintain password-based auth as fallback 2. User Education: - - New technology requires user education - Some users may be hesitant to adopt - Need clear documentation and UI guidance @@ -148,7 +137,6 @@ We chose SimpleWebAuthn because: ### Neutral: 1. Data Storage: - - New database model for passkeys - Additional storage requirements per user - Migration path for existing users diff --git a/docs/decisions/043-pwnedpasswords.md b/docs/decisions/043-pwnedpasswords.md index 366105e6f..e457f537d 100644 --- a/docs/decisions/043-pwnedpasswords.md +++ b/docs/decisions/043-pwnedpasswords.md @@ -22,14 +22,12 @@ However, we wanted to implement this in a way that: We will integrate the HaveIBeenPwned Password API with the following approach: 1. **Progressive Enhancement** - - The password check is implemented as a non-blocking enhancement - If the check fails or times out (>1s), we allow the password - This ensures users can still set passwords even if the service is unavailable 2. **Development Experience** - - The API calls are mocked during development and testing using MSW (Mock Service Worker) - This prevents unnecessary API calls during development @@ -37,7 +35,6 @@ We will integrate the HaveIBeenPwned Password API with the following approach: - Follows our pattern of mocking external services 3. **Error Handling** - - Timeout after 1 second to prevent blocking users - Graceful fallback if the service is unavailable - Warning logs for monitoring service health diff --git a/docs/decisions/044-rr-devtools.md b/docs/decisions/044-rr-devtools.md index 904051aba..60b4c52b4 100644 --- a/docs/decisions/044-rr-devtools.md +++ b/docs/decisions/044-rr-devtools.md @@ -6,34 +6,33 @@ Status: accepted ## Context -Epic Stack uses React Router for routing. React Router is a powerful -library, but it can be difficult to debug and visualize the routing -in your application. This is especially true when you have a complex -routing structure with nested routes, dynamic routes, and you rely -on data functions like loaders and actions, which the Epic Stack does. - -It is also hard to know which routes are currently active -(which ones are rendered) and if any if the loaders are triggered -when you expect them to be. This can lead to confusion and frustration -and the use of console.log statements to debug the routing in your -application. - -This is where the React Router DevTools come in. The React -Router DevTools are a set of tools that do all of these things for you. - -React Router has a set of DevTools that help debug and visualize the -routing in your application. The DevTools allow you to see the -current route information, including the current location, the matched -routes, and the route hierarchy. This can be very helpful when debugging -your applications. The DevTools also hook into your server-side by -wrapping loaders and actions, allowing you to get extensive -information about the data being loaded and the actions being dispatched. +Epic Stack uses React Router for routing. React Router is a powerful library, +but it can be difficult to debug and visualize the routing in your application. +This is especially true when you have a complex routing structure with nested +routes, dynamic routes, and you rely on data functions like loaders and actions, +which the Epic Stack does. + +It is also hard to know which routes are currently active (which ones are +rendered) and if any if the loaders are triggered when you expect them to be. +This can lead to confusion and frustration and the use of console.log statements +to debug the routing in your application. + +This is where the React Router DevTools come in. The React Router DevTools are a +set of tools that do all of these things for you. + +React Router has a set of DevTools that help debug and visualize the routing in +your application. The DevTools allow you to see the current route information, +including the current location, the matched routes, and the route hierarchy. +This can be very helpful when debugging your applications. The DevTools also +hook into your server-side by wrapping loaders and actions, allowing you to get +extensive information about the data being loaded and the actions being +dispatched. ## Decision -We will add the React Router DevTools to the Epic Stack. The DevTools -will be added to the project as a development dependency. The DevTools -will be used in development mode only. +We will add the React Router DevTools to the Epic Stack. The DevTools will be +added to the project as a development dependency. The DevTools will be used in +development mode only. The DevTools will be used to enhance the following: @@ -45,19 +44,18 @@ The DevTools will be used to enhance the following: 6. See cache information returned via headers from your loaders 7. See which loaders/actions are triggered when you navigate to a route 8. and a lot more! - ## Consequences With the addition of the React Router DevTools, you will not have to rely on -console.log statements to debug your routing. The DevTools will provide you -with the tools to ship your applications faster and with more confidence. +console.log statements to debug your routing. The DevTools will provide you with +the tools to ship your applications faster and with more confidence. -The DevTools will also help you visualize the routing in your application, -which can be very helpful in understanding routing in general, and figuring -out if your routes are set up correctly. +The DevTools will also help you visualize the routing in your application, which +can be very helpful in understanding routing in general, and figuring out if +your routes are set up correctly. -They are not included in the production build by default, so you will not -have to worry about them being included in your production bundle. -They are only included in development mode, so you can use them without -any negative performance impact in production. \ No newline at end of file +They are not included in the production build by default, so you will not have +to worry about them being included in your production bundle. They are only +included in development mode, so you can use them without any negative +performance impact in production. diff --git a/docs/examples.md b/docs/examples.md index 22edd9cc6..af670284b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -120,8 +120,9 @@ This page links to examples of how to implement some things with the Epic Stack. [@hakimLyon](https://github.com/hakimLyon): An example of Epic Stack with Hono. - [Varlock](https://github.com/dmno-dev/epic-stack-with-varlock) by - [@dmno-dev](https://github.com/dmno-dev): Using [Varlock](https://varlock.dev) and a - `.env.schema` file to to improve DX and security of configuration and secrets. + [@dmno-dev](https://github.com/dmno-dev): Using [Varlock](https://varlock.dev) + and a `.env.schema` file to to improve DX and security of configuration and + secrets. ## How to contribute diff --git a/docs/skills/epic-auth/SKILL.md b/docs/skills/epic-auth/SKILL.md index dad6b3153..6be8ed51c 100644 --- a/docs/skills/epic-auth/SKILL.md +++ b/docs/skills/epic-auth/SKILL.md @@ -1,6 +1,7 @@ --- name: epic-auth -description: Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack +description: + Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack categories: - authentication - sessions @@ -13,6 +14,7 @@ categories: ## When to use this skill Use this skill when you need to: + - Implement user authentication - Work with sessions and cookies - Configure OAuth providers (GitHub, Google, etc.) @@ -28,11 +30,16 @@ Use this skill when you need to: Following Epic Web principles: -**Least privilege** - Users should only have access to what they need, when they need it. Sessions should have minimal permissions and expire appropriately. Don't grant more access than necessary. +**Least privilege** - Users should only have access to what they need, when they +need it. Sessions should have minimal permissions and expire appropriately. +Don't grant more access than necessary. -**Design to fail fast and early** - Validate authentication and authorization as early as possible. Check session validity immediately, verify permissions before processing requests, and return clear errors quickly. +**Design to fail fast and early** - Validate authentication and authorization as +early as possible. Check session validity immediately, verify permissions before +processing requests, and return clear errors quickly. **Example - Least privilege in sessions:** + ```typescript // ✅ Good - Minimal session data, explicit permissions const session = await prisma.session.create({ @@ -58,20 +65,21 @@ const session = await prisma.session.create({ ``` **Example - Fail fast authentication:** + ```typescript // ✅ Good - Validate authentication early export async function loader({ request }: Route.LoaderArgs) { // Check authentication immediately - fail fast const userId = await requireUserId(request) - + // Check permissions early - fail fast await requireUserWithPermission(request, 'read:note:own') - + // Only proceed if authenticated and authorized const notes = await prisma.note.findMany({ where: { ownerId: userId }, }) - + return { notes } } @@ -79,7 +87,7 @@ export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) { // Process request first... const notes = await prisma.note.findMany() - + // Check authentication at the end - too late! const userId = await getUserId(request) if (!userId) { @@ -91,9 +99,11 @@ export async function loader({ request }: Route.LoaderArgs) { ### Cookie-based Sessions -Epic Stack uses cookie-based sessions for authentication. Sessions are stored in the database and identified by signed cookies. +Epic Stack uses cookie-based sessions for authentication. Sessions are stored in +the database and identified by signed cookies. **Session configuration:** + ```typescript // app/utils/session.server.ts import { createCookieSessionStorage } from 'react-router' @@ -113,6 +123,7 @@ export const authSessionStorage = createCookieSessionStorage({ ### Get current user **Server-side:** + ```typescript import { getUserId, requireUserId } from '#app/utils/auth.server.ts' @@ -129,6 +140,7 @@ await requireAnonymous(request) // Redirects to / if authenticated ``` **Client-side:** + ```typescript import { useOptionalUser, useUser } from '#app/utils/user.ts' @@ -142,6 +154,7 @@ const user = useUser() ### Login with Email/Password **Validation schema:** + ```typescript const LoginSchema = z.object({ username: UsernameSchema, @@ -152,13 +165,14 @@ const LoginSchema = z.object({ ``` **Login action (fail fast):** + ```typescript import { login } from '#app/utils/auth.server.ts' import { handleNewSession } from './login.server.ts' export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() - + // Validate input early - fail fast const submission = await parseWithZod(formData, { schema: LoginSchema, @@ -198,14 +212,15 @@ export async function action({ request }: Route.ActionArgs) { ### Signup with Email/Password **Signup action:** + ```typescript import { signup } from '#app/utils/auth.server.ts' export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() - + // Validate form... - + const session = await signup({ email, username, @@ -222,6 +237,7 @@ export async function action({ request }: Route.ActionArgs) { Epic Stack uses `remix-auth` for OAuth providers. **Configure provider (GitHub example):** + ```typescript // app/utils/providers/github.server.ts import { GitHubStrategy } from 'remix-auth-github' @@ -249,12 +265,13 @@ export class GitHubProvider implements AuthProvider { ``` **Callback handler:** + ```typescript // app/routes/_auth/auth.$provider/callback.ts export async function loader({ request, params }: Route.LoaderArgs) { const providerName = ProviderNameSchema.parse(params.provider) const authResult = await authenticator.authenticate(providerName, request) - + if (!authResult.success) { throw redirectWithToast('/login', { title: 'Auth Failed', @@ -305,6 +322,7 @@ export async function loader({ request, params }: Route.LoaderArgs) { Epic Stack supports authentication with passkeys using WebAuthn. **Loader to generate options:** + ```typescript // app/routes/_auth/webauthn/authentication.ts import { generateAuthenticationOptions } from '@simplewebauthn/server' @@ -320,19 +338,23 @@ export async function loader({ request }: Route.LoaderArgs) { challenge: options.challenge, }) - return Response.json({ options }, { - headers: { 'Set-Cookie': cookieHeader } - }) + return Response.json( + { options }, + { + headers: { 'Set-Cookie': cookieHeader }, + }, + ) } ``` **Action to verify authentication:** + ```typescript import { verifyAuthenticationResponse } from '@simplewebauthn/server' export async function action({ request }: Route.ActionArgs) { const cookie = await passkeyCookie.parse(request.headers.get('Cookie')) - + if (!cookie?.challenge) { throw new Error('Authentication challenge not found') } @@ -382,6 +404,7 @@ export async function action({ request }: Route.ActionArgs) { Epic Stack uses TOTP (Time-based One-Time Password) para 2FA. **Check if user has 2FA:** + ```typescript const verification = await prisma.verification.findUnique({ where: { @@ -395,6 +418,7 @@ const userHasTwoFactor = Boolean(verification) ``` **Handle session with 2FA:** + ```typescript export async function handleNewSession({ request, @@ -422,7 +446,7 @@ export async function handleNewSession({ const verifySession = await verifySessionStorage.getSession() verifySession.set(unverifiedSessionIdKey, session.id) verifySession.set(rememberKey, remember) - + // Redirect to 2FA verification const redirectUrl = getRedirectToUrl({ request, @@ -453,6 +477,7 @@ export async function handleNewSession({ ``` **Verify 2FA code:** + ```typescript import { prepareTOTP, verifyTOTP } from '@epic-web/totp' @@ -520,6 +545,7 @@ export async function action({ request }: Route.ActionArgs) { Epic Stack uses TOTP codes sent via email for verification. **Prepare verification:** + ```typescript import { prepareVerification } from './verify.server.ts' @@ -541,6 +567,7 @@ return redirect(redirectTo.toString()) ``` **Verify code:** + ```typescript export async function loader({ request }: Route.LoaderArgs) { const verifySession = await verifySessionStorage.getSession( @@ -565,6 +592,7 @@ export async function action({ request }: Route.ActionArgs) { ### Password Reset **Request reset:** + ```typescript export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() @@ -598,6 +626,7 @@ export async function action({ request }: Route.ActionArgs) { ``` **Reset password:** + ```typescript export async function action({ request }: Route.ActionArgs) { // Verify code first (similar to email verification) @@ -636,6 +665,7 @@ export async function action({ request }: Route.ActionArgs) { ### Session Management **Create session:** + ```typescript const session = await prisma.session.create({ data: { @@ -647,6 +677,7 @@ const session = await prisma.session.create({ ``` **Session expiration:** + ```typescript export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 // 30 days @@ -655,11 +686,13 @@ export const getSessionExpirationDate = () => ``` **Destroy session:** + ```typescript await prisma.session.deleteMany({ where: { id: sessionId } }) ``` **Destroy all user sessions:** + ```typescript await prisma.session.deleteMany({ where: { userId } }) ``` @@ -712,11 +745,11 @@ export async function action({ request }: Route.ActionArgs) { // app/routes/protected.tsx export async function loader({ request }: Route.LoaderArgs) { const userId = await requireUserId(request) - + const data = await prisma.something.findMany({ where: { userId }, }) - + return { data } } @@ -737,17 +770,27 @@ export async function loader({ request }: Route.LoaderArgs) { ## Common mistakes to avoid -- ❌ **Delayed authentication checks**: Validate authentication and authorization as early as possible - fail fast -- ❌ **Granting excessive privileges**: Follow least privilege - only grant access to what's needed, when it's needed -- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user ID), check permissions from database -- ❌ **Not verifying session on each request**: Always use `getUserId` or `requireUserId` in protected loaders/actions -- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating session -- ❌ **Not destroying expired sessions**: Sessions must be verified against `expirationDate` - check early -- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and cookie creation +- ❌ **Delayed authentication checks**: Validate authentication and + authorization as early as possible - fail fast +- ❌ **Granting excessive privileges**: Follow least privilege - only grant + access to what's needed, when it's needed +- ❌ **Storing too much in sessions**: Store minimal data in sessions (just user + ID), check permissions from database +- ❌ **Not verifying session on each request**: Always use `getUserId` or + `requireUserId` in protected loaders/actions +- ❌ **Not handling 2FA correctly**: Verify if user has 2FA before creating + session +- ❌ **Not destroying expired sessions**: Sessions must be verified against + `expirationDate` - check early +- ❌ **Not using `handleNewSession`**: This helper correctly handles 2FA and + cookie creation - ❌ **Forgetting to handle `remember`**: Make sure to respect user preference -- ❌ **Not validating OAuth callbacks**: Always validate that provider exists and result is successful - fail fast -- ❌ **Not linking OAuth accounts**: If email exists, link the account instead of creating duplicate -- ❌ **Not updating counter in passkeys**: Always update counter after successful verification +- ❌ **Not validating OAuth callbacks**: Always validate that provider exists + and result is successful - fail fast +- ❌ **Not linking OAuth accounts**: If email exists, link the account instead + of creating duplicate +- ❌ **Not updating counter in passkeys**: Always update counter after + successful verification ## References diff --git a/docs/skills/epic-caching/SKILL.md b/docs/skills/epic-caching/SKILL.md index 891cff269..23cc7060e 100644 --- a/docs/skills/epic-caching/SKILL.md +++ b/docs/skills/epic-caching/SKILL.md @@ -1,6 +1,7 @@ --- name: epic-caching -description: Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack +description: + Guide on caching with cachified, SQLite cache, and LRU cache for Epic Stack categories: - caching - performance @@ -12,6 +13,7 @@ categories: ## When to use this skill Use this skill when you need to: + - Cache results of expensive queries - Cache responses from external APIs - Optimize performance of data that doesn't change frequently @@ -25,9 +27,13 @@ Use this skill when you need to: Following Epic Web principles: -**Weigh the cost-benefit of performance optimizations** - Caching adds complexity. Only add cache when there's a clear, measurable benefit. Don't cache "just in case" - cache when you have a real performance problem that caching solves. +**Weigh the cost-benefit of performance optimizations** - Caching adds +complexity. Only add cache when there's a clear, measurable benefit. Don't cache +"just in case" - cache when you have a real performance problem that caching +solves. **When NOT to use cache:** + - Data that changes frequently (cache invalidation becomes a problem) - Data that's already fast to fetch (no measurable benefit) - Data that's only fetched once (no benefit from caching) @@ -35,16 +41,25 @@ Following Epic Web principles: - When cache invalidation logic becomes more complex than the problem it solves **Example - Evaluating cost-benefit:** + ```typescript // ✅ Good - Cache expensive external API call -export async function getGitHubEvents({ username, timings }: { username: string; timings?: Timings }) { +export async function getGitHubEvents({ + username, + timings, +}: { + username: string + timings?: Timings +}) { return await cachified({ key: `github:${username}:events`, cache, timings, getFreshValue: async () => { // Expensive: External API call, rate limits, network latency - const response = await fetch(`https://api.github.com/users/${username}/events/public`) + const response = await fetch( + `https://api.github.com/users/${username}/events/public`, + ) return await response.json() }, checkValue: GitHubEventSchema.array(), @@ -90,12 +105,14 @@ Epic Stack provides two types of cache: Epic Stack uses `@epic-web/cachified` as an abstraction for cache management. **Basic import:** + ```typescript import { cachified, cache } from '#app/utils/cache.server.ts' import { type Timings } from '#app/utils/timing.server.ts' ``` **Basic structure:** + ```typescript export async function getCachedData({ timings, @@ -110,7 +127,9 @@ export async function getCachedData({ // Get fresh data return await fetchDataFromAPI() }, - checkValue: z.object({ /* schema */ }), // Validation with Zod + checkValue: z.object({ + /* schema */ + }), // Validation with Zod ttl: 1000 * 60 * 60 * 24, // 24 hours staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, // 30 days }) @@ -120,6 +139,7 @@ export async function getCachedData({ ### Cache Keys **Naming conventions:** + - Use format: `entity:identifier:data` - Examples: - `user:${userId}:profile` @@ -128,6 +148,7 @@ export async function getCachedData({ - `tito:scheduled-events` **Avoid:** + - Keys that are too long - Keys with special characters - Keys that don't clearly identify the content @@ -135,6 +156,7 @@ export async function getCachedData({ ### TTL (Time To Live) **Define TTL:** + ```typescript await cachified({ key: 'my-key', @@ -145,6 +167,7 @@ await cachified({ ``` **Null TTL to never expire:** + ```typescript ttl: null, // Never expires (not recommended unless necessary) ``` @@ -154,6 +177,7 @@ ttl: null, // Never expires (not recommended unless necessary) SWR allows returning stale data while fresh data is fetched in the background. **Example:** + ```typescript await cachified({ key: 'my-key', @@ -165,6 +189,7 @@ await cachified({ ``` **Behavior:** + - **Less than 24h**: Returns cache, no request made - **24h - 30 days**: Returns stale cache immediately, updates in background - **More than 30 days**: Waits for fresh data before returning @@ -208,19 +233,23 @@ import { type Timings } from '#app/utils/timing.server.ts' export async function loader({ request }: Route.LoaderArgs) { const timings: Timings = {} - + const events = await getEvents({ timings }) - + // Timings are automatically added to headers - return json({ events }, { - headers: combineServerTimings(timings), - }) + return json( + { events }, + { + headers: combineServerTimings(timings), + }, + ) } ``` ### Cache Invalidation **Invalidate by key:** + ```typescript import { cache } from '#app/utils/cache.server.ts' @@ -228,15 +257,17 @@ await cache.delete('user:123:profile') ``` **Invalidate multiple keys:** + ```typescript // Search and delete matching keys import { searchCacheKeys } from '#app/utils/cache.server.ts' const keys = await searchCacheKeys('user:123', 100) -await Promise.all(keys.map(key => cache.delete(key))) +await Promise.all(keys.map((key) => cache.delete(key))) ``` **Invalidate entire SQLite cache:** + ```typescript // Use admin dashboard or await cache.clear() // If available @@ -268,11 +299,13 @@ return cachedValue With LiteFS, SQLite cache is automatically replicated: **Behavior:** + - Only the primary instance writes to cache - Replicas can read from cache - Writes are automatically synchronized **Best practices:** + - Don't assume all writes are immediate - Use `ensurePrimary()` if you need to guarantee writes @@ -281,10 +314,10 @@ import { ensurePrimary } from '#app/utils/litefs.server.ts' export async function action({ request }: Route.ActionArgs) { await ensurePrimary() // Ensure we're on primary instance - + // Invalidate cache await cache.delete('my-key') - + // ... } ``` @@ -292,6 +325,7 @@ export async function action({ request }: Route.ActionArgs) { ### Error Handling **Handle errors in getFreshValue:** + ```typescript await cachified({ key: 'my-key', @@ -316,6 +350,7 @@ Epic Stack includes a dashboard to manage cache: **Route:** `/admin/cache` **Features:** + - View all cache keys - Search keys - View details of a key @@ -403,7 +438,7 @@ export async function getUserStats({ select: { createdAt: true }, }), ]) - + return { totalNotes, totalLikes, @@ -424,9 +459,9 @@ export async function getUserStats({ export async function action({ request }: Route.ActionArgs) { const userId = await requireUserId(request) const formData = await request.formData() - + // ... validate and create note - + const note = await prisma.note.create({ data: { title, @@ -435,14 +470,14 @@ export async function action({ request }: Route.ActionArgs) { }, include: { owner: true }, }) - + // Invalidate related cache await Promise.all([ cache.delete(`user:${userId}:notes`), cache.delete(`user:${userId}:stats`), cache.delete(`note:${note.id}:full`), ]) - + return redirect(`/users/${note.owner.username}/notes/${note.id}`) } ``` @@ -471,14 +506,16 @@ export async function getUserWithNotes({ }, }) }, - checkValue: z.object({ - id: z.string(), - username: z.string(), - name: z.string().nullable(), - }).nullable(), + checkValue: z + .object({ + id: z.string(), + username: z.string(), + name: z.string().nullable(), + }) + .nullable(), ttl: 1000 * 60 * 30, // 30 minutes }) - + const notes = await cachified({ key: `user:${userId}:notes`, cache, @@ -494,14 +531,16 @@ export async function getUserWithNotes({ orderBy: { updatedAt: 'desc' }, }) }, - checkValue: z.array(z.object({ - id: z.string(), - title: z.string(), - updatedAt: z.date(), - })), + checkValue: z.array( + z.object({ + id: z.string(), + title: z.string(), + updatedAt: z.date(), + }), + ), ttl: 1000 * 60 * 10, // 10 minutes }) - + return { user, notes } } ``` @@ -516,33 +555,41 @@ export async function fetchWithDedup(url: string) { if (requestCache.has(url)) { return requestCache.get(url) } - - const promise = fetch(url).then(res => res.json()) + + const promise = fetch(url).then((res) => res.json()) requestCache.set(url, promise) - + // Clean up after 1 second setTimeout(() => { requestCache.delete(url) }, 1000) - + return promise } ``` ## Common mistakes to avoid -- ❌ **Caching without measuring benefit**: Only add cache when there's a clear, measurable performance problem -- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to fetch - it adds complexity without benefit -- ❌ **Caching frequently changing data**: Cache invalidation becomes more complex than the problem it solves -- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive personal data -- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely necessary -- ❌ **Not validating cached data**: Always use `checkValue` with Zod to validate data +- ❌ **Caching without measuring benefit**: Only add cache when there's a clear, + measurable performance problem +- ❌ **Caching simple, fast queries**: Don't cache data that's already fast to + fetch - it adds complexity without benefit +- ❌ **Caching frequently changing data**: Cache invalidation becomes more + complex than the problem it solves +- ❌ **Caching sensitive data**: Never cache passwords, tokens, or sensitive + personal data +- ❌ **TTL too long**: Avoid very long TTLs (> 1 week) unless absolutely + necessary +- ❌ **Not validating cached data**: Always use `checkValue` with Zod to + validate data - ❌ **Forgetting to invalidate cache**: Invalidate cache after mutations - ❌ **Assuming cache always works**: Cache can fail, always handle errors - ❌ **Keys too long or ambiguous**: Use consistent and descriptive format - ❌ **Not using timings**: Integrate with server timing for monitoring -- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when appropriate -- ❌ **Over-caching**: Too much caching makes the system harder to understand and debug +- ❌ **Forgetting stale-while-revalidate**: Use SWR for better UX when + appropriate +- ❌ **Over-caching**: Too much caching makes the system harder to understand + and debug ## References diff --git a/docs/skills/epic-database/SKILL.md b/docs/skills/epic-database/SKILL.md index 9ff3654a1..46f7b638e 100644 --- a/docs/skills/epic-database/SKILL.md +++ b/docs/skills/epic-database/SKILL.md @@ -13,6 +13,7 @@ categories: ## When to use this skill Use this skill when you need to: + - Design database schema with Prisma - Create migrations - Work with SQLite and LiteFS @@ -27,11 +28,16 @@ Use this skill when you need to: Following Epic Web principles: -**Do as little as possible** - Only fetch the data you actually need. Use `select` to fetch specific fields instead of entire models. Avoid over-fetching data "just in case" - fetch what you need, when you need it. +**Do as little as possible** - Only fetch the data you actually need. Use +`select` to fetch specific fields instead of entire models. Avoid over-fetching +data "just in case" - fetch what you need, when you need it. -**Pragmatism over purity** - Optimize queries when there's a measurable benefit, but don't over-optimize prematurely. Simple, readable queries are often better than complex optimized ones. Add indexes when queries are slow, not before. +**Pragmatism over purity** - Optimize queries when there's a measurable benefit, +but don't over-optimize prematurely. Simple, readable queries are often better +than complex optimized ones. Add indexes when queries are slow, not before. **Example - Fetch only what you need:** + ```typescript // ✅ Good - Fetch only needed fields const user = await prisma.user.findUnique({ @@ -52,6 +58,7 @@ const user = await prisma.user.findUnique({ ``` **Example - Pragmatic optimization:** + ```typescript // ✅ Good - Simple query first, optimize if needed const notes = await prisma.note.findMany({ @@ -73,6 +80,7 @@ const notes = await prisma.note.findMany({ Epic Stack uses Prisma with SQLite as the database. **Basic configuration:** + ```prisma // prisma/schema.prisma generator client { @@ -87,16 +95,17 @@ datasource db { ``` **Basic model:** + ```prisma model User { id String @id @default(cuid()) email String @unique username String @unique name String? - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + notes Note[] roles Role[] } @@ -105,13 +114,13 @@ model Note { id String @id @default(cuid()) title String content String - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + owner User @relation(fields: [ownerId], references: [id]) ownerId String - + @@index([ownerId]) @@index([ownerId, updatedAt]) } @@ -122,12 +131,14 @@ model Note { Epic Stack uses CUID2 to generate unique IDs. **Advantages:** + - Globally unique - Sortable - Secure (no exposed information) - URL-friendly **Example:** + ```prisma model User { id String @id @default(cuid()) // Automatically generates CUID2 @@ -137,6 +148,7 @@ model User { ### Timestamps **Standard fields:** + ```prisma model User { createdAt DateTime @default(now()) @@ -147,6 +159,7 @@ model User { ### Relationships **One-to-Many:** + ```prisma model User { id String @id @default(cuid()) @@ -157,12 +170,13 @@ model Note { id String @id @default(cuid()) owner User @relation(fields: [ownerId], references: [id]) ownerId String - + @@index([ownerId]) } ``` **One-to-One:** + ```prisma model User { id String @id @default(cuid()) @@ -177,6 +191,7 @@ model UserImage { ``` **Many-to-Many:** + ```prisma model User { id String @id @default(cuid()) @@ -192,18 +207,20 @@ model Role { ### Indexes **Create indexes:** + ```prisma model Note { id String @id @default(cuid()) ownerId String updatedAt DateTime - + @@index([ownerId]) // Simple index @@index([ownerId, updatedAt]) // Composite index } ``` **Best practices:** + - Index foreign keys - Index fields used in `where` frequently - Index fields used in `orderBy` @@ -212,6 +229,7 @@ model Note { ### Cascade Delete **Configure cascade:** + ```prisma model User { id String @id @default(cuid()) @@ -226,6 +244,7 @@ model Note { ``` **Options:** + - `onDelete: Cascade` - Deletes children when parent is deleted - `onDelete: SetNull` - Sets to null when parent is deleted - `onDelete: Restrict` - Prevents deletion if there are children @@ -233,17 +252,19 @@ model Note { ### Migrations **Create migration:** + ```bash npx prisma migrate dev --name add_user_field ``` **Apply migrations in production:** + ```bash npx prisma migrate deploy ``` -**Automatic migrations:** -Migrations are automatically applied on deploy via `litefs.yml`. +**Automatic migrations:** Migrations are automatically applied on deploy via +`litefs.yml`. **"Widen then Narrow" strategy for zero-downtime:** @@ -279,11 +300,13 @@ ALTER TABLE User DROP COLUMN name; ### Prisma Client **Import Prisma Client:** + ```typescript import { prisma } from '#app/utils/db.server.ts' ``` **Basic query:** + ```typescript const user = await prisma.user.findUnique({ where: { id: userId }, @@ -291,6 +314,7 @@ const user = await prisma.user.findUnique({ ``` **Specific select:** + ```typescript const user = await prisma.user.findUnique({ where: { id: userId }, @@ -304,6 +328,7 @@ const user = await prisma.user.findUnique({ ``` **Include relations:** + ```typescript const user = await prisma.user.findUnique({ where: { id: userId }, @@ -321,6 +346,7 @@ const user = await prisma.user.findUnique({ ``` **Complex queries:** + ```typescript const notes = await prisma.note.findMany({ where: { @@ -341,6 +367,7 @@ const notes = await prisma.note.findMany({ ### Transactions **Use transactions:** + ```typescript await prisma.$transaction(async (tx) => { const user = await tx.user.create({ @@ -350,7 +377,7 @@ await prisma.$transaction(async (tx) => { roles: { connect: { name: 'user' } }, }, }) - + await tx.note.create({ data: { title: 'Welcome', @@ -358,7 +385,7 @@ await prisma.$transaction(async (tx) => { ownerId: user.id, }, }) - + return user }) ``` @@ -366,24 +393,31 @@ await prisma.$transaction(async (tx) => { ### SQLite con LiteFS **Multi-region with LiteFS:** + - Only the primary instance can write - Replicas can only read - Writes are automatically replicated **Check primary instance:** + ```typescript import { ensurePrimary, getInstanceInfo } from '#app/utils/litefs.server.ts' export async function action({ request }: Route.ActionArgs) { // Ensure we're on primary instance for writes await ensurePrimary() - + // Now we can write safely - await prisma.user.create({ data: { /* ... */ } }) + await prisma.user.create({ + data: { + /* ... */ + }, + }) } ``` **Get instance information:** + ```typescript import { getInstanceInfo } from '#app/utils/litefs.server.ts' @@ -399,6 +433,7 @@ if (currentIsPrimary) { ### Seed Scripts **Create seed:** + ```typescript // prisma/seed.ts import { prisma } from '#app/utils/db.server.ts' @@ -411,7 +446,7 @@ async function seed() { { name: 'admin', description: 'Administrator' }, ], }) - + // Create users const user = await prisma.user.create({ data: { @@ -420,7 +455,7 @@ async function seed() { roles: { connect: { name: 'user' } }, }, }) - + console.log('Seed complete!') } @@ -435,6 +470,7 @@ seed() ``` **Run seed:** + ```bash npx prisma db seed # Or directly: @@ -444,14 +480,17 @@ npx tsx prisma/seed.ts ### Query Optimization **Guidelines (pragmatic approach):** + - Use `select` to fetch only needed fields - do as little as possible - Use selective `include` - only include relations you actually use - Index fields used in `where` and `orderBy` - but only if queries are slow -- Use composite indexes for complex queries - when you have a real performance problem +- Use composite indexes for complex queries - when you have a real performance + problem - Avoid `select: true` (fetches everything) - be explicit about what you need - Measure first, optimize second - don't pre-optimize **Optimized example (do as little as possible):** + ```typescript // ❌ Avoid: Fetches everything unnecessarily const user = await prisma.user.findUnique({ @@ -490,6 +529,7 @@ const user = await prisma.user.findUnique({ ### Prisma Query Logging **Configure logging:** + ```typescript // app/utils/db.server.ts const client = new PrismaClient({ @@ -502,7 +542,7 @@ const client = new PrismaClient({ client.$on('query', async (e) => { if (e.duration < 20) return // Only log slow queries - + console.info(`prisma:query - ${e.duration}ms - ${e.query}`) }) ``` @@ -510,11 +550,13 @@ client.$on('query', async (e) => { ### Database URL **Development:** + ```bash DATABASE_URL=file:./data/db.sqlite ``` **Production (Fly.io):** + ```bash DATABASE_URL=file:/litefs/data/sqlite.db ``` @@ -522,16 +564,19 @@ DATABASE_URL=file:/litefs/data/sqlite.db ### Connecting to DB in Production **SSH to Fly instance:** + ```bash fly ssh console --app [YOUR_APP_NAME] ``` **Connect to DB CLI:** + ```bash fly ssh console -C database-cli --app [YOUR_APP_NAME] ``` **Prisma Studio:** + ```bash # Terminal 1: Start Prisma Studio fly ssh console -C "npx prisma studio" -s --app [YOUR_APP_NAME] @@ -553,16 +598,16 @@ model Post { title String content String published Boolean @default(false) - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String - + comments Comment[] tags Tag[] - + @@index([authorId]) @@index([authorId, published]) @@index([published, updatedAt]) @@ -571,15 +616,15 @@ model Post { model Comment { id String @id @default(cuid()) content String - + createdAt DateTime @default(now()) - + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) postId String - + author User @relation(fields: [authorId], references: [id]) authorId String - + @@index([postId]) @@index([authorId]) } @@ -600,14 +645,14 @@ export async function getPosts({ published?: boolean }) { const where: Prisma.PostWhereInput = {} - + if (userId) { where.authorId = userId } if (published !== undefined) { where.published = published } - + const [posts, total] = await Promise.all([ prisma.post.findMany({ where, @@ -628,7 +673,7 @@ export async function getPosts({ }), prisma.post.count({ where }), ]) - + return { posts, total, @@ -662,7 +707,7 @@ export async function createPostWithTags({ }), ), ) - + // Create post const post = await tx.post.create({ data: { @@ -674,7 +719,7 @@ export async function createPostWithTags({ }, }, }) - + return post }) } @@ -703,18 +748,18 @@ async function seed() { }, }), ]) - + // Create roles with permissions const userRole = await prisma.role.create({ data: { name: 'user', description: 'Standard user', permissions: { - connect: permissions.map(p => ({ id: p.id })), + connect: permissions.map((p) => ({ id: p.id })), }, }, }) - + // Create user with role const user = await prisma.user.create({ data: { @@ -725,25 +770,37 @@ async function seed() { }, }, }) - + console.log('Seed complete!') } ``` ## Common mistakes to avoid -- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need - do as little as possible -- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add indexes "just in case" -- ❌ **Not using indexes when needed**: Index foreign keys and fields used in frequent queries, but only if they're actually slow -- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when you need them -- ❌ **Not using transactions for related operations**: Always use transactions when multiple operations must be atomic -- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in production -- ❌ **Breaking migrations without strategy**: Use "widen then narrow" for zero-downtime -- ❌ **Not validating data before inserting**: Always validate with Zod before create/update -- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when parent is deleted -- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or others -- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but ensure in scripts -- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries over complex optimized ones unless there's a real problem +- ❌ **Fetching unnecessary data**: Use `select` to fetch only what you need - + do as little as possible +- ❌ **Over-optimizing prematurely**: Measure first, then optimize. Don't add + indexes "just in case" +- ❌ **Not using indexes when needed**: Index foreign keys and fields used in + frequent queries, but only if they're actually slow +- ❌ **N+1 queries**: Use `include` to fetch relations in a single query when + you need them +- ❌ **Not using transactions for related operations**: Always use transactions + when multiple operations must be atomic +- ❌ **Writing from replicas**: Verify `ensurePrimary()` before writes in + production +- ❌ **Breaking migrations without strategy**: Use "widen then narrow" for + zero-downtime +- ❌ **Not validating data before inserting**: Always validate with Zod before + create/update +- ❌ **Forgetting `onDelete` in relations**: Explicitly decide what to do when + parent is deleted +- ❌ **Not using CUID2**: Epic Stack uses CUID2 by default, don't use UUID or + others +- ❌ **Not closing Prisma Client**: Prisma handles this automatically, but + ensure in scripts +- ❌ **Complex queries when simple ones work**: Prefer simple, readable queries + over complex optimized ones unless there's a real problem ## References diff --git a/docs/skills/epic-deployment/SKILL.md b/docs/skills/epic-deployment/SKILL.md index 2761c9869..107745043 100644 --- a/docs/skills/epic-deployment/SKILL.md +++ b/docs/skills/epic-deployment/SKILL.md @@ -1,6 +1,7 @@ --- name: epic-deployment -description: Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack +description: + Guide on deployment with Fly.io, multi-region setup, and CI/CD for Epic Stack categories: - deployment - fly-io @@ -13,6 +14,7 @@ categories: ## When to use this skill Use this skill when you need to: + - Configure deployment on Fly.io - Setup multi-region deployment - Configure CI/CD with GitHub Actions @@ -28,6 +30,7 @@ Use this skill when you need to: Epic Stack uses Fly.io for hosting with configuration in `fly.toml`. **Basic configuration:** + ```toml # fly.toml app = "your-app-name" @@ -47,11 +50,13 @@ destination = "/data" ### Primary Region **Configure primary region:** + ```toml primary_region = "sjc" # Change according to your location ``` **Important:** The primary region must be the same for: + - `primary_region` en `fly.toml` - Region del volume `data` - `PRIMARY_REGION` en variables de entorno @@ -59,6 +64,7 @@ primary_region = "sjc" # Change according to your location ### LiteFS Configuration **Configuration in `other/litefs.yml`:** + ```yaml fuse: dir: '${LITEFS_DIR}' @@ -94,6 +100,7 @@ exec: ### Healthchecks **Configuration in `fly.toml`:** + ```toml [[services.http_checks]] interval = "10s" @@ -106,10 +113,12 @@ tls_skip_verify = false ``` **Healthcheck implementation:** + ```typescript // app/routes/resources/healthcheck.tsx export async function loader({ request }: Route.LoaderArgs) { - const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') + const host = + request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') try { await Promise.all([ @@ -130,6 +139,7 @@ export async function loader({ request }: Route.LoaderArgs) { ### Environment Variables **Secrets in Fly.io:** + ```bash # Generate secrets fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME] @@ -143,6 +153,7 @@ fly secrets unset SECRET_NAME --app [YOUR_APP_NAME] ``` **Common secrets:** + - `SESSION_SECRET` - Secret for signing session cookies - `HONEYPOT_SECRET` - Secret for honeypot fields - `DATABASE_URL` - Automatically configured by LiteFS @@ -154,16 +165,19 @@ fly secrets unset SECRET_NAME --app [YOUR_APP_NAME] ### Volumes **Create volume:** + ```bash fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME] ``` **List volumes:** + ```bash fly volumes list --app [YOUR_APP_NAME] ``` **Expand volume:** + ```bash fly volumes extend --size 10 --app [YOUR_APP_NAME] ``` @@ -171,6 +185,7 @@ fly volumes extend --size 10 --app [YOUR_APP_NAME] ### Multi-Region Deployment **Deploy to multiple regions:** + ```bash # Deploy in primary region (more instances) fly scale count 2 --region sjc --app [YOUR_APP_NAME] @@ -181,6 +196,7 @@ fly scale count 1 --region syd --app [YOUR_APP_NAME] ``` **Verify instances:** + ```bash fly status --app [YOUR_APP_NAME] # The ROLE column will show "primary" or "replica" @@ -189,11 +205,13 @@ fly status --app [YOUR_APP_NAME] ### Consul Setup **Attach Consul:** + ```bash fly consul attach --app [YOUR_APP_NAME] ``` **Consul manages:** + - Which instance is primary - Automatic failover - Data replication @@ -201,6 +219,7 @@ fly consul attach --app [YOUR_APP_NAME] ### GitHub Actions CI/CD **Basic workflow:** + ```yaml # .github/workflows/deploy.yml name: Deploy @@ -222,6 +241,7 @@ jobs: ``` **Complete configuration:** + - Deploy to `production` from `main` branch - Deploy to `staging` from `dev` branch - Tests before deploy (optional) @@ -230,13 +250,16 @@ jobs: Following Epic Web principles: -**Deployable commits** - Every commit to the main branch should be deployable. This means: +**Deployable commits** - Every commit to the main branch should be deployable. +This means: + - The code should be in a working state - Tests should pass - The application should build successfully - No "WIP" or "TODO" commits that break the build **Example - Deployable commit workflow:** + ```bash # ✅ Good - Each commit is deployable git commit -m "Add user profile page" @@ -254,6 +277,7 @@ git commit -m "Add feature (tests failing)" ``` **Benefits:** + - Easy rollback - any commit can be deployed - Continuous deployment - deploy any time - Clear history - each commit represents a working state @@ -263,15 +287,18 @@ git commit -m "Add feature (tests failing)" Following Epic Web principles: -**Small and short lived merge requests** - Keep PRs small and merge them quickly. Large PRs are hard to review, risky to merge, and slow down the team. +**Small and short lived merge requests** - Keep PRs small and merge them +quickly. Large PRs are hard to review, risky to merge, and slow down the team. **Guidelines:** + - **Small PRs** - Focus on one feature or fix per PR - **Short-lived** - Merge within a day or two, not weeks - **Reviewable** - PRs should be reviewable in 30 minutes or less - **Independent** - Each PR should be independently deployable **Example - Small, focused PR:** + ```bash # ✅ Good - Small, focused PR # PR: "Add email validation to signup form" @@ -289,6 +316,7 @@ Following Epic Web principles: ``` **Benefits:** + - Faster reviews - easier to understand and review - Lower risk - smaller changes are less risky - Faster feedback - get feedback sooner @@ -296,6 +324,7 @@ Following Epic Web principles: - Better collaboration - team can work in parallel on different small PRs **When PRs get too large:** + - Split into multiple smaller PRs - Use feature flags to merge incrementally - Break down into logical pieces @@ -303,11 +332,13 @@ Following Epic Web principles: ### Tigris Object Storage **Create storage:** + ```bash fly storage create --app [YOUR_APP_NAME] ``` **This creates:** + - Tigris bucket - Automatic environment variables: - `TIGRIS_ENDPOINT` @@ -317,8 +348,8 @@ fly storage create --app [YOUR_APP_NAME] ### Database Migrations -**Automatic migrations:** -Migrations are automatically applied on deploy via `litefs.yml`: +**Automatic migrations:** Migrations are automatically applied on deploy via +`litefs.yml`: ```yaml exec: @@ -331,6 +362,7 @@ exec: ### Database Backups **Create backup:** + ```bash # SSH to instance fly ssh console --app [YOUR_APP_NAME] @@ -345,6 +377,7 @@ fly ssh sftp get /backups/backup-2024-01-01.db --app [YOUR_APP_NAME] ``` **Restore backup:** + ```bash # Upload backup fly ssh sftp shell --app [YOUR_APP_NAME] @@ -360,11 +393,13 @@ exit ### Deployment Local **Deploy con Fly CLI:** + ```bash fly deploy ``` **Deploy con Docker:** + ```bash # Build docker build -t epic-stack . -f other/Dockerfile \ @@ -383,12 +418,14 @@ docker run -d \ ### Zero-Downtime Deploys **Strategy:** + - Deploy to multiple instances - Automatic blue-green deployment - Healthchecks verify app is ready - Auto-rollback if healthcheck fails **Configuration:** + ```toml [experimental] auto_rollback = true @@ -397,17 +434,20 @@ auto_rollback = true ### Monitoring **View logs:** + ```bash fly logs --app [YOUR_APP_NAME] ``` **View metrics:** + ```bash fly dashboard --app [YOUR_APP_NAME] # Or visit: https://fly.io/apps/[YOUR_APP_NAME]/monitoring ``` **Sentry (opcional):** + ```bash fly secrets set SENTRY_DSN=your-sentry-dsn --app [YOUR_APP_NAME] ``` @@ -514,15 +554,20 @@ git push origin main ## Common mistakes to avoid -- ❌ **Non-deployable commits**: Every commit to main should be deployable - no WIP or broken commits -- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are hard to review and risky -- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml` matches the volume region +- ❌ **Non-deployable commits**: Every commit to main should be deployable - no + WIP or broken commits +- ❌ **Large, long-lived PRs**: Keep PRs small and merge quickly - large PRs are + hard to review and risky +- ❌ **Inconsistent primary region**: Make sure `primary_region` in `fly.toml` + matches the volume region - ❌ **Secrets not configured**: Configure all secrets before first deploy - ❌ **Volume not created**: Create the `data` volume before deploy - ❌ **Consul not attached**: Attach Consul before first deploy - ❌ **Migrations on replicas**: Only the primary instance should run migrations -- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime deploys -- ❌ **Deploy breaking changes without strategy**: Use "widen then narrow" for migrations +- ❌ **Not using healthchecks**: Healthchecks are critical for zero-downtime + deploys +- ❌ **Deploy breaking changes without strategy**: Use "widen then narrow" for + migrations - ❌ **Secrets in code**: Never commit secrets, use `fly secrets` - ❌ **Not making backups**: Make regular database backups - ❌ **FLY_API_TOKEN exposed**: Never commit the token, only in GitHub Secrets @@ -541,9 +586,11 @@ git push origin main ### Preview Deployments (Inspired by Vercel Deploy Claimable) -Epic Stack can implement preview deployments similar to Vercel's deploy claimable pattern. +Epic Stack can implement preview deployments similar to Vercel's deploy +claimable pattern. **✅ Good - Preview deployments for pull requests:** + ```yaml # .github/workflows/preview-deploy.yml name: Preview Deploy @@ -563,7 +610,7 @@ jobs: # Create or reuse preview app PREVIEW_APP="my-app-pr-${{ github.event.pull_request.number }}" flyctl apps list | grep "$PREVIEW_APP" || flyctl apps create "$PREVIEW_APP" - + # Deploy to preview app flyctl deploy --app "$PREVIEW_APP" --remote-only env: @@ -581,6 +628,7 @@ jobs: ``` **✅ Good - Auto-cleanup preview deployments:** + ```yaml # .github/workflows/cleanup-preview.yml name: Cleanup Preview @@ -605,9 +653,14 @@ jobs: ### Environment Detection **✅ Good - Detect deployment environment:** + ```typescript // app/utils/env.server.ts -export function getDeploymentEnv(): 'production' | 'staging' | 'preview' | 'development' { +export function getDeploymentEnv(): + | 'production' + | 'staging' + | 'preview' + | 'development' { if (process.env.NODE_ENV === 'development') { return 'development' } @@ -628,6 +681,7 @@ export function getDeploymentEnv(): 'production' | 'staging' | 'preview' | 'deve ``` **✅ Good - Environment-specific configuration:** + ```typescript const env = getDeploymentEnv() @@ -636,7 +690,7 @@ export const config = { staging: env === 'staging', preview: env === 'preview', development: env === 'development', - + // Preview deployments might have limited features features: { analytics: env === 'production', @@ -649,6 +703,7 @@ export const config = { ### Build Artifact Exclusion **✅ Good - Optimize Docker builds:** + ```dockerfile # other/Dockerfile # Multi-stage build for smaller image size @@ -687,6 +742,7 @@ CMD ["npm", "start"] ``` **✅ Good - Docker ignore file:** + ```dockerignore # .dockerignore (in other/) node_modules @@ -710,6 +766,7 @@ build ### Deployment Status and Monitoring **✅ Good - Deployment status tracking:** + ```typescript // app/routes/admin/deployment-status.tsx export async function loader({ request }: Route.LoaderArgs) { @@ -728,6 +785,7 @@ export async function loader({ request }: Route.LoaderArgs) { ### Rollback Strategies **✅ Good - Quick rollback with Fly.io:** + ```bash # List recent releases fly releases list --app my-app @@ -737,6 +795,7 @@ fly releases rollback --app my-app ``` **✅ Good - Automated rollback on failure:** + ```toml # fly.toml [experimental] diff --git a/docs/skills/epic-forms/SKILL.md b/docs/skills/epic-forms/SKILL.md index fa9c3d1ab..2457203e0 100644 --- a/docs/skills/epic-forms/SKILL.md +++ b/docs/skills/epic-forms/SKILL.md @@ -13,6 +13,7 @@ categories: ## When to use this skill Use this skill when you need to: + - Create forms in an Epic Stack application - Implement form validation with Zod - Work with Conform for progressively enhanced forms @@ -27,11 +28,18 @@ Use this skill when you need to: Following Epic Web principles: -**Explicit is better than implicit** - Make validation rules clear and explicit using Zod schemas. Every validation rule should be visible in the schema, not hidden in business logic. Error messages should be specific and helpful, telling users exactly what went wrong and how to fix it. +**Explicit is better than implicit** - Make validation rules clear and explicit +using Zod schemas. Every validation rule should be visible in the schema, not +hidden in business logic. Error messages should be specific and helpful, telling +users exactly what went wrong and how to fix it. -**Design to fail fast and early** - Validate input as early as possible, ideally on the client side before submission, and always on the server side. Return clear, specific error messages immediately so users can fix issues without frustration. +**Design to fail fast and early** - Validate input as early as possible, ideally +on the client side before submission, and always on the server side. Return +clear, specific error messages immediately so users can fix issues without +frustration. **Example - Explicit validation:** + ```typescript // ✅ Good - Explicit validation with clear error messages const SignupSchema = z.object({ @@ -55,6 +63,7 @@ const SignupSchema = z.object({ ``` **Example - Fail fast validation:** + ```typescript // ✅ Good - Validate early and return specific errors immediately export async function action({ request }: Route.ActionArgs) { @@ -95,9 +104,11 @@ export async function action({ request }: Route.ActionArgs) { ### Basic setup with Conform -Epic Stack uses [Conform](https://conform.guide/) to handle forms with progressive enhancement. +Epic Stack uses [Conform](https://conform.guide/) to handle forms with +progressive enhancement. **Basic setup:** + ```typescript import { getFormProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' @@ -133,25 +144,29 @@ export default function SignupRoute({ actionData }: Route.ComponentProps) { Conform integrates seamlessly with Zod for validation. **Define schema:** + ```typescript import { z } from 'zod' -const SignupSchema = z.object({ - email: z.string().email('Invalid email'), - password: z.string().min(6, 'Password must be at least 6 characters'), - confirmPassword: z.string(), -}).superRefine(({ confirmPassword, password }, ctx) => { - if (confirmPassword !== password) { - ctx.addIssue({ - path: ['confirmPassword'], - code: 'custom', - message: 'Passwords must match', - }) - } -}) +const SignupSchema = z + .object({ + email: z.string().email('Invalid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string(), + }) + .superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + path: ['confirmPassword'], + code: 'custom', + message: 'Passwords must match', + }) + } + }) ``` **Validation in action (fail fast):** + ```typescript export async function action({ request }: Route.ActionArgs) { const formData = await request.formData() @@ -216,6 +231,7 @@ export async function action({ request }: Route.ActionArgs) { Epic Stack provides pre-built field components: **Basic Field:** + ```typescript import { Field, ErrorList } from '#app/components/forms.tsx' import { getInputProps } from '@conform-to/react' @@ -235,6 +251,7 @@ import { getInputProps } from '@conform-to/react' ``` **TextareaField:** + ```typescript import { TextareaField } from '#app/components/forms.tsx' import { getTextareaProps } from '@conform-to/react' @@ -253,6 +270,7 @@ import { getTextareaProps } from '@conform-to/react' ``` **CheckboxField:** + ```typescript import { CheckboxField } from '#app/components/forms.tsx' import { getInputProps } from '@conform-to/react' @@ -268,6 +286,7 @@ import { getInputProps } from '@conform-to/react' ``` **OTPField:** + ```typescript import { OTPField } from '#app/components/forms.tsx' @@ -287,6 +306,7 @@ import { OTPField } from '#app/components/forms.tsx' ### Error Handling **Display field errors:** + ```typescript