diff --git a/.env.example b/.env.example deleted file mode 100644 index c5daeb2..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -LITEFS_DIR="/litefs/data" -DATABASE_PATH="./prisma/data.db" -DATABASE_URL="file:./data.db?connection_limit=1" -CACHE_DATABASE_PATH="./other/cache.db" -SESSION_SECRET="super-duper-s3cret" -HONEYPOT_SECRET="super-duper-s3cret" -RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" -SENTRY_DSN="your-dsn" - -# this is set to a random value in the Dockerfile -INTERNAL_COMMAND_TOKEN="some-made-up-token" - -# the mocks and some code rely on these two being prefixed with "MOCK_" -# if they aren't then the real github api will be attempted -GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" -GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" -GITHUB_TOKEN="MOCK_GITHUB_TOKEN" -GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" - -# set this to false to prevent search engines from indexing the website -# default to allow indexing for seo safety -ALLOW_INDEXING="true" - -# Tigris Object Storage (S3-compatible) Configuration -AWS_ACCESS_KEY_ID="mock-access-key" -AWS_SECRET_ACCESS_KEY="mock-secret-key" -AWS_REGION="auto" -AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" -BUCKET_NAME="mock-bucket" diff --git a/.env.schema b/.env.schema new file mode 100644 index 0000000..5f242dc --- /dev/null +++ b/.env.schema @@ -0,0 +1,102 @@ +# This env file uses @env-spec - see https://varlock.dev/env-spec for more info +# +# @defaultRequired=true @defaultSensitive=false +# @currentEnv=$NODE_ENV +# @generateTypes(lang=ts, path=types/env-vars.d.ts) +# ---------- + +# @type=enum(development, production, test) +NODE_ENV=development +# @type=enum(development, production, test) +MODE=$NODE_ENV + +# @type=port +PORT=3000 + +LITEFS_DIR="/litefs/data" +DATABASE_PATH="./prisma/data.db" +DATABASE_URL="file:./data.db?connection_limit=1" +CACHE_DATABASE_PATH="./other/cache.db" + +# used to secure sessions +# @sensitive +# @docs(https://stack-staging.epicweb.dev/topic/deployment) +SESSION_SECRET="super-duper-s3cret" + +# encryption seed for honeypot server +# @sensitive +# @docs(https://stack-staging.epicweb.dev/topic/deployment) +HONEYPOT_SECRET="super-duper-s3cret" + +# this is set to a random value in the Dockerfile +# @sensitive +INTERNAL_COMMAND_TOKEN="some-made-up-token" + +# set to false to prevent search engines from indexing the website (defaults to allow) +ALLOW_INDEXING=true + +# enables mocks for external services +MOCKS=forEnv(development, test) + +# will be set to curent commit sha in deployments +# @optional +COMMIT_SHA= + +# API key for Resend (email service) +# @type=string(startsWith=re_) +# @sensitive +# @optional # remove this if using resend +# @docs(https://resend.com/docs/dashboard/api-keys/introduction#what-is-an-api-key) +RESEND_API_KEY= + +# will be set to true when running in CI +CI=false + +# Sentry settings (error tracking) +# note that SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT are optional +# but enable @sentry/react-router integration and release tagging +# --- +# @type=url +# @optional # remove this if using sentry +# @example=https://examplePublicKey@o0.ingest.sentry.io/0 +# @docs(https://docs.sentry.io/concepts/key-terms/dsn-explainer/) +SENTRY_DSN= +# @optional @sensitive +SENTRY_AUTH_TOKEN= +# @required=if($SENTRY_AUTH_TOKEN) +SENTRY_ORG= +# @required=if($SENTRY_AUTH_TOKEN) +SENTRY_PROJECT= + +# GitHub settings +# +# the mocks and some code rely on these being prefixed with "MOCK_" +# if they aren't then the real github api will be attempted +# --- +GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" +# @sensitive +GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" +# @sensitive +GITHUB_TOKEN="MOCK_GITHUB_TOKEN" +# @type=url +GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" + + +# Tigris Object Storage (S3-compatible) Configuration +# --- +AWS_ACCESS_KEY_ID="mock-access-key" +# @sensitive +AWS_SECRET_ACCESS_KEY="mock-secret-key" +AWS_REGION="auto" +# @type=url +AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" +BUCKET_NAME="mock-bucket" + +# Populated by fly.io +# --- +# current fly.io region +# @optional +FLY_REGION= +# app name as set in fly.io +# @optional +FLY_APP_NAME= \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..6b5776f --- /dev/null +++ b/.env.test @@ -0,0 +1,2 @@ +DATABASE_PATH="./tests/prisma/base.db" +DATABASE_URL=file:../tests/prisma/base.db \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49cbbad..1c7d48b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,9 +30,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🏄 Copy test env vars - run: cp .env.example .env - - name: 🛠 Setup Database run: npx prisma migrate deploy && npx prisma generate --sql @@ -57,9 +54,6 @@ jobs: - name: 🏗 Build run: npm run build - - name: 🏄 Copy test env vars - run: cp .env.example .env - - name: 🛠 Setup Database run: npx prisma migrate deploy && npx prisma generate --sql @@ -81,9 +75,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🏄 Copy test env vars - run: cp .env.example .env - - name: 🛠 Setup Database run: npx prisma migrate deploy && npx prisma generate --sql @@ -98,9 +89,6 @@ jobs: - name: ⬇️ Checkout repo uses: actions/checkout@v4 - - name: 🏄 Copy test env vars - run: cp .env.example .env - - name: ⎔ Setup node uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index 2345034..ef74da5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules /build /server-build .env +.env.local +.env.*.local .cache /prisma/data.db @@ -26,3 +28,4 @@ node_modules # generated files /app/components/ui/icons .react-router/ +/types/env-vars.d.ts diff --git a/.prettierignore b/.prettierignore index f022d02..81afeda 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ node_modules /public/build /server-build .env +.env.* /test-results/ /playwright-report/ @@ -11,5 +12,6 @@ node_modules /tests/fixtures/email/*.json /coverage /prisma/migrations +/types/env-vars.d.ts package-lock.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3c0a690..7eb65d6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,7 @@ "prisma.prisma", "qwtel.sqlite-viewer", "yoavbls.pretty-ts-errors", - "github.vscode-github-actions" + "github.vscode-github-actions", + "varlock.env-spec-language" ] } diff --git a/README.md b/README.md index b9d163e..432bd75 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,35 @@ -
-

The Epic Stack 🚀

- - Ditch analysis paralysis and start shipping Epic Web apps. - -

- This is an opinionated project starter and reference that allows teams to - ship their ideas to production faster and on a more stable foundation based - on the experience of Kent C. Dodds and - contributors. -

-
+# Epic Stack + Varlock -```sh -npx epicli -``` +An example repo using [Varlock](https://varlock.dev/) within the Epic Stack to help manage configuration and secrets. -[![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack) +With Varlock, we convert the `.env.example` file into a `.env.schema` which contains additional schema information about all configuration in the system. This will improve developer onboarding into the epic stack, as well as ongoing DX as devs add more config into their apps. It adds additional guardrails around configuration in general, and notably adds additional protection for sensitive secrets. -[The Epic Stack](https://www.epicweb.dev/epic-stack) +## Why do this? +- validations, default values, and documentation are all now in one source of truth (`.env.schema`) +- no more duplication between `.env.example` and `.env`, which means it will never get out of sync +- only overrides must be added by user +- clear env validation, decoupled from the application booting +- improved TS types / IntelliSense +- allows more flexible validation and composition of values based on other items +- easy to now pull secrets from secure backends like 1pass, etc +- leak prevention! log redaction! +- clear error messages when accessing bad env vars, or using them in wrong place -
+## Screenshots -## Watch Kent's Introduction to The Epic Stack +Some screenshots of varlock in action: -[![Epic Stack Talk slide showing Flynn Rider with knives, the text "I've been around and I've got opinions" and Kent speaking in the corner](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/277818553-47158e68-4efc-43ae-a477-9d1670d4217d.png)](https://www.epicweb.dev/talks/the-epic-stack) +_`varlock load` showing loaded and validated env_ +image -["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack) +_Improved IntelliSense_ +image -## Docs +_Leak detection example_ +image -[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs) -(please 🙏). +_Log redaction example_ +image -## Support - -- 🆘 Join the - [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions) - and the [KCD Community on Discord](https://kcd.im/discord). -- 💡 Create an - [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas) - for suggestions. -- 🐛 Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to - report a bug. - -## Branding - -Want to talk about the Epic Stack in a blog post or talk? Great! Here are some -assets you can use in your material: -[EpicWeb.dev/brand](https://epicweb.dev/brand) - -## Thanks - -You rock 🪨 +_Example of failing env validation_ +image diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 9b7749f..6998451 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,6 +1,7 @@ import { startTransition } from 'react' import { hydrateRoot } from 'react-dom/client' import { HydratedRouter } from 'react-router/dom' +import { ENV } from 'varlock/env' if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { void import('./utils/monitoring.client.tsx').then(({ init }) => init()) diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 99fdd4b..f1c7cbd 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -12,17 +12,13 @@ import { type ActionFunctionArgs, type HandleDocumentRequestFunction, } from 'react-router' -import { getEnv, init } from './utils/env.server.ts' +import { ENV } from 'varlock/env' import { getInstanceInfo } from './utils/litefs.server.ts' import { NonceProvider } from './utils/nonce-provider.ts' import { makeTimings } from './utils/timing.server.ts' export const streamTimeout = 5000 -init() -global.ENV = getEnv() - -const MODE = process.env.NODE_ENV ?? 'development' type DocRequestArgs = Parameters @@ -30,12 +26,12 @@ export default async function handleRequest(...args: DocRequestArgs) { const [request, responseStatusCode, responseHeaders, reactRouterContext] = args const { currentInstance, primaryInstance } = await getInstanceInfo() - responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown') - responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') + responseHeaders.set('fly-region', ENV.FLY_REGION ?? 'unknown') + responseHeaders.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown') responseHeaders.set('fly-primary-instance', primaryInstance) responseHeaders.set('fly-instance', currentInstance) - if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { + if (ENV.NODE_ENV === 'production' && ENV.SENTRY_DSN) { responseHeaders.append('Document-Policy', 'js-profiling') } @@ -72,8 +68,8 @@ export default async function handleRequest(...args: DocRequestArgs) { directives: { fetch: { 'connect-src': [ - MODE === 'development' ? 'ws:' : undefined, - process.env.SENTRY_DSN ? '*.sentry.io' : undefined, + ENV.MODE === 'development' ? 'ws:' : undefined, + ENV.SENTRY_DSN ? '*.sentry.io' : undefined, "'self'", ], 'font-src': ["'self'"], @@ -114,8 +110,8 @@ export default async function handleRequest(...args: DocRequestArgs) { export async function handleDataRequest(response: Response) { const { currentInstance, primaryInstance } = await getInstanceInfo() - response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown') - response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') + response.headers.set('fly-region', ENV.FLY_REGION ?? 'unknown') + response.headers.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown') response.headers.set('fly-primary-instance', primaryInstance) response.headers.set('fly-instance', currentInstance) diff --git a/app/root.tsx b/app/root.tsx index 3b19435..93038c1 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,6 +11,7 @@ import { useMatches, } from 'react-router' import { HoneypotProvider } from 'remix-utils/honeypot/react' +import { ENV } from 'varlock/env' import { type Route } from './+types/root.ts' import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png' import faviconAssetUrl from './assets/favicons/favicon.svg' @@ -31,7 +32,6 @@ import tailwindStyleSheetUrl from './styles/tailwind.css?url' import { getUserId, logout } from './utils/auth.server.ts' import { ClientHintCheck, getHints } from './utils/client-hints.tsx' import { prisma } from './utils/db.server.ts' -import { getEnv } from './utils/env.server.ts' import { pipeHeaders } from './utils/headers.server.ts' import { honeypot } from './utils/honeypot.server.ts' import { combineHeaders, getDomainUrl, getImgSrc } from './utils/misc.tsx' @@ -119,7 +119,6 @@ export async function loader({ request }: Route.LoaderArgs) { theme: getTheme(request), }, }, - ENV: getEnv(), toast, honeyProps, }, @@ -138,14 +137,11 @@ function Document({ children, nonce, theme = 'light', - env = {}, }: { children: React.ReactNode nonce: string theme?: Theme - env?: Record }) { - const allowIndexing = ENV.ALLOW_INDEXING !== 'false' return ( @@ -153,19 +149,13 @@ function Document({ - {allowIndexing ? null : ( + {ENV.ALLOW_INDEXING ? null : ( )} {children} -