diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2d4454..17031977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,12 @@ jobs: build: runs-on: ubuntu-24.04 + env: + # Secrets for the frontend build (envsafe validation runs under NODE_ENV=production). + # Locally these come from scripts/with-secrets.sh + 1Password; in CI we use + # GitHub Actions secrets directly and the wrapper's `none` fallback passes them through. + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + TEST_SECRET: ${{ secrets.TEST_SECRET }} steps: - name: ๐Ÿ“ฅ Checkout Repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -47,5 +53,9 @@ jobs: uses: ./.github/actions/node-setup - name: ๐Ÿ—๏ธ Build packages + # Secrets for the frontend's envsafe validation come from the job env above + # (workflow-level GitHub Actions secrets). `pnpm build` invokes the wrapper, + # which in CI auto-detects no provider CLI is present and falls through to + # plain exec โ€” secrets are already in process.env. run: pnpm build shell: bash diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a62be287..2dad2eb6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,11 @@ env: NODE_ENV: test CI: true E2E_BASE_URL: http://localhost:3000 + # Secrets used by the frontend (envsafe) at build + start. Locally these come from + # scripts/with-secrets.sh + 1Password; in CI we use GitHub Actions secrets directly + # and the wrapper's `none` fallback passes them through. + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + TEST_SECRET: ${{ secrets.TEST_SECRET }} jobs: e2e: @@ -39,6 +44,10 @@ jobs: uses: ./.github/actions/node-setup - name: ๐Ÿ—๏ธ Build apps + # Secrets for the frontend's envsafe validation come from the workflow env + # above. `pnpm build` invokes the wrapper, which in CI auto-detects no + # provider CLI is present and falls through to plain exec โ€” secrets are + # already in process.env. run: pnpm build shell: bash diff --git a/.prettierignore b/.prettierignore index d46897d0..3c9d81e2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,9 @@ pnpm-lock.yaml *.md Dockerfile +# Config files prettier can't parse natively +*.toml + # Build outputs .next/ dist/ diff --git a/README.md b/README.md index f16d16b3..3834f912 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ A modern React monorepo showcasing Next.js 15, TypeScript, and best practices fo ### Prerequisites -- **Node.js**: Version specified in `package.json` -- **pnpm**: Version specified in `package.json` -- **Corepack**: Run `corepack enable` to manage package manager versions automatically +- **[mise](https://mise.jdx.dev/)**: Required โ€” installs and pins `node` and `pnpm` with checksum-verified binaries. Install with `brew install mise` on macOS (see the [Tool Management guide](documentation/Tool%20management.md) for other platforms and the full rationale). - **Docker**: For containerized development (optional but recommended) +- **1Password CLI**: For injecting secrets when running locally (see [Environment secrets](#environment-secrets) below) + +`node` and `pnpm` are managed by `mise` โ€” you do **not** install them separately. This is part of the project's [supply-chain security posture](https://infinum.com/handbook/frontend/node/security/overview) *(internal handbook)*; see [Tool Management](documentation/Tool%20management.md) for details. ### One-Command Setup @@ -17,9 +18,12 @@ A modern React monorepo showcasing Next.js 15, TypeScript, and best practices fo # Clone, install, and start development git clone cd JS-React-Example -pnpm install && pnpm dev +mise trust && mise install && pnpm install +pnpm dev ``` +`mise trust` approves this repo's `mise.toml`, `mise install` downloads and checksum-verifies `node` and `pnpm` against [mise.lock](mise.lock), and `pnpm dev` starts the dev server with secrets injected by [scripts/with-secrets.sh](scripts/with-secrets.sh) from 1Password. See [Tool Management](documentation/Tool%20management.md) for the full setup and [Environment Variables](documentation/Environment%20variables.md) for the secrets pipeline. + **Access the applications:** - **Frontend**: http://localhost:3000 @@ -129,17 +133,46 @@ API patterns and development guide: [API Development Guide](documentation/API%20 ### Key Variables -- **`NEXTAUTH_SECRET`**: Authentication secret (auto-generated in dev) +- **`NEXTAUTH_SECRET`**: Authentication secret โ€” injected at runtime via [scripts/with-secrets.sh](scripts/with-secrets.sh) + 1Password (see **Environment secrets** below) - **`NEXTAUTH_URL`**: Application URL for OAuth callbacks - **OAuth providers**: `GOOGLE_CLIENT_ID`, `GITHUB_CLIENT_ID`, etc. ### Environment Files -- **`.env.local`**: Local development overrides -- **`.env.compose`**: Docker Compose environment +- **`.env.local`**: Local development overrides (non-secret) +- **`.env.compose`**: Docker Compose environment (non-secret) **Complete setup**: [Environment Variables Guide](documentation/Environment%20variables.md) +**Adding a new env variable:** + +- **Non-secret** (feature flag, public URL, port, etc.) โ€” see [Scenario A](documentation/Environment%20variables.md#scenario-a-non-secret-variable). Edit `.env` + validator; usually two files. +- **Secret** (API key, auth secret, DB password, etc.) โ€” see [Scenario B](documentation/Environment%20variables.md#scenario-b-secret-used-only-at-runtime) for runtime-only secrets, [Scenario C](documentation/Environment%20variables.md#scenario-c-secret-needed-at-build-time-too) if envsafe validates it during `next build`. Both include vault setup, the `.env.secret` manifest, compose forwarding, the server validator, and CI wiring. + +The canonical reference with every touch point per scenario plus a quick-scan matrix is [Adding a new environment variable](documentation/Environment%20variables.md#adding-a-new-environment-variable). + +### Environment secrets + +The goal is to keep secrets out of the filesystem entirely โ€” nothing resolved on disk, nothing leaked to unrelated shell commands, only available to the processes that need them. To achieve this, a small wrapper script โ€” [scripts/with-secrets.sh](scripts/with-secrets.sh) โ€” injects secrets into the process environment at task-run time by delegating to the configured secret-store CLI. This is part of the project's [supply-chain security posture](https://infinum.com/handbook/frontend/node/security/overview) *(internal handbook)* โ€” see the [Tool Management guide](documentation/Tool%20management.md) for the broader rationale. + +**Reference setup: 1Password CLI** + +This project uses the 1Password CLI (`op`). Each app declares its secret references in [apps/frontend/.env.secret](apps/frontend/.env.secret) using `op://vault/item/field` syntax โ€” the file is committed because it contains *pointers*, not values. When `pnpm dev` runs, the wrapper invokes `op run --env-file=.env.secret -- next dev` and `op` resolves each reference against your authenticated 1Password session. + +To set up your local environment: + +1. Install the CLI: `brew install 1password-cli` +2. In the 1Password app, open **Settings โ†’ Developer** and enable **Integrate with 1Password CLI**. +3. Ensure your account has access to the vault where the project secrets are stored. + +On first `pnpm dev`, 1Password prompts for authorization. After that, secrets are injected transparently โ€” the example app renders "Hello Infinum!" on the login page when `TEST_SECRET` is available. + +**Other backends** + +1Password is the reference for this project, but the wrapper is the single integration point. Any other secret store with a `run`-style CLI (Doppler, Infisical, HashiCorp Vault, AWS Secrets Manager via `chamber`, Bitwarden, etc.) can be wired in by appending a `case` arm to [scripts/with-secrets.sh](scripts/with-secrets.sh) and using a corresponding manifest filename. Nothing else in the repo names a provider. + +**Full details**: [Environment Variables Guide โ€” Secrets](documentation/Environment%20variables.md#secrets) + ## Development Workflow ### Daily Development @@ -232,6 +265,7 @@ New UI components should: ### Available Guides - [Monorepo Structure](documentation/Monorepo%20Structure.md) - Project organization and architecture +- [Tool Management (mise)](documentation/Tool%20management.md) - Why mise, how to install it, how tool pinning works - [Development Workflow](documentation/Development%20Workflow%20Guide.md) - Git workflow, code review, releases - [UI Components](documentation/UI%20Components%20Guide.md) - ShadCN component generation and customization - [Semantic Tokens](documentation/Semantic%20Tokens%20Guide.md) - Design tokens and theming system @@ -239,6 +273,10 @@ New UI components should: - [Environment Variables](documentation/Environment%20variables.md) - Complete environment setup - [Docker Setup](documentation/Docker%20setup.md) - Containerized development and deployment +### External references + +- [Infinum handbook โ€” Node.js Security](https://infinum.com/handbook/frontend/node/security/overview) *(internal)* - The security rationale behind `mise`, pnpm hardening, and AI-assisted development guardrails used in this repo. + ### Architecture Decisions Technical decisions are documented in [Architecture Decision Records](documentation/decision-record/) following the ADR format for traceability and team alignment. diff --git a/apps/frontend-e2e/playwright.config.ts b/apps/frontend-e2e/playwright.config.ts index 8fab27bc..e42445a6 100644 --- a/apps/frontend-e2e/playwright.config.ts +++ b/apps/frontend-e2e/playwright.config.ts @@ -15,6 +15,11 @@ export default defineConfig({ ], webServer: process.env.CI ? { + // The frontend's `start` script wraps `next start` with + // scripts/with-secrets.sh. In CI the wrapper auto-detects no provider + // CLI is present and falls through to plain exec โ€” secrets are already + // in the job env (set from GitHub Actions secrets in + // .github/workflows/e2e.yml). command: 'pnpm --filter @infinum/frontend start', port: 3000, reuseExistingServer: !process.env.CI, diff --git a/apps/frontend/.env b/apps/frontend/.env index ea56f222..bc3390fe 100644 --- a/apps/frontend/.env +++ b/apps/frontend/.env @@ -1,5 +1,5 @@ # NextAuth config -NEXTAUTH_SECRET="aVlrr1mODOkPGrXTt6vN515S8Cry5fnpX5pYFoM/bws=" +# NEXTAUTH_SECRET handled through .env.secret + scripts/with-secrets.sh (see documentation/Environment variables.md) NEXTAUTH_URL="http://localhost:3000" # API @@ -7,3 +7,5 @@ API_BASE_URL="http://localhost:3000/api" # Common NODE_ENV="development" + +# TEST_SECRET handled through .env.secret + scripts/with-secrets.sh (see documentation/Environment variables.md) diff --git a/apps/frontend/.env.compose b/apps/frontend/.env.compose index 90264053..977b74bf 100644 --- a/apps/frontend/.env.compose +++ b/apps/frontend/.env.compose @@ -1,5 +1,8 @@ # NextAuth config -NEXTAUTH_SECRET="aVlrr1mODOkPGrXTt6vN515S8Cry5fnpX5pYFoM/bws=" +# NEXTAUTH_SECRET forwarded from the host process by docker compose's `environment:` block. +# Locally: pnpm docker:prod resolves it via scripts/with-secrets.sh + .env.secret. +# In CI: the pipeline's secret provider exports it before compose runs. +# See documentation/Environment variables.md. NEXTAUTH_URL="http://localhost:3000" # API diff --git a/apps/frontend/.env.secret b/apps/frontend/.env.secret new file mode 100644 index 00000000..20dc37a0 --- /dev/null +++ b/apps/frontend/.env.secret @@ -0,0 +1,12 @@ +# Secret references for this app. Resolved at task-run time by +# scripts/with-secrets.sh (which delegates to the configured provider CLI โ€” +# 1Password by default, see documentation/Environment variables.md). +# +# The values here are POINTERS, not secrets. Reading this file does nothing +# without an authenticated session to the underlying secret store. +# +# Format: KEY= +# 1Password: op://// + +NEXTAUTH_SECRET=op://JS general/ReactExample/NextAuthSecret +TEST_SECRET=op://JS general/ReactExample/TestSecret diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index 6d01bc32..36b1b9ab 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -5,3 +5,6 @@ src/lib/i18n/locales/en/*.d.json.ts .env* !.env !.env.compose +# Provider-reference manifest for scripts/with-secrets.sh โ€” pointers only, +# never resolved values. See documentation/Environment variables.md. +!.env.secret diff --git a/apps/frontend/.prettierignore b/apps/frontend/.prettierignore index 270a3cd2..1a6d5469 100644 --- a/apps/frontend/.prettierignore +++ b/apps/frontend/.prettierignore @@ -8,5 +8,8 @@ node_modules/ # Next specific next-env.d.ts +# Config files prettier can't parse natively +*.toml + # Other Dockerfile diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 15241adf..364e788e 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,8 +1,11 @@ -FROM node:24.11.0-alpine AS base +FROM node:24.15.0-alpine AS base WORKDIR /app ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +# bash is needed by the wrapper shims under ./scripts (with-mise.sh, +# with-secrets.sh) that root + per-app pnpm scripts route through. +# Alpine ships only busybox sh, which lacks the bashisms ([[ ]], arrays). +RUN apk add --no-cache bash && corepack enable # # # TURBO PRUNE FROM base AS turbo @@ -21,6 +24,12 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile FROM dependencies AS development COPY --from=turbo /app/out/full ./ +# Root `pnpm build` and the app's own `pnpm dev`/`pnpm build` route through +# ./scripts/with-mise.sh and ./scripts/with-secrets.sh respectively; +# scripts/ isn't part of `turbo prune --docker` output, so copy it +# explicitly. Both wrappers fall through to plain `exec` inside the +# container (no mise/op CLI to delegate to). +COPY scripts ./scripts EXPOSE 3000 @@ -29,6 +38,17 @@ CMD ["pnpm", "dev"] # # # Builder FROM development AS builder +# Secrets needed for `next build` (envsafe validates them during static +# rendering). Passed in via docker-compose `build.args` from the host's +# env, which is populated by scripts/with-secrets.sh resolving op:// refs +# (locally) or the pipeline's env: block (CI). These ENV declarations are +# scoped to this stage โ€” the production stage below restarts FROM base, so +# the values do not propagate into the final image. +ARG NEXTAUTH_SECRET +ARG TEST_SECRET +ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET +ENV TEST_SECRET=$TEST_SECRET + RUN pnpm build # # # Production diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e878c8df..0cb4d390 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,16 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "build": "next build", + "build": "../../scripts/with-secrets.sh . -- next build", "build:analyze": "ANALYZE=true pnpm build", "check-licenses": "node ../../scripts/check-licenses-workspace.js", "clean": "rm -rf node_modules .turbo .next .eslintcache", - "dev": "NODE_OPTIONS='--inspect' next dev", + "dev": "NODE_OPTIONS='--inspect' ../../scripts/with-secrets.sh . -- next dev", "lint": "eslint . --cache", "lint:fix": "eslint . --cache --fix", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", - "start": "next start", + "start": "../../scripts/with-secrets.sh . -- next start", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", diff --git a/apps/frontend/src/app/[locale]/(public)/login/page.tsx b/apps/frontend/src/app/[locale]/(public)/login/page.tsx index abaa800c..ae5a9917 100644 --- a/apps/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/apps/frontend/src/app/[locale]/(public)/login/page.tsx @@ -1,10 +1,15 @@ +import { secretEnv } from '../../../../lib/env/validate-env.server'; import { AuthCard } from '../_components/AuthCard/AuthCard'; import { LoginForm } from '../_components/LoginForm/LoginForm'; export default function LoginPage() { + const serverVars = secretEnv(); + const secret = serverVars.TEST_SECRET; + return ( + {secret} ); } diff --git a/apps/frontend/src/lib/env/validate-env.server.ts b/apps/frontend/src/lib/env/validate-env.server.ts index 2070eab2..d4ea11e8 100644 --- a/apps/frontend/src/lib/env/validate-env.server.ts +++ b/apps/frontend/src/lib/env/validate-env.server.ts @@ -20,4 +20,9 @@ export const secretEnv = () => desc: 'A secure random string used by NextAuth for signing and encrypting JWT tokens and cookies. Required in production, especially when using JWT sessions.', docs: 'https://next-auth.js.org/configuration/options#nextauth_secret', }), + TEST_SECRET: str({ + input: process.env.TEST_SECRET, + devDefault: 'Missing injected variable', + desc: 'Used to test if the mise env variables are injected correctly.', + }), }); diff --git a/apps/storybook/Dockerfile b/apps/storybook/Dockerfile index e8ffde2f..8756b819 100644 --- a/apps/storybook/Dockerfile +++ b/apps/storybook/Dockerfile @@ -1,8 +1,11 @@ -FROM node:24.11.0-alpine AS base +FROM node:24.15.0-alpine AS base WORKDIR /app ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +# bash is needed by the wrapper shims under ./scripts (with-mise.sh, +# with-secrets.sh) that root + per-app pnpm scripts route through. +# Alpine ships only busybox sh, which lacks the bashisms ([[ ]], arrays). +RUN apk add --no-cache bash && corepack enable # # # TURBO PRUNE FROM base AS turbo @@ -22,6 +25,10 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile FROM dependencies AS builder COPY --from=turbo /app/out/full ./ +# Root `pnpm build` runs ./scripts/with-mise.sh; scripts/ isn't part of +# `turbo prune --docker` output, so copy it explicitly. The wrapper falls +# through to plain `exec` inside the container (no mise to delegate to). +COPY scripts ./scripts RUN pnpm build diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a3bfa93d..873af8bb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,10 +9,24 @@ services: context: .. dockerfile: ./apps/frontend/Dockerfile target: production + # Build-time secrets for envsafe validation during `next build`. + # Compose interpolates ${NAME} from the parent process's env โ€” which + # the scripts/with-secrets.sh chain has already populated (locally + # via op, in CI via the pipeline's secret provider). Values flow: + # host env โ†’ compose interpolation โ†’ Dockerfile ARG โ†’ ENV in builder + # stage only (production stage starts FROM base, see Dockerfile). + args: + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + TEST_SECRET: ${TEST_SECRET} ports: - '3000:3000' environment: - HOSTNAME=0.0.0.0 + # Secrets forwarded from the host process. These have no value here on purpose: + # compose passes through whatever is in the parent environment (populated locally + # by mise, or by the pipeline's secret provider in CI). Never inline real values. + - NEXTAUTH_SECRET + - TEST_SECRET env_file: - ../apps/frontend/.env - ../apps/frontend/.env.compose diff --git a/documentation/Docker setup.md b/documentation/Docker setup.md index f1a7bef5..37e0b3b6 100644 --- a/documentation/Docker setup.md +++ b/documentation/Docker setup.md @@ -33,7 +33,7 @@ All Dockerfiles follow a consistent multi-stage build pattern: ```dockerfile # Base stage - Common Node.js setup -FROM node:24.11.0-alpine AS base +FROM node:24.15.0-alpine AS base # Turbo prune stage - Monorepo optimization FROM base AS turbo @@ -70,7 +70,7 @@ This creates a minimal subset of the monorepo containing only the files needed f ### App-Level Environment Files -Each application maintains its environment variables within its own directory: +Each application maintains its (non-secret) environment variables within its own directory: ``` apps/ @@ -89,19 +89,87 @@ apps/ 3. **Clarity** - Environment variables are co-located with the application that uses them 4. **Flexibility** - Different environments (local vs Docker) can have different configurations +Only non-secret values live in these files. Real secrets are injected at task-run time by [scripts/with-secrets.sh](../scripts/with-secrets.sh) from a secret store (1Password by default; see the [Environment Variables guide](./Environment%20variables.md#secrets)) and forwarded into the container at runtime, not read from any committed or gitignored env file. + ### Docker Compose Integration -The `docker-compose.yml` references each application's environment file: +The `docker-compose.yml` references each application's environment file for non-secret defaults, forwards runtime secrets from the wrapped host process via `environment:` name-only entries, and passes the same secrets as `build.args` so `next build` can validate them via envsafe during static rendering: ```yaml services: frontend: + build: + context: .. + dockerfile: ./apps/frontend/Dockerfile + target: production + args: + # Build-time: compose interpolates ${NAME} from the host's env. + # The scripts/with-secrets.sh chain has already populated it. + # Declared as ARG in the Dockerfile's builder stage; ENV-promoted + # only inside that stage so `next build` can read it. The + # production stage restarts FROM base, so the value does not + # propagate into the final image. + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + TEST_SECRET: ${TEST_SECRET} env_file: + - ../apps/frontend/.env - ../apps/frontend/.env.compose + environment: + # Runtime: forwarded from the host process. Locally these are + # populated by scripts/with-secrets.sh resolving + # apps/frontend/.env.secret refs; in CI they come from the + # pipeline's secret provider. No value here โ€” compose passes + # whatever is in the parent environment. + - NEXTAUTH_SECRET storybook: - # No env_file needed for storybook in this example + # No env_file or secrets needed for storybook in this example +``` + +### Why both `build.args` and `environment:` + +`build.args` are baked into the *build* of the image (consumed by `next build` for envsafe validation during static rendering). They are scoped to the builder stage only โ€” the production stage restarts `FROM base`, so the values never reach the final image's layers. `environment:` forwards the same values into the *running container* so the Next.js server (and its instrumentation hook) can see them at startup. Both are needed because the build container and the runtime container are distinct processes with separate env namespaces; both must carry the secret. + +### Running compose + +The root-level `pnpm docker:prod` script invokes [scripts/compose.sh](../scripts/compose.sh) โ€” a generic driver that wraps `docker compose` with one [scripts/with-secrets.sh](../scripts/with-secrets.sh) layer per app under `apps/`. Each layer injects that app's secrets (resolved by the configured provider CLI) into the parent process; compose's per-service `environment:` blocks then forward only what each container asks for. + +```sh +pnpm docker:prod -- up -d # builds and starts the prod stack +pnpm docker:prod -- up frontend # brings up just one service +pnpm docker:prod -- logs -f # tails logs +pnpm docker:prod -- down # tears it down +``` + +Under the hood, the driver builds a chain like: + +``` +pnpm docker:prod -- up -d + โ†’ ./scripts/compose.sh up -d + โ†’ with-secrets.sh apps/storybook -- + โ†’ with-secrets.sh apps/frontend-e2e -- + โ†’ with-secrets.sh apps/frontend -- + โ†’ op run --env-file=apps/frontend/.env.secret -- + โ†’ docker compose -f docker/docker-compose.yml up -d ``` +Apps without a `.env.secret` (or other supported manifest) fall through to plain `exec` inside their `with-secrets.sh` layer โ€” wrapping them is one cheap shell hop, no provider work. Apps with a manifest but no matching CLI on PATH produce a warning to stderr so the misconfiguration is visible at the call site. + +### Adding a new app's secrets + +The driver discovers apps by globbing `apps/*/`, so adding a second app with its own vault is a zero-edit operation at the root โ€” the next `pnpm docker:prod` run wraps the new manifest automatically. No changes to root `package.json`, `compose.sh`, or any other app's config. + +For the per-secret touch points (manifest, compose forwarding, build-args, Dockerfile `ARG`/`ENV`, CI workflow `env:` blocks, etc.) see the canonical checklist: [Environment Variables โ€” Adding a new environment variable](./Environment%20variables.md#adding-a-new-environment-variable). + +### Multi-app secrets: one chain, per-service forwarding + +When multiple apps each declare their own secrets, the driver layers them so the host process has all secrets in env by the time `docker compose up` runs. **Compose's `environment:` block is what enforces isolation between services** โ€” each service forwards only the names it lists. So even though the host process briefly holds both frontend's and backend's secrets, the frontend container only sees `NEXTAUTH_SECRET`, the backend container only sees `DATABASE_PASSWORD`, and storybook sees nothing. + +A subtle footgun worth knowing about: **don't share secret *names* between apps.** If `apps/frontend/.env.secret` and `apps/backend/.env.secret` both declare `API_KEY`, the layer that runs last wins for that name in the host process โ€” and both services' `environment: - API_KEY` blocks will receive the same value. Use app-scoped names (`FRONTEND_API_KEY`, `BACKEND_API_KEY`) so each app's secret namespace stays disjoint. + +### CI + +The wrapper auto-detects that no provider CLI is present and falls through to plain `exec`. Secrets come from the pipeline's secret provider (e.g. GitHub Actions `secrets`) exported to the job's `env:` block, and compose forwards them to the container the same way. The compose file itself does not need to change between local and CI use. + ## Application Independence Principle ### Why Apps Should Work Independently @@ -164,16 +232,18 @@ services: ### Production Script -The root `package.json` includes a convenient Docker script: +The root `package.json` exposes the compose driver as `docker:prod`: ```json { "scripts": { - "docker:prod": "docker compose -f ./docker/docker-compose.yml" + "docker:prod": "./scripts/compose.sh" } } ``` +The driver ([scripts/compose.sh](../scripts/compose.sh)) wraps `docker compose -f docker/docker-compose.yml` with one [scripts/with-secrets.sh](../scripts/with-secrets.sh) layer per app under `apps/` โ€” see [Running compose](#running-compose) for the full call chain. + ### Flexible Usage Patterns This script can be used in multiple ways: @@ -257,14 +327,16 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install When adding a new application to the monorepo: -1. **Create Dockerfile** in the app directory following the established pattern -2. **Add environment files** (`.env.local` and `.env.compose`) -3. **Update docker-compose.yml** with the new service -4. **Test independence** - ensure the app works without monorepo dependencies +1. **Create Dockerfile** in the app directory following the established pattern. +2. **Add non-secret environment files** (`.env`, `.env.compose`) with defaults only. +3. **Add secrets** per the canonical checklist in [Environment Variables โ€” Adding a new environment variable](./Environment%20variables.md#adding-a-new-environment-variable). Touch points include `.env.secret`, the compose service's `environment:` and `build.args:` blocks, the Dockerfile builder stage's `ARG`/`ENV` pairs, the validator, and the CI workflow `env:` blocks. +4. **Update docker-compose.yml** with the new service. +5. **Test independence** - ensure the app works without monorepo dependencies. ### Security Considerations -- **Environment variables** are properly isolated per application +- **Non-secret env files** are isolated per application and contain no sensitive values +- **Secrets** never touch disk โ€” fetched on demand via [scripts/with-secrets.sh](../scripts/with-secrets.sh) + secret-store CLI (local) or injected by the pipeline (CI) - **Build context** is minimized using Turbo prune - **Production images** don't include development dependencies - **Base images** are regularly updated for security patches diff --git a/documentation/E2E Testing.md b/documentation/E2E Testing.md index 41a26342..bd62568c 100644 --- a/documentation/E2E Testing.md +++ b/documentation/E2E Testing.md @@ -1,5 +1,14 @@ # E2E Testing (Playwright) +## Prerequisites + +The frontend under test reads secrets (e.g. `NEXTAUTH_SECRET`, `TEST_SECRET`) that are injected at runtime rather than stored in env files. Before running E2E locally you need either: + +- The 1Password CLI installed and authenticated so `pnpm dev` (via [scripts/with-secrets.sh](../scripts/with-secrets.sh)) can fetch secrets from the project vault (reference setup โ€” see [Environment Variables โ€” Secrets](./Environment%20variables.md#secrets)), **or** +- Equivalent local access to whatever secret-store backend your project uses (add the relevant case to the wrapper script). + +In CI, secrets come from the pipeline's secret provider (GitHub Actions `secrets` in this repo) and are exported into the job's `env:` block โ€” no local CLI is needed on the runner. + ## How to run - All E2E (root script): `pnpm e2e` diff --git a/documentation/Environment variables.md b/documentation/Environment variables.md index 52d41ed7..6f83aca3 100644 --- a/documentation/Environment variables.md +++ b/documentation/Environment variables.md @@ -2,12 +2,13 @@ > **TL;DR Cheat-Sheet** > -> 1. **Committed templates** — `.env` and `.env.compose` are tracked in git with non-secret defaults. -> 1. **Developer overrides** — All `*.local` files are ignored and never committed. -> 1. **Two validators** — `validate-env.client.ts` for public vars, `validate-env.server.ts` for secrets. -> 1. **Type-safe access** — Use `getPublicEnv()` for public vars, `secretEnv()` for secrets. -> 1. Variables in `validate-env.client.ts` are exposed to the browser (no `NEXT_PUBLIC_` prefix needed). -> 1. Never put secrets in `validate-env.client.ts` - only in `validate-env.server.ts`. +> 1. **Committed templates** โ€” `.env` and `.env.compose` are tracked in git with non-secret defaults. +> 2. **Developer overrides** โ€” All `*.local` files are ignored and never committed. Use them for personal non-secret tweaks (ports, feature flags), not real secrets. +> 3. **Secrets** โ€” Never written to disk. Injected at task-run time by [scripts/with-secrets.sh](../scripts/with-secrets.sh) from the configured secret-store CLI (1Password by default โ€” see [Secrets](#secrets)). +> 4. **Two validators** โ€” `validate-env.client.ts` for public vars, `validate-env.server.ts` for secrets. +> 5. **Type-safe access** โ€” Use `getPublicEnv()` for public vars, `secretEnv()` for secrets. +> 6. Variables in `validate-env.client.ts` are exposed to the browser (no `NEXT_PUBLIC_` prefix needed). +> 7. Never put secrets in `validate-env.client.ts` - only in `validate-env.server.ts`. > > **!! WARNING !!** > @@ -17,15 +18,17 @@ ## Overview -The monorepo uses a repeatable pattern for environment variables that works both on the host machine and inside Docker containers. Each app maintains committed template files with non-secret defaults, while developers add their own secrets and overrides in local files that are never committed. +The monorepo uses a repeatable pattern for environment variables that works on the host machine, inside Docker containers, and in CI. Each app maintains committed template files with non-secret defaults; developers add personal non-secret overrides in local files that are never committed; and true secrets live outside the filesystem entirely, injected into the process environment at task-run time (see [Secrets](#secrets)). ### High-Level Goals Each application has: -- **`.env`** — Committed, non-secret host defaults for running the app on your local machine. -- **`.env.compose`** — Committed, non-secret Docker defaults for running the app in containers. -- **`*.local` files** — Developer-specific overrides and secrets, never committed to git. +- **`.env`** โ€” Committed, non-secret host defaults for running the app on your local machine. +- **`.env.compose`** โ€” Committed, non-secret Docker defaults for running the app in containers. +- **`.env.secret`** โ€” Committed, but contains only **references** (e.g. `op://vault/item/field`), not resolved values. Read at task-run time by [scripts/with-secrets.sh](../scripts/with-secrets.sh). +- **`*.local` files** โ€” Developer-specific non-secret overrides, never committed to git. +- **Secrets** โ€” Not in any committed file as values. Injected at task-run time by the wrapper script from a secret-store CLI (1Password by default) locally, and from the pipeline's secret provider (e.g. GitHub Actions secrets) in CI. We use: @@ -36,7 +39,8 @@ We use: Key goals: - **No "works on my machine" bugs** - host and container values are isolated. -- **No secrets in git** - templates provide defaults; developers add secrets locally. +- **No secret values in git** - templates carry non-secret defaults; `.env.secret` carries references that require vault auth to resolve. +- **No secrets on disk** - real secret values are fetched on demand and live only in the process environment that needs them. - **Early failure** - missing or malformed variables abort the start-up sequence. - **Type safety** - the `envsafe()` helper supplies autocomplete and correct types. @@ -49,11 +53,12 @@ The apps should use the following `.gitignore` rules for environment files: .env* !.env !.env.compose +!.env.secret ``` ### What This Means -**Only `.env` and `.env.compose` are tracked in git.** +**Only `.env`, `.env.compose`, and `.env.secret` are tracked in git.** All other `.env*` files are ignored, including: @@ -62,26 +67,28 @@ All other `.env*` files are ignored, including: - `.env.production.local` - `.env.test.local` - `.env.compose.local` +- `.env.secret.local` (if anyone uses `op inject` to materialize resolved values locally) ### Important Rules -1. **Never put secrets in `.env` or `.env.compose`** — these files are committed and visible to everyone. -2. **Use `.local` files for secrets** — add API keys, passwords, and other sensitive values to `.env.local` or `.env.compose.local`. -3. **Templates are for defaults** — committed files should contain example values, localhost URLs, and other non-sensitive defaults. +1. **Never put secret values in any env file** โ€” not in committed templates (`.env`, `.env.compose`) and not in gitignored `.local` files. `.env.secret` is tracked, but it must only contain *references* (e.g. `op://...`), never resolved values. +2. **Use `.local` files for non-secret developer overrides** โ€” per-developer ports, feature flags, alternate URLs, etc. If a value is truly sensitive, it belongs in the secret store, not here. +3. **Templates are for defaults** โ€” committed files should contain example values, localhost URLs, and other non-sensitive defaults. When a secret-bearing variable is handled by `.env.secret`, add a comment to `.env` (and `.env.compose` if applicable) pointing at this guide so contributors don't re-add it to the file. ## Next.js Environment File Reference Each application uses multiple environment files with different purposes and precedence: -| File | Purpose | Tracked in Git? | Used Where | -| ----------------------------- | ------------------------------------------------------------ | --------------- | ---------- | -| `.env` | Host defaults (local dev on your machine) | **Yes** | Host | -| `.env.local` | Host-only overrides & secrets for this developer | **No** | Host | -| `.env.development.local` | Optional host-only overrides when `NODE_ENV=development` | **No** | Host | -| `.env.production.local` | Optional host-only overrides when `NODE_ENV=production` | **No** | Host | -| `.env.test.local` | Optional host-only overrides when `NODE_ENV=test` | **No** | Host | -| `.env.compose` | Docker defaults (typically using service names) | **Yes** | Docker | -| `.env.compose.local` | Docker-only overrides & secrets for this developer | **No** | Docker | +| File | Purpose | Tracked in Git? | Used Where | +| ----------------------------- | --------------------------------------------------------------- | --------------- | ---------- | +| `.env` | Host defaults (local dev on your machine) | **Yes** | Host | +| `.env.local` | Host-only non-secret overrides for this developer | **No** | Host | +| `.env.development.local` | Optional host-only non-secret overrides when `NODE_ENV=development` | **No** | Host | +| `.env.production.local` | Optional host-only non-secret overrides when `NODE_ENV=production` | **No** | Host | +| `.env.test.local` | Optional host-only non-secret overrides when `NODE_ENV=test` | **No** | Host | +| `.env.compose` | Docker defaults (typically using service names) | **Yes** | Docker | +| `.env.compose.local` | Docker-only non-secret overrides for this developer | **No** | Docker | +| `.env.secret` | Provider **references** to secrets (e.g. `op://...`) | **Yes** | Host / Docker-via-host | ### Host vs Docker Usage @@ -90,7 +97,8 @@ Each application uses multiple environment files with different purposes and pre When you run `next dev` or similar commands directly on your machine (not in Docker), Next.js reads `.env*` files in a specific order (see next section). Typically you'll use: - `.env` for generic defaults -- `.env.local` to override values or add secrets +- `.env.local` to override non-secret values per developer +- `pnpm dev` (which invokes the wrapper) to inject secrets on top (see [Secrets](#secrets)) **Docker (Running in Containers):** @@ -98,21 +106,24 @@ When you run via `docker-compose up`, the `env_file` entries in `docker-compose. - `.env` for generic defaults - `.env.compose` for container-specific defaults (e.g., `http://postgres:5432` instead of `localhost`) -- `.env.compose.local` to override values or add secrets for Docker +- `.env.compose.local` to override non-secret values per developer +- `pnpm docker:prod` (which wraps `docker compose` with the secrets wrapper) to inject secrets into the host process, forwarded to the container via `environment:` name-only entries (see [Secrets](#secrets)) ## Next.js Environment Variable Load Order When running on the **host** (not Docker), Next.js resolves each environment variable by checking the following sources in order, stopping at the first match: -1. **`process.env`** — Values injected by the shell, CI, or other external sources -2. **`.env.$(NODE_ENV).local`** — e.g., `.env.development.local` or `.env.production.local` -3. **`.env.local`** — Ignored when `NODE_ENV=test` -4. **`.env.$(NODE_ENV)`** — e.g., `.env.development`, `.env.production`, or `.env.test` -5. **`.env`** — Base defaults +1. **`process.env`** โ€” Values injected by the shell, CI, or other external sources +2. **`.env.$(NODE_ENV).local`** โ€” e.g., `.env.development.local` or `.env.production.local` +3. **`.env.local`** โ€” Ignored when `NODE_ENV=test` +4. **`.env.$(NODE_ENV)`** โ€” e.g., `.env.development`, `.env.production`, or `.env.test` +5. **`.env`** โ€” Base defaults This means that variables set in `.env.local` override those in `.env`, and variables set in `.env.development.local` override both. -**Important:** When using Docker Compose, variables loaded via `env_file` become part of `process.env` inside the container, so they override all `.env*` files for those variables. +**Important:** When you start the app via `pnpm dev` (or any other wrapped script), the wrapper injects secrets into `process.env` before Next.js starts โ€” so they win against every `.env*` file under rule 1 above. This is intentional: the secret store is the source of truth, and the env files are just non-secret defaults. + +**Important:** When using Docker Compose, variables loaded via `env_file` become part of `process.env` inside the container, so they override all `.env*` files for those variables. Secrets are forwarded to the container from the outer wrapped task (not from `env_file`). **Reference:** [Next.js Environment Variables Documentation](https://nextjs.org/docs/app/guides/environment-variables) @@ -120,19 +131,20 @@ This means that variables set in `.env.local` override those in `.env`, and vari ### On the Host (No Docker) -Developers mainly use `.env` + `.env.local` (and optionally `.env.development.local` or `.env.production.local`). +Developers mainly use `.env` + `.env.local` (and optionally `.env.development.local` or `.env.production.local`) for non-secret values, and the wrapper + secret-store pipeline for secrets. -When you run `next dev` or `next build`, Next.js reads the files according to the load order described above. +When you run `next dev` or `next build` directly, Next.js reads the files according to the load order described above. When you run the app via `pnpm dev`, the wrapper additionally injects secrets into `process.env` before Next.js starts. **Example workflow:** -1. Clone the repo - `.env` is already there with sensible defaults. -2. `postinstall` script creates `.env.local` which is a copy of `.env` file; add your secrets there (API keys, database passwords, etc.). -3. Run `next dev` - your local overrides take precedence. +1. Clone the repo โ€” `.env` is already there with sensible defaults. +2. `postinstall` script creates `.env.local` as a copy of `.env`; tweak per-developer non-secret values here (custom ports, feature flags, etc.). +3. Set up the secret store once (see [Secrets](#secrets)) โ€” e.g. install and authenticate the 1Password CLI. +4. Run `pnpm dev` โ€” the wrapper fetches secrets from the store, injects them as env vars, then starts Next.js. ### In Docker (via `docker-compose`) -The `docker-compose.yml` file uses `env_file` to inject variables into the container: +The `docker-compose.yml` file uses `env_file` to inject non-secret variables into the container: ```yaml services: @@ -142,22 +154,191 @@ services: - ../apps/frontend/.env - ../apps/frontend/.env.compose - ../apps/frontend/.env.compose.local + environment: + # Forward secrets from the wrapped host environment into the container. + # These are NOT read from any env file. + - NEXTAUTH_SECRET # ... other config ``` **How this works:** 1. **`.env`** provides generic defaults. -2. **`.env.compose`** provides container-specific defaults (e.g., service names like `postgres` instead of `localhost`). -3. **`.env.compose.local`** allows per-developer Docker overrides and secrets. +2. **`.env.compose`** provides container-specific defaults (e.g., service names like `postgres` instead of `localhost`). Must contain no secrets. +3. **`.env.compose.local`** allows per-developer Docker overrides for non-secret values. +4. **`environment:` (without a value)** forwards a variable from the host process โ€” where the wrapper has just injected the secret โ€” into the container. Because Docker Compose loads these files into `process.env`, they override any `.env*` files inside the container for those variables. Next.js sees them as already-set environment variables and uses them directly. **Example workflow:** -1. Clone the repo - both `.env` and `.env.compose` are already there. -2. `postinstall` script creates `.env.compose.local` which is a copy of `.env.compose` file; add your Docker-specific secrets there. -3. Run `docker-compose up` - your local overrides take precedence. +1. Clone the repo โ€” both `.env` and `.env.compose` are already there. +2. `postinstall` script creates `.env.compose.local` as a copy of `.env.compose`; tweak per-developer non-secret values here. +3. Set up the secret store once (see [Secrets](#secrets)). +4. Run `pnpm docker:prod -- up -d` (or another subcommand). The script delegates to the frontend's `docker:prod`, which wraps `docker compose` with [scripts/with-secrets.sh](../scripts/with-secrets.sh) โ€” so secrets are resolved on the host process and compose's `environment:` block forwards them into the container. + +## Secrets + +### Goal + +No secret values on disk. True secret values never live in `.env`, `.env.local`, `.env.compose`, `.env.compose.local`, or any other file โ€” committed or gitignored. `.env.secret` is committed but contains only **references** (e.g. `op://vault/item/field`); resolving them requires an authenticated session to the underlying secret store. Resolved values are pulled at task-run time and injected directly into the process environment that needs them. + +### How it works + +A single wrapper script โ€” [scripts/with-secrets.sh](../scripts/with-secrets.sh) โ€” is the only place the repo names a specific secret-store CLI. It: + +1. Detects which provider to use by inspecting the app's folder (`.env.secret` + `op` on PATH โ†’ 1Password). The `SECRETS_PROVIDER=` env var overrides auto-detection. +2. Execs the wrapped command under that provider's `run` mode (`op run --env-file=.env.secret -- โ€ฆ`), so the resolved values exist only in the child process's environment โ€” never in a shell variable, never on disk. +3. Falls through to `exec "$@"` when no provider is configured (the CI path: secrets are already in `process.env` from the pipeline's secret provider). + +Per-app pnpm scripts call the wrapper directly: + +```jsonc +// apps/frontend/package.json +{ + "scripts": { + "dev": "NODE_OPTIONS='--inspect' ../../scripts/with-secrets.sh . -- next dev", + "build": "../../scripts/with-secrets.sh . -- next build", + "start": "../../scripts/with-secrets.sh . -- next start", + "docker:prod": "../../scripts/with-secrets.sh . -- docker compose -f ../../docker/docker-compose.yml" + } +} +``` + +### Reference setup: 1Password CLI + +This project uses the 1Password CLI (`op`). Each secret is declared in [apps/frontend/.env.secret](../apps/frontend/.env.secret): + +``` +NEXTAUTH_SECRET=op://JS general/ReactExample/NextAuthSecret +TEST_SECRET=op://JS general/ReactExample/TestSecret +``` + +When `pnpm dev` runs, the wrapper invokes `op run --env-file=.env.secret -- next dev`. `op` resolves each `op://โ€ฆ` reference on demand from your authenticated 1Password session and sets the resolved values as environment variables for the child process. Nothing is written to disk; nothing leaks to unrelated shell commands. + +### Local setup (1Password) + +1. Install the 1Password CLI: + ```sh + brew install 1password-cli + ``` +2. In the 1Password desktop app โ†’ **Settings โ†’ Developer** โ†’ enable **Integrate with 1Password CLI**. +3. Ensure your account has access to the vault that holds the project secrets. + +On first `pnpm dev`, 1Password prompts for authorization. After that, secrets are injected transparently. + +### Alternative backends + +1Password is the only provider wired up out of the box, but the wrapper is the single integration point โ€” every other provider with a `run`-style CLI (Doppler, Infisical, HashiCorp Vault, AWS Secrets Manager via `chamber`, Bitwarden, etc.) plugs in by appending a `case` arm to [scripts/with-secrets.sh](../scripts/with-secrets.sh) and a corresponding manifest filename. The rest of the pipeline (the per-app pnpm scripts, `docker-compose.yml`, CI, the envsafe validators) does not name a provider, so adding one is a localized change. + +To force a specific provider regardless of auto-detection, set `SECRETS_PROVIDER=` in the environment. + +### CI + +Local developer machines use the wrapper + a local secret-store CLI, but CI runners have their own secret provider and don't need any CLI. The wrapper's `SECRETS_PROVIDER=none` fallback (auto-selected when neither a manifest nor a CLI is present) execs the command directly, so CI just runs the plain scripts with secrets supplied through the job's `env:` block: + +```yaml +jobs: + build: + env: + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + TEST_SECRET: ${{ secrets.TEST_SECRET }} + steps: + - run: pnpm build +``` + +The Playwright config in [apps/frontend-e2e/playwright.config.ts](../apps/frontend-e2e/playwright.config.ts) uses plain `pnpm --filter @infinum/frontend start` for the same reason โ€” by the time `next start` runs in CI, the secret is already in `process.env`, regardless of whether it came from the wrapper locally or from the pipeline runner in CI. + +## Adding a new environment variable + +Every contributor adding an env variable needs to update a different set of files depending on (a) whether it's a secret and (b) when it's needed (runtime only vs build-time + runtime). The three scenarios below cover the cases this template supports โ€” pick the one that matches your variable, follow the checklist, and you're done. + +If you're unsure which scenario applies: + +- **Is the value sensitive** (auth secret, API key, signing cert, DB password)? If yes โ†’ it's a **secret**. Pick scenario B or C. +- **Does the app read it during static rendering at build time** (e.g., a server component imports `secretEnv()` and the page is statically generated, or any `getStaticProps`-style code accesses it)? If yes โ†’ it's needed at **build time**. Pick scenario C. If only at request time / server runtime โ†’ scenario B is enough. +- **Otherwise** (non-sensitive value the client or server uses) โ†’ scenario A. + +### Scenario A: non-secret variable + +Examples: `API_BASE_URL`, a feature-flag name, a non-secret OAuth client ID, a port number. + +1. **Default value** โ€” Add `NAME="default-value"` to [apps/<app>/.env](../apps/frontend/.env). This is the host default; the file is committed. +2. **Docker default (optional)** โ€” If the value differs inside a container (e.g., a service name instead of `localhost`), add `NAME="container-value"` to [apps/<app>/.env.compose](../apps/frontend/.env.compose). +3. **Per-developer overrides (no code change)** โ€” `.env.local` and `.env.compose.local` are auto-created from the templates by [scripts/create-env-overrides.sh](../scripts/create-env-overrides.sh) on first install. Devs tweak there without touching the committed defaults. +4. **Validator** โ€” Register the variable in: + - [apps/<app>/src/lib/env/validate-env.client.ts](../apps/frontend/src/lib/env/validate-env.client.ts) โ€” if it should be readable from client components (it gets bundled into the browser). + - [apps/<app>/src/lib/env/validate-env.server.ts](../apps/frontend/src/lib/env/validate-env.server.ts) โ€” if only the server should see it. +5. **CI (only if it differs in CI)** โ€” If the value needs to be different in CI than in `.env`, add it to the workflow's `env:` block. Most non-secret variables don't need this โ€” the committed `.env` defaults work. + +No Dockerfile, compose runtime forwarding, or secret-store work is needed. The variable lives in plain env files. + +### Scenario B: secret used only at runtime + +Examples: an API key used inside route handlers, a database password the server connects with after startup. + +1. **Secret store** โ€” Create the item in your vault (e.g., a new 1Password item under the project vault). Note its reference path: `op:////`. +2. **Manifest** โ€” Add `NAME=op:////` to [apps/<app>/.env.secret](../apps/frontend/.env.secret). This file is committed; it holds *references*, not values. +3. **Compose runtime forwarding** โ€” In [docker/docker-compose.yml](../docker/docker-compose.yml), add `- NAME` (name only, no value) under the service's `environment:` block so compose forwards the value from the host process into the container at runtime. +4. **Validator** โ€” Register the variable in [apps/<app>/src/lib/env/validate-env.server.ts](../apps/frontend/src/lib/env/validate-env.server.ts) with `'server-only'` already imported. Never add secrets to `validate-env.client.ts` โ€” they would be bundled into the browser. +5. **Comment-fence the plain env files** โ€” Add a comment in [apps/<app>/.env](../apps/frontend/.env) (and [apps/<app>/.env.compose](../apps/frontend/.env.compose) if the secret reaches the container) noting that the variable is injected via `.env.secret`, so contributors don't re-add a default value: + ``` + # NAME handled through .env.secret + scripts/with-secrets.sh (see documentation/Environment variables.md) + ``` +6. **GitHub Actions secret** โ€” In the repo's GitHub Actions settings, add `NAME` as a repository (or organization) secret with the resolved value. This is the CI counterpart to the 1Password item. +7. **CI workflows** โ€” In every workflow that runs code touching the secret, add it to the `env:` block: + ```yaml + env: + NAME: ${{ secrets.NAME }} + ``` + Check [.github/workflows/ci.yml](../.github/workflows/ci.yml) and [.github/workflows/e2e.yml](../.github/workflows/e2e.yml) โ€” both currently list `NEXTAUTH_SECRET` and `TEST_SECRET`; mirror that pattern. + +### Scenario C: secret needed at build time too + +Examples: anything envsafe validates during `next build` static rendering. In this template that includes `NEXTAUTH_SECRET` and `TEST_SECRET`, because the login page is a server component that calls `secretEnv()` and gets statically generated. + +Do **everything from Scenario B**, then add: + +8. **Compose build-args** โ€” In [docker/docker-compose.yml](../docker/docker-compose.yml) under the service's `build:` block, add `NAME: ${NAME}` to the `args:` map. Compose interpolates `${NAME}` from the host process's env (which the [scripts/with-secrets.sh](../scripts/with-secrets.sh) chain has already populated for local; CI exports it via the workflow `env:` block). +9. **Dockerfile builder stage** โ€” In [apps/<app>/Dockerfile](../apps/frontend/Dockerfile), add inside the `builder` stage (before `RUN pnpm build`): + ```dockerfile + ARG NAME + ENV NAME=$NAME + ``` + The `ARG` makes the build-arg visible to the stage; the `ENV` promotes it so `next build`'s envsafe check sees it. These declarations are scoped to the builder stage only โ€” the production stage restarts `FROM base`, so values don't propagate into the final image layers. + +### Touch-point matrix + +The same information in matrix form, for quick reference: + +| File / system | A (non-secret) | B (runtime secret) | C (build+runtime secret) | +| ---------------------------------------------------------------------------------------- | :---: | :---: | :---: | +| Secret store (e.g. 1Password vault item) | โ€” | โœ“ | โœ“ | +| `apps//.env.secret` | โ€” | โœ“ | โœ“ | +| `apps//.env` (default value or comment-fence) | โœ“ value | โœ“ comment | โœ“ comment | +| `apps//.env.compose` (container default or comment-fence) | optional | optional comment | optional comment | +| `apps//src/lib/env/validate-env.client.ts` | if public | โ€” | โ€” | +| `apps//src/lib/env/validate-env.server.ts` | if server-only | โœ“ | โœ“ | +| `docker/docker-compose.yml` โ€” service `environment:` (runtime forwarding) | โ€” | โœ“ | โœ“ | +| `docker/docker-compose.yml` โ€” service `build.args:` (build-time injection) | โ€” | โ€” | โœ“ | +| `apps//Dockerfile` builder stage โ€” `ARG NAME` + `ENV NAME=$NAME` | โ€” | โ€” | โœ“ | +| GitHub Actions repo settings โ€” register `secrets.NAME` | โ€” | โœ“ | โœ“ | +| `.github/workflows/.yml` โ€” add to job/workflow `env:` block | optional | โœ“ | โœ“ | + +When in doubt, start with the more restrictive scenario (C if you're not sure whether it's build-time, B if you're not sure whether it's a secret). You can always relax later by removing the extra declarations. + +### Why the wrapper uses `exec` and provider-native `run` modes + +Every provider in the supported list exposes a `run` (or `exec`) subcommand that resolves references and **execs** the child with the resolved values in its environment โ€” the values never pass through a shell variable. That avoids two classes of leak: + +- **Log/expansion leaks** โ€” a value pulled into a shell variable can show up in `set -x` output, history, or expansion errors. +- **Quoting/escaping bugs** โ€” letting `op` (or its peers) write directly into the child's `environ` array sidesteps any shell-quoting concerns. + +The wrapper itself uses `exec` for the same reason and to ensure signals propagate cleanly to long-running children like `next dev` or `docker compose up`. + +### What if I don't have vault access yet? + +If you're onboarding and don't yet have access to the project's 1Password vault (or equivalent), ask in the team channel to be added. As a short-term workaround you can export the required secrets manually in your shell before running `pnpm dev` โ€” the wrapper's `none` fallback will pass them through if no provider is configured, or you can force it with `SECRETS_PROVIDER=none pnpm dev`. This is a stopgap โ€” the wrapper + vault flow is the supported path. ## Security & Public Variables @@ -165,8 +346,8 @@ Because Docker Compose loads these files into `process.env`, they override any ` With `next-public-env`, the distinction between public and private variables is enforced by which validator you add them to: -- **Public variables** — Defined in `validate-env.client.ts`, exposed to the browser via `getPublicEnv()` -- **Private variables** — Defined in `validate-env.server.ts`, only available on the server via `secretEnv()` +- **Public variables** โ€” Defined in `validate-env.client.ts`, exposed to the browser via `getPublicEnv()` +- **Private variables** โ€” Defined in `validate-env.server.ts`, only available on the server via `secretEnv()` **Important:** Unlike traditional Next.js, you **don't need the `NEXT_PUBLIC_` prefix**. Any variable in `validate-env.client.ts` is automatically public, regardless of its name. diff --git a/documentation/Tool management.md b/documentation/Tool management.md new file mode 100644 index 00000000..6e486b67 --- /dev/null +++ b/documentation/Tool management.md @@ -0,0 +1,129 @@ +# Tool Management (mise) + +> **TL;DR** +> +> 1. **`mise` is required** โ€” it pins `node` and `pnpm` versions for every contributor and CI runner, using [mise.lock](../mise.lock) to verify each binary against a SHA256 checksum before running it. +> 2. **One-time setup:** `brew install mise`, then `mise trust && mise install` from the repo root. Add `eval "$(mise activate zsh)"` to your shell rc so the pinned versions are on PATH whenever you `cd` into the repo. +> 3. **Day-to-day commands stay `pnpm`** โ€” `pnpm dev`, `pnpm build`, `pnpm test`, etc. The root scripts route through [scripts/with-mise.sh](../scripts/with-mise.sh), which prefixes `mise exec --` when mise is available, so the pinned toolchain is used even if your shell isn't activated. +> 4. **Secrets pipeline** โ€” a small provider-agnostic wrapper ([scripts/with-secrets.sh](../scripts/with-secrets.sh)) injects secrets into the relevant tasks (see [Environment Variables โ€” Secrets](./Environment%20variables.md#secrets)). +> 5. **Security story** โ€” this setup is the project's implementation of the [Infinum handbook's Node.js security guidelines](https://infinum.com/handbook/frontend/node/security/overview) *(internal)*. Please read the handbook for the full rationale. + +## Why mise? + +Node.js tooling is one of the most aggressively targeted parts of the modern software supply chain. In 2025 and 2026 alone, attackers compromised packages with hundreds of millions of weekly downloads, and the pattern is not slowing down. The [Infinum handbook's Node.js security overview](https://infinum.com/handbook/frontend/node/security/overview) *(internal)* covers this threat model in depth, along with the tool-management, dependency-hardening, and AI-assisted-development guidelines this repo implements. Internal readers can navigate to the sub-sections from the overview page; the overview is the only handbook link referenced from this repo. + +This repo follows the handbook's tool-management recommendation. [mise](https://mise.jdx.dev/) (pronounced "meez") was chosen over alternatives (`nvm`, `fnm`, `volta`, `asdf`, `corepack`) because it: + +1. **Pins every tool in one file** โ€” `node`, `pnpm`, and anything else the project adopts later (e.g. `bun`, `deno`, `jq`), using the single [mise.toml](../mise.toml). +2. **Verifies binaries against a committed lockfile** โ€” [mise.lock](../mise.lock) records the SHA256 of each tool binary for each supported platform. On `mise install`, mise re-downloads and checks the hash before putting the binary on your PATH. +3. **Works identically on developer machines, Docker builds, and CI** โ€” no branching logic for "is this a developer laptop?" + +### What this setup protects against + +- **Compromised tool distributions** โ€” if `nodejs.org` or the pnpm release bucket were ever replaced with a malicious binary, `mise install` would fail the checksum check against [mise.lock](../mise.lock) instead of silently installing the tampered binary. +- **Version drift** โ€” every contributor and every CI job runs the exact same `node` and `pnpm` versions. No "works on my machine" from a floating major version. [pnpm-workspace.yaml](../pnpm-workspace.yaml)'s `engineStrict: true` setting combined with `engines.{node,pnpm}` in [package.json](../package.json) makes `pnpm install` fail when either version doesn't match, so a contributor running with their system node/pnpm gets a clear error rather than a confusing runtime failure. +- **AI agents running `npm install -g` on your behalf** โ€” when an agent (or a well-meaning tutorial) tries to install a global tool, mise's project-scoped shims mean the agent can't reach past the pinned set without an explicit edit to `mise.toml`, which is code-reviewed like any other change. + +### What this setup does **not** protect against + +mise is one layer of defense. It does not protect you from: + +- Malicious packages pulled in as transitive dependencies โ€” that is the job of the [pnpm hardening settings](../README.md#pnpm-config) (`minimumReleaseAge`, `onlyBuiltDependencies`, `strictDepBuilds`). +- Prompt injection or malicious MCP servers in AI tooling. +- Malicious IDE extensions. +- Social engineering. + +See the handbook overview for the full scope of what's in and out of the Node.js security guidelines. + +## Installation + +### Install mise itself + +mise is a single static Go binary with no runtime dependencies. On macOS: + +```sh +brew install mise +``` + +For other platforms (Linux, Windows, manual install) follow [mise's installation guide](https://mise.jdx.dev/getting-started.html). + +Add the mise shell activation to your shell's rc file (zsh example): + +```sh +echo 'eval "$(mise activate zsh)"' >> ~/.zshrc +``` + +Restart your shell. From this point on, whenever you `cd` into a directory with a `mise.toml`, mise will automatically put the pinned tool versions on your PATH. + +### Install this project's tools + +From the repo root: + +```sh +mise trust # one-time: approve this repo's mise.toml +mise install # download + checksum-verify node and pnpm +``` + +Verify: + +```sh +node --version # should match mise.toml [tools] node +pnpm --version # should match mise.toml [tools] pnpm +which node # should resolve to a path under ~/.local/share/mise/installs/ +``` + +## Repo layout + +`mise.toml` lives at the repo root and contains only tool-management config โ€” no tasks, no secrets: + +``` +mise.toml # [settings] + [tools] โ€” what to pin +mise.lock # SHA256 checksums for the pinned binaries (auto-generated) +``` + +There is **no per-app `mise.toml`**. Secret injection is handled by [scripts/with-secrets.sh](../scripts/with-secrets.sh), invoked from per-app pnpm scripts โ€” see [Environment Variables โ€” Secrets](./Environment%20variables.md#secrets). + +## Daily usage + +Day-to-day, just use the pnpm scripts. The root scripts route through [scripts/with-mise.sh](../scripts/with-mise.sh) โ€” a tiny shim that prefixes `mise exec --` when mise is on PATH, so the pinned toolchain is used even from a shell that hasn't been activated: + +```sh +pnpm dev # start all apps in dev (secrets injected via with-secrets.sh) +pnpm build # build all apps and packages +pnpm start # run all apps in production mode +pnpm test # run unit tests +pnpm e2e # run Playwright E2E tests +pnpm docker:prod # docker compose for the production stack (see Docker Setup guide) +``` + +You can also invoke `mise exec -- ` manually for any command that should run under the pinned toolchain (e.g. `mise exec -- node ./scripts/foo.js`). + +## Why the `with-mise.sh` shim on the root scripts? + +`pnpm dev` resolves whichever `node` and `pnpm` are on PATH. With shell activation that's the mise-pinned binary; without it, it's whatever else the user has installed (homebrew, fnm, volta, nvm, system). Routing the root scripts through [scripts/with-mise.sh](../scripts/with-mise.sh) forces every local invocation through `mise exec --`, so a contributor who hasn't sourced `mise activate` still gets the verified toolchain. Turbo's child processes inherit the activated PATH, so the shim is needed only at the top level. + +The shim falls through to plain `exec` when mise isn't on PATH โ€” that's the CI path. CI runners provision node via `actions/setup-node` reading [package.json](../package.json)'s `engines.node` and never install mise, so the prefix would otherwise break CI. The shim makes the root scripts portable between both contexts without changing what gets typed. + +pnpm's own `engineStrict` (set in [pnpm-workspace.yaml](../pnpm-workspace.yaml)) is the second line of defense: even if a contributor bypasses `mise exec` and runs `pnpm install` directly with the wrong node or pnpm version, pnpm fails the install before any code runs. + +## Updating tool versions + +1. Edit `[tools]` in [mise.toml](../mise.toml) with the new version. +2. Run `mise install` โ€” this downloads the new binary and refreshes [mise.lock](../mise.lock) with the new checksum. +3. Commit both `mise.toml` and `mise.lock` in the same PR. Reviewers should verify the lockfile changes look plausible (only the expected tool and checksums changed). +4. Keep `engines.node` in [package.json](../package.json) and any `FROM node:-alpine` lines in Dockerfiles in sync so `pnpm`'s `engineStrict` and Docker builds agree with mise. + +## Troubleshooting + +- **`command not found: mise`** โ€” shell activation is missing. Re-run `eval "$(mise activate zsh)"` or add it to your rc file. +- **`mise trust` was not run** โ€” mise refuses to evaluate a new `mise.toml` until you've explicitly trusted it. Run `mise trust` from the repo root. +- **`pnpm install` fails with "Unsupported engine"** โ€” pnpm's `engineStrict` detected your shell is running a non-pinned node or pnpm. Run `mise install` and ensure shell activation is configured (see [Install mise itself](#install-mise-itself)). +- **Checksum mismatch on `mise install`** โ€” do **not** bypass this. Stop, report to the security team (see the [handbook overview](https://infinum.com/handbook/frontend/node/security/overview) *(internal)* for incident-response guidance), and do not install the binary. +- **Wrong node/pnpm on PATH** โ€” `which node` should resolve under `~/.local/share/mise/installs/...`. If it points at Homebrew or nvm, your shell activation is shadowed; check your rc file order. +- **1Password prompts every time** โ€” make sure **Settings โ†’ Developer โ†’ Integrate with 1Password CLI** is enabled in the 1Password desktop app. If you still get prompted, your session token is expiring; that's controlled by your 1Password account's session-length policy. + +## Related documentation + +- [Environment Variables](./Environment%20variables.md) โ€” the secrets pipeline in detail +- [Docker Setup](./Docker%20setup.md) โ€” how `pnpm docker:prod` forwards args and secrets into compose +- [Infinum handbook โ€” Node.js Security](https://infinum.com/handbook/frontend/node/security/overview) *(internal)* โ€” the broader story this setup implements diff --git a/mise.lock b/mise.lock new file mode 100644 index 00000000..b4c63cb7 --- /dev/null +++ b/mise.lock @@ -0,0 +1,65 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.node]] +version = "24.15.0" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-arm64-musl"] +checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz" + +[tools.node."platforms.linux-x64-musl"] +checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz" + +[tools.node."platforms.windows-x64"] +checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62" +url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip" + +[[tools.pnpm]] +version = "10.33.0" +backend = "aqua:pnpm/pnpm" + +[tools.pnpm."platforms.linux-arm64"] +checksum = "sha256:06755ad2817548b84317d857d5c8003dc6e9e28416a3ea7467256c49ab400d48" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-linux-arm64" + +[tools.pnpm."platforms.linux-arm64-musl"] +checksum = "sha256:06755ad2817548b84317d857d5c8003dc6e9e28416a3ea7467256c49ab400d48" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-linux-arm64" + +[tools.pnpm."platforms.linux-x64"] +checksum = "sha256:8d4e8f7d778e8ac482022e2577011706a872542f6f6f233e795a4d9f978ea8b5" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-linux-x64" + +[tools.pnpm."platforms.linux-x64-musl"] +checksum = "sha256:8d4e8f7d778e8ac482022e2577011706a872542f6f6f233e795a4d9f978ea8b5" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-linux-x64" + +[tools.pnpm."platforms.macos-arm64"] +checksum = "sha256:ed8a1f140f4de457b01ebe0be3ae28e9a7e28863315dcd53d22ff1e5a32d63ae" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-macos-arm64" + +[tools.pnpm."platforms.macos-x64"] +checksum = "sha256:c31e29554b0e3f4e03f4617195c949595e4dca36085922003de4896c3ca4057d" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-macos-x64" + +[tools.pnpm."platforms.windows-x64"] +checksum = "sha256:afc96009dc39fe23a835d65192049e6a995f342496b175585dc2beda7d42d33f" +url = "https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-win-x64.exe" diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..0a508eed --- /dev/null +++ b/mise.toml @@ -0,0 +1,8 @@ +[settings] +lockfile = true +install_before = "7d" +idiomatic_version_file_enable_tools = ["node", "pnpm"] + +[tools] +node = "24.15.0" +pnpm = "10.33.0" diff --git a/package.json b/package.json index a97b2a4f..f1e5557c 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,18 @@ "version": "0.0.0", "private": true, "engines": { - "node": "24.11.0" + "node": "24.15.0", + "pnpm": "10.33.0" }, "scripts": { "bootstrap": "bash ./scripts/bootstrap.sh", - "build": "turbo run build --parallel --log-order=grouped", + "build": "./scripts/with-mise.sh turbo run build --parallel --log-order=grouped", "clean": "pnpm turbo clean && rm -rf node_modules .turbo coverage .eslintcache", "check-licenses": "turbo run check-licenses check-licenses:root && node scripts/aggregate-license-results.js --parallel --log-order=grouped", "check-licenses:root": "node scripts/check-licenses-workspace.js", - "dev": "turbo run dev --parallel --ui=tui --continue", - "docker:prod": "docker compose -f ./docker/docker-compose.yml", - "e2e": "turbo run e2e --parallel --log-order=grouped --continue", + "dev": "./scripts/with-mise.sh turbo run dev --parallel --ui=tui --continue", + "docker:prod": "./scripts/compose.sh", + "e2e": "./scripts/with-mise.sh turbo run e2e --parallel --log-order=grouped --continue", "e2e:install": "turbo run e2e:install", "e2e:report": "turbo run e2e:report --parallel --log-order=grouped", "e2e:update": "pnpm e2e -- --update-snapshots", @@ -29,11 +30,12 @@ "prettier:check:root": "prettier --check . '!apps/**' '!packages/**'", "prettier:fix": "turbo run prettier:fix prettier:fix:root --parallel --log-order=grouped", "prettier:fix:root": "prettier --write . '!apps/**' '!packages/**'", - "test": "turbo run test --parallel --log-order=grouped --continue", - "test:watch": "turbo run test:watch --ui=tui --continue", - "test:coverage": "turbo run test:coverage --parallel --log-order=grouped --continue", + "start": "./scripts/with-mise.sh turbo run start --parallel --log-order=grouped", + "test": "./scripts/with-mise.sh turbo run test --parallel --log-order=grouped --continue", + "test:watch": "./scripts/with-mise.sh turbo run test:watch --ui=tui --continue", + "test:coverage": "./scripts/with-mise.sh turbo run test:coverage --parallel --log-order=grouped --continue", "test:coverage:aggregate": "pnpm test:coverage && MONOREPO=true node scripts/aggregate-coverage-results.js", - "test:coverage:watch": "turbo run test:coverage:watch --ui=tui --continue" + "test:coverage:watch": "./scripts/with-mise.sh turbo run test:coverage:watch --ui=tui --continue" }, "devDependencies": { "@actions/core": "catalog:", @@ -55,5 +57,5 @@ "turbo": "catalog:", "typescript": "catalog:" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" + "packageManager": "pnpm@10.33.0" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f4f7a3cc..83c90e0a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,15 @@ +engineStrict: true + +minimumReleaseAge: 10080 + +allowBuilds: + sharp@0.34.5: true + core-js: false + +blockExoticSubdeps: true + +trustPolicy: no-downgrade + packages: - apps/* - packages/* @@ -76,10 +88,6 @@ catalog: typescript: 5.8.3 typescript-eslint: 8.46.2 -engineStrict: true - -minimumReleaseAge: 10080 - minimumReleaseAgeExclude: - ts-jest@29.4.10 - postcss@8.5.10 diff --git a/scripts/compose.sh b/scripts/compose.sh new file mode 100755 index 00000000..1997dbce --- /dev/null +++ b/scripts/compose.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Root-level compose driver. +# +# Wraps `docker compose -f docker/docker-compose.yml` with one `with-secrets.sh` +# layer per app under apps/. Each layer injects that app's secrets (resolved +# from its provider manifest) into the parent process; compose's per-service +# `environment:` blocks then forward only what each container asks for. +# +# Apps are discovered by globbing apps/*/, not by name โ€” adding a new app is a +# zero-edit operation here. Apps without a secrets manifest fall through +# inside with-secrets.sh, so wrapping them is cheap (one extra exec, no +# provider work). +# +# Usage: +# scripts/compose.sh up -d +# scripts/compose.sh up frontend +# scripts/compose.sh logs -f +# scripts/compose.sh down + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cmd=(docker compose -f "$repo_root/docker/docker-compose.yml" "$@") + +# Sort for stable ordering (purely cosmetic โ€” secret namespaces are disjoint +# per app, so layer order doesn't affect resolved values). +for app_dir in $(printf '%s\n' "$repo_root"/apps/*/ | sort); do + [[ -d "$app_dir" ]] || continue + cmd=("$repo_root/scripts/with-secrets.sh" "$app_dir" -- "${cmd[@]}") +done + +exec "${cmd[@]}" diff --git a/scripts/with-mise.sh b/scripts/with-mise.sh new file mode 100755 index 00000000..281eff0c --- /dev/null +++ b/scripts/with-mise.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Entry-point shim for root pnpm scripts. +# +# Runs the given command through `mise exec --` when mise is available +# (developer machines, ensures the pinned toolchain is used even from a +# non-activated shell). Falls through to plain exec when mise isn't on PATH +# (CI runners use actions/setup-node and don't install mise). +# +# Why not just always use `mise exec --` in package.json: that breaks CI, +# which provisions node via actions/setup-node reading package.json's +# engines.node and never installs mise. + +set -euo pipefail + +# Toolchain-state line so a contributor sees, on every root pnpm script, +# whether the pinned mise toolchain or the shell's ambient node/pnpm is +# about to run their command. The *absence* of the green check becomes the +# signal โ€” silence here would let a misconfigured shell pass unnoticed. +# Silent in CI (actions/setup-node is the source of truth and decorative +# output muddies workflow logs). +if [[ -z "${CI:-}" ]]; then + if command -v mise > /dev/null 2>&1; then + versions="$(mise current 2> /dev/null | awk '{printf "%s@%s ", $1, $2}')" + versions="${versions% }" + printf '\033[32mโœ“\033[0m toolchain: mise ยท %s\n' "${versions:-active}" >&2 + else + printf '\033[33mโš \033[0m toolchain: mise not on PATH โ€” using ambient node/pnpm\n' >&2 + fi +fi + +if command -v mise > /dev/null 2>&1; then + exec mise exec -- "$@" +else + exec "$@" +fi diff --git a/scripts/with-secrets.sh b/scripts/with-secrets.sh new file mode 100755 index 00000000..f6427f65 --- /dev/null +++ b/scripts/with-secrets.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Provider-agnostic secrets wrapper. +# +# Usage: +# with-secrets.sh -- +# +# Resolves secrets for via whichever provider is configured, then +# execs with the secrets in its environment. Supported providers are +# auto-detected from the manifest file present in ; override with +# SECRETS_PROVIDER=op|doppler|infisical|chamber|none. +# +# In CI (or any environment where secrets are already in process.env) no +# manifest file is needed: the wrapper falls through to `exec "$@"` and the +# child process inherits whatever the runner exported. +# +# Each provider's invocation is intentionally `exec` so signals propagate +# cleanly to the child (important for long-running processes like +# `next dev` or `docker compose up`). + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: with-secrets.sh -- " >&2 + exit 64 +fi + +app_dir="$1" +shift +[[ "${1:-}" == "--" ]] && shift + +if [[ ! -d "$app_dir" ]]; then + echo "with-secrets: app dir '$app_dir' does not exist" >&2 + exit 66 +fi + +provider="${SECRETS_PROVIDER:-auto}" +provider_explicit=0 +[[ -n "${SECRETS_PROVIDER:-}" ]] && provider_explicit=1 + +if [[ "$provider" == "auto" ]]; then + if [[ -f "$app_dir/.env.secret" ]]; then + if command -v op > /dev/null 2>&1; then + provider=op + else + provider=none + fi + else + provider=none + fi +fi + +# Warn whenever a manifest exists but no injection will happen โ€” covers both +# auto-resolution failing to find a CLI AND a developer who has explicitly +# set SECRETS_PROVIDER=none in their environment. Apps without a manifest +# (e.g. storybook) stay silent โ€” "no secrets" is their expected shape. CI +# stays silent โ€” the pipeline's env: block populates secrets upstream. +if [[ "$provider" == "none" && -f "$app_dir/.env.secret" && -z "${CI:-}" ]]; then + if [[ "$provider_explicit" == "1" ]]; then + printf '\033[33mโš \033[0m with-secrets: %s has .env.secret but SECRETS_PROVIDER=none (explicit) โ€” secrets NOT injected\n' "$app_dir" >&2 + else + printf '\033[33mโš \033[0m with-secrets: %s has .env.secret but no provider CLI on PATH โ€” secrets NOT injected\n' "$app_dir" >&2 + fi +fi + +case "$provider" in + op) + [[ -z "${CI:-}" ]] && printf '\033[32mโœ“\033[0m with-secrets: %s ยท op\n' "$app_dir" >&2 + exec op run --env-file="$app_dir/.env.secret" -- "$@" + ;; + none) exec "$@" ;; + *) + echo "with-secrets: unknown SECRETS_PROVIDER='$provider'" >&2 + exit 78 + ;; +esac