From 3ff6750333e3a6a0c65dde7e58f3adbbeee3b3a2 Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:13:33 -0600 Subject: [PATCH 1/2] Harden config and persistence workflow for durable operations --- .env.example | 24 ++ .github/workflows/ci.yml | 75 +++++++ .gitignore | 7 + ADR-001.md | 13 ++ ARCHITECHTURE.md | 290 +------------------------ ARCHITECTURE.md | 302 ++++++++++++++++++++++++++ PRODUCT.md | 12 + README.md | 202 +++++++---------- docker-compose.yml | 79 ++++--- flapjack_api/.env.example | 17 ++ flapjack_api/flapjack_api/settings.py | 227 +++++++++++-------- flapjack_api/flapjack_api/urls.py | 2 + flapjack_api/registry/health.py | 21 ++ flapjack_api/registry/tests_health.py | 11 + flapjack_frontend/.env.dev.example | 2 + flapjack_frontend/.env.prod.example | 2 + flapjack_frontend/.gitignore | 4 +- scripts/backup_db.sh | 19 ++ scripts/migrate.sh | 11 + scripts/restore_db.sh | 25 +++ 20 files changed, 811 insertions(+), 534 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 flapjack_api/.env.example create mode 100644 flapjack_api/registry/health.py create mode 100644 flapjack_api/registry/tests_health.py create mode 100644 flapjack_frontend/.env.dev.example create mode 100644 flapjack_frontend/.env.prod.example create mode 100755 scripts/backup_db.sh create mode 100755 scripts/migrate.sh create mode 100755 scripts/restore_db.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac626ff --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copy to .env for local Docker Compose usage. +# Do not commit real credentials. + +FLAPJACK_ENV=development +DJANGO_DEBUG=true +DJANGO_SECRET_KEY=replace-with-long-random-secret +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CORS_ALLOW_ALL=true +DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000 +DJANGO_LOG_LEVEL=INFO +DJANGO_SECURE_SSL_REDIRECT=false + +POSTGRES_DB=registry +POSTGRES_USER=flapjack +POSTGRES_PASSWORD=replace-with-local-dev-password +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_HOST=redis +REDIS_PORT=6379 + +JWT_ACCESS_TOKEN_MINUTES=60 +JWT_REFRESH_TOKEN_DAYS=7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62178ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + env: + POSTGRES_DB: registry + POSTGRES_USER: flapjack + POSTGRES_PASSWORD: flapjack_test_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U flapjack -d registry" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + + defaults: + run: + working-directory: flapjack_api + + env: + FLAPJACK_ENV: development + DJANGO_DEBUG: "true" + DJANGO_SECRET_KEY: ci-dev-secret + DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1 + DJANGO_CORS_ALLOW_ALL: "true" + POSTGRES_DB: registry + POSTGRES_USER: flapjack + POSTGRES_PASSWORD: flapjack_test_password + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: 5432 + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Install backend deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Django checks + run: python manage.py check + - name: Migration consistency + run: python manage.py makemigrations --check --dry-run + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: flapjack_frontend + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install frontend deps + run: npm ci + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d66987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.env +*.dump +backups/ +__pycache__/ +.pytest_cache/ +.DS_Store diff --git a/ADR-001.md b/ADR-001.md index 12b3b2e..cd8bab0 100644 --- a/ADR-001.md +++ b/ADR-001.md @@ -166,3 +166,16 @@ Revisit this ADR if: - the product begins storing large binary artifacts at scale - compliance or security requirements require stronger secret or key management - the team decides to modernize the stack after persistence hardening is complete + +## Implementation notes (April 21, 2026) + +To operationalize this ADR in-repo, the baseline implementation now includes: + +- backend fail-fast environment validation for non-development config +- compose-level env-file loading (`.env`) +- `/api/healthz/` endpoint and container health checks +- migration script: `./scripts/migrate.sh` +- backup script: `./scripts/backup_db.sh` +- restore script: `./scripts/restore_db.sh ` + +Production operations should still use managed PostgreSQL backup/PITR controls in addition to these scripts. diff --git a/ARCHITECHTURE.md b/ARCHITECHTURE.md index c4ee673..dd70b64 100644 --- a/ARCHITECHTURE.md +++ b/ARCHITECHTURE.md @@ -1,289 +1,5 @@ -# Architecture +# Deprecated filename -## Overview +This file is kept for backward compatibility only. -Flapjack is a research data platform for genetic circuit and assay workflows. It combines a browser-based client, a Django/Channels backend, relational persistence, and websocket-based computation/streaming endpoints. - -This document describes: - -- the current observed architecture in the repository -- the intended system boundaries -- operational and security concerns -- the recommended target deployment model - -## System context - -At a high level, Flapjack consists of four runtime layers: - -1. **Frontend client** - - React application served on port 3000 in local development - - calls REST endpoints over HTTP - - connects to websocket endpoints for analysis, plotting, and registry upload flows - -2. **Backend application** - - Django application served as ASGI with Gunicorn + Uvicorn workers - - exposes REST endpoints for the registry and auth flows - - exposes websocket routes through Django Channels - -3. **Primary data store** - - PostgreSQL - - intended to hold the durable system of record for users, metadata, studies, assays, samples, and measurements - -4. **Ephemeral messaging/cache layer** - - Redis - - used for Channels transport and similar short-lived coordination concerns - - must not be treated as the durable source of truth - -## Current observed implementation - -### Frontend - -The frontend is a React application using: - -- React 16 -- `react-app-rewired` -- Ant Design -- Redux -- Plotly - -The local development frontend expects: - -- `REACT_APP_HTTP_API=http://localhost:8000/api/` -- `REACT_APP_WS_API=ws://localhost:8000/ws/` - -### Backend - -The backend is a Django 3 application with: - -- Django REST Framework -- JWT authentication -- Channels -- Redis channel layer -- Gunicorn + Uvicorn for ASGI serving - -Observed URL structure: - -- `/api/auth/register/` -- `/api/auth/log_in/` -- `/api/auth/refresh/` -- `/api/auth/user_info/` -- `/api/auth/log_out/` -- `/api//` - -Observed websocket routes: - -- `/ws/plot` -- `/ws/analysis` -- `/ws/registry` - -### Registry/data API surface - -The registry router currently includes endpoints for: - -- study -- assay -- sample -- dna -- media -- strain -- measurement -- signal -- supplement -- chemical -- vector -- vectorall -- user -- assays_in_study -- vector_in_assay -- strain_in_assay -- media_in_assay -- signal_in_assay - -These resources strongly suggest the core product boundary is a biological experiment and measurement registry with analysis and plotting layered on top. - -## Component boundaries - -### `flapjack_frontend` -Responsible for: - -- user-facing navigation and data interaction -- token-driven authenticated API access -- websocket-driven interactive analysis/plot behavior -- rendering of registry and visualization workflows - -It should not: - -- embed secrets -- own authoritative business rules -- become the source of truth for access control or validation - -### `flapjack_api` -Responsible for: - -- authentication and identity -- authorization and permission enforcement -- persistence rules and data model lifecycle -- REST API behavior -- websocket orchestration -- business logic around registry, analysis, and plotting - -It should not: - -- assume local-only infrastructure -- rely on hardcoded environment-specific settings -- treat Redis as durable storage - -### PostgreSQL -Responsible for: - -- durable relational storage -- transactional integrity -- authoritative system-of-record persistence - -It should not: - -- be configured by hardcoded credentials in repo files -- be used without backups and restore procedures -- depend on ad hoc container-local storage in production - -### Redis -Responsible for: - -- channel layer transport -- short-lived cache/message coordination - -It should not: - -- store authoritative long-term data -- be required to recover historical data after restart - -## Runtime flows - -## 1. Authentication flow - -1. User registers or logs in through `/api/auth/...` -2. Backend issues JWT refresh and access tokens -3. Frontend stores and sends bearer tokens to the backend -4. Refresh token is used to obtain new access tokens -5. Logout blacklists the provided refresh token - -## 2. Registry CRUD flow - -1. Frontend sends authenticated REST requests to `/api/...` -2. Django REST Framework resolves routing and permissions -3. Registry/business logic reads/writes PostgreSQL -4. Response is returned to frontend as JSON - -## 3. Websocket analysis/plot flow - -1. Frontend connects to `/ws/plot`, `/ws/analysis`, or `/ws/registry` -2. Django Channels routes websocket traffic -3. Redis is used as the channel layer backend -4. Backend returns streamed responses or computed results to the client - -## Deployment model - -## Current local model - -Current local development uses a single Docker Compose file with: - -- frontend container -- backend container -- postgres container -- redis container - -This is adequate for local development only. - -## Target production model - -Production should use a split-responsibility model: - -- frontend served as static assets or containerized web app behind TLS -- backend deployed as an ASGI app service -- managed PostgreSQL for durable storage -- managed Redis only if still required for Channels -- object storage for large uploaded files or exports -- secrets injected at runtime from environment or secret manager -- automated backups and restore runbooks - -## Configuration boundaries - -The repository must move toward explicit environment separation: - -- `development` -- `staging` -- `production` - -Recommended config categories: - -- Django secret key -- allowed hosts -- debug mode -- CORS/CSRF origins -- database connection settings -- Redis settings -- file/object storage settings -- JWT/token settings -- logging and observability settings - -No environment-specific secret should be committed to version control. - -## Security posture - -The current repository state indicates the codebase is still operating with development-oriented defaults. - -Immediate security objectives: - -- remove hardcoded secrets and credentials -- disable wildcard hosts/origins outside local development -- define production-safe auth/session/token settings -- add secure deployment defaults -- document incident recovery and restore procedures - -## Observability and operations - -The target operational baseline should include: - -- structured application logs -- clear startup failures when required env vars are missing -- health endpoints for backend, database, and Redis dependencies -- migration strategy documented and scripted -- backup and restore procedures documented and tested -- CI checks for build, lint, and smoke validation - -## Testing expectations - -Current repository structure should evolve toward: - -- backend unit/integration tests for auth and critical registry flows -- frontend tests for critical state and routing behavior -- smoke test that brings up Compose and validates API readiness -- migration test coverage for schema changes that affect persistent data - -## Architecture risks - -Current observed risks include: - -- local-development settings embedded in application code -- hardcoded secrets and database credentials -- production-unsuitable permissive Django settings -- unclear persistence guarantees in current Compose setup -- no documented backup/restore contract -- aging framework/runtime versions that will make maintenance harder over time - -## Open questions - -1. What uploaded artifacts, if any, are currently stored on local container filesystems rather than in PostgreSQL or object storage? -2. Which websocket workloads are latency-sensitive enough to keep on Channels/Redis as-is? -3. What is the expected production deployment target: single VM, container platform, or managed app services? -4. Is there existing legacy data that must be imported before the current database is retired? -5. Which API behaviors are relied on by external clients such as `pyFlapjack` and must remain backward compatible? - -## Architecture principles - -1. PostgreSQL is the durable source of truth. -2. Redis is ephemeral. -3. Code must be runnable locally in Docker. -4. Production config must be explicit and environment-driven. -5. Security and recoverability are mandatory, not optional. -6. Major architecture changes require an ADR. +Use `ARCHITECTURE.md` as the canonical architecture document. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5900dbb --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,302 @@ +# Architecture + +## Overview + +Flapjack is a research data platform for genetic circuit and assay workflows. It combines a browser-based client, a Django/Channels backend, relational persistence, and websocket-based computation/streaming endpoints. + +This document describes: + +- the current observed architecture in the repository +- the intended system boundaries +- operational and security concerns +- the recommended target deployment model + +## System context + +At a high level, Flapjack consists of four runtime layers: + +1. **Frontend client** + - React application served on port 3000 in local development + - calls REST endpoints over HTTP + - connects to websocket endpoints for analysis, plotting, and registry upload flows + +2. **Backend application** + - Django application served as ASGI with Gunicorn + Uvicorn workers + - exposes REST endpoints for the registry and auth flows + - exposes websocket routes through Django Channels + +3. **Primary data store** + - PostgreSQL + - intended to hold the durable system of record for users, metadata, studies, assays, samples, and measurements + +4. **Ephemeral messaging/cache layer** + - Redis + - used for Channels transport and similar short-lived coordination concerns + - must not be treated as the durable source of truth + +## Current observed implementation + +### Frontend + +The frontend is a React application using: + +- React 16 +- `react-app-rewired` +- Ant Design +- Redux +- Plotly + +The local development frontend expects: + +- `REACT_APP_HTTP_API=http://localhost:8000/api/` +- `REACT_APP_WS_API=ws://localhost:8000/ws/` + +### Backend + +The backend is a Django 3 application with: + +- Django REST Framework +- JWT authentication +- Channels +- Redis channel layer +- Gunicorn + Uvicorn for ASGI serving + +Observed URL structure: + +- `/api/auth/register/` +- `/api/auth/log_in/` +- `/api/auth/refresh/` +- `/api/auth/user_info/` +- `/api/auth/log_out/` +- `/api//` + +Observed websocket routes: + +- `/ws/plot` +- `/ws/analysis` +- `/ws/registry` + +### Registry/data API surface + +The registry router currently includes endpoints for: + +- study +- assay +- sample +- dna +- media +- strain +- measurement +- signal +- supplement +- chemical +- vector +- vectorall +- user +- assays_in_study +- vector_in_assay +- strain_in_assay +- media_in_assay +- signal_in_assay + +These resources strongly suggest the core product boundary is a biological experiment and measurement registry with analysis and plotting layered on top. + +## Component boundaries + +### `flapjack_frontend` +Responsible for: + +- user-facing navigation and data interaction +- token-driven authenticated API access +- websocket-driven interactive analysis/plot behavior +- rendering of registry and visualization workflows + +It should not: + +- embed secrets +- own authoritative business rules +- become the source of truth for access control or validation + +### `flapjack_api` +Responsible for: + +- authentication and identity +- authorization and permission enforcement +- persistence rules and data model lifecycle +- REST API behavior +- websocket orchestration +- business logic around registry, analysis, and plotting + +It should not: + +- assume local-only infrastructure +- rely on hardcoded environment-specific settings +- treat Redis as durable storage + +### PostgreSQL +Responsible for: + +- durable relational storage +- transactional integrity +- authoritative system-of-record persistence + +It should not: + +- be configured by hardcoded credentials in repo files +- be used without backups and restore procedures +- depend on ad hoc container-local storage in production + +### Redis +Responsible for: + +- channel layer transport +- short-lived cache/message coordination + +It should not: + +- store authoritative long-term data +- be required to recover historical data after restart + +## Runtime flows + +## 1. Authentication flow + +1. User registers or logs in through `/api/auth/...` +2. Backend issues JWT refresh and access tokens +3. Frontend stores and sends bearer tokens to the backend +4. Refresh token is used to obtain new access tokens +5. Logout blacklists the provided refresh token + +## 2. Registry CRUD flow + +1. Frontend sends authenticated REST requests to `/api/...` +2. Django REST Framework resolves routing and permissions +3. Registry/business logic reads/writes PostgreSQL +4. Response is returned to frontend as JSON + +## 3. Websocket analysis/plot flow + +1. Frontend connects to `/ws/plot`, `/ws/analysis`, or `/ws/registry` +2. Django Channels routes websocket traffic +3. Redis is used as the channel layer backend +4. Backend returns streamed responses or computed results to the client + +## Deployment model + +## Current local model + +Current local development uses a single Docker Compose file with: + +- frontend container +- backend container +- postgres container +- redis container + +This is adequate for local development only. + +## Target production model + +Production should use a split-responsibility model: + +- frontend served as static assets or containerized web app behind TLS +- backend deployed as an ASGI app service +- managed PostgreSQL for durable storage +- managed Redis only if still required for Channels +- object storage for large uploaded files or exports +- secrets injected at runtime from environment or secret manager +- automated backups and restore runbooks + +## Configuration boundaries + +The repository must move toward explicit environment separation: + +- `development` +- `staging` +- `production` + +Recommended config categories: + +- Django secret key +- allowed hosts +- debug mode +- CORS/CSRF origins +- database connection settings +- Redis settings +- file/object storage settings +- JWT/token settings +- logging and observability settings + +No environment-specific secret should be committed to version control. + +## Security posture + +The current repository state indicates the codebase is still operating with development-oriented defaults. + +Immediate security objectives: + +- remove hardcoded secrets and credentials +- disable wildcard hosts/origins outside local development +- define production-safe auth/session/token settings +- add secure deployment defaults +- document incident recovery and restore procedures + +## Observability and operations + +The target operational baseline should include: + +- structured application logs +- clear startup failures when required env vars are missing +- health endpoints for backend, database, and Redis dependencies +- migration strategy documented and scripted +- backup and restore procedures documented and tested +- CI checks for build, lint, and smoke validation + +## Testing expectations + +Current repository structure should evolve toward: + +- backend unit/integration tests for auth and critical registry flows +- frontend tests for critical state and routing behavior +- smoke test that brings up Compose and validates API readiness +- migration test coverage for schema changes that affect persistent data + +## Architecture risks + +Current observed risks include: + +- local-development settings embedded in application code +- hardcoded secrets and database credentials +- production-unsuitable permissive Django settings +- unclear persistence guarantees in current Compose setup +- no documented backup/restore contract +- aging framework/runtime versions that will make maintenance harder over time + +## Open questions + +1. What uploaded artifacts, if any, are currently stored on local container filesystems rather than in PostgreSQL or object storage? +2. Which websocket workloads are latency-sensitive enough to keep on Channels/Redis as-is? +3. What is the expected production deployment target: single VM, container platform, or managed app services? +4. Is there existing legacy data that must be imported before the current database is retired? +5. Which API behaviors are relied on by external clients such as `pyFlapjack` and must remain backward compatible? + +## Architecture principles + +1. PostgreSQL is the durable source of truth. +2. Redis is ephemeral. +3. Code must be runnable locally in Docker. +4. Production config must be explicit and environment-driven. +5. Security and recoverability are mandatory, not optional. +6. Major architecture changes require an ADR. + +## Hardening baseline implemented + +The repository now includes a minimum hardening baseline: + +- environment-driven backend config with fail-fast checks for non-development runs (`flapjack_api/flapjack_api/settings.py`) +- compose-managed environment loading via root `.env` +- health endpoint (`/api/healthz/`) and container health checks +- local backup/restore/migration scripts under `scripts/` +- `.env.example` files with placeholders only +- CI workflow for backend checks and frontend build + +These changes are intended to preserve runtime behavior while reducing data-loss and secret-management risk. diff --git a/PRODUCT.md b/PRODUCT.md index 766b1d1..2d390ec 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -142,3 +142,15 @@ The immediate product direction is: - preserve user workflows - make persistence trustworthy - make the repository understandable enough for sustained Codex-assisted development + +## Current stabilization deltas + +The current stabilization pass adds: + +- centralized environment-driven backend configuration +- fail-fast startup checks for non-development secret and host/cors config +- health checks for API/db/redis services +- documented backup/restore scripts for PostgreSQL +- baseline CI checks for backend and frontend + +These changes are intentionally narrow to preserve existing product behavior. diff --git a/README.md b/README.md index 479a59c..447d8ae 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,14 @@ Flapjack Fullstack is the deployment and development repository for the Flapjack This repository brings together: -- `flapjack_api`: Django/Channels backend, REST API, authentication, and websocket endpoints +- `flapjack_api`: Django/Channels backend, REST API, authentication, websocket endpoints - `flapjack_frontend`: React frontend -- `docker-compose.yml`: local orchestration for the app stack - -## Purpose - -This repository exists to make Flapjack runnable as a single system during development and to provide the foundation for a production-ready deployment. - -The application currently supports: - -- user registration, login, token refresh, logout, and user info retrieval -- study, assay, sample, vector, strain, media, signal, measurement, and related registry operations -- websocket-backed analysis, plotting, and registry upload flows -- programmatic access through a documented REST and websocket API +- `docker-compose.yml`: local orchestration for frontend, backend, PostgreSQL, and Redis ## Current stack ### Backend -- Python 3.7 container -- Django 3.0.5 +- Python / Django 3.0.x - Django REST Framework - Channels + Redis channel layer - JWT authentication via `djangorestframework-simplejwt` @@ -41,113 +29,81 @@ The application currently supports: - PostgreSQL 12 - Redis -## Repository layout - -```text -. -├── flapjack_api/ -│ ├── flapjack_api/ # Django project config, ASGI/WSGI, routing, settings -│ ├── accounts/ # Authentication endpoints -│ ├── registry/ # Core data registry API -│ ├── analysis/ # Analysis services / websocket handlers -│ ├── plot/ # Plot services / websocket handlers -│ ├── requirements.txt -│ └── Dockerfile -├── flapjack_frontend/ -│ ├── src/ -│ ├── public/ -│ ├── package.json -│ └── Dockerfile -└── docker-compose.yml -Local development -Prerequisites -Docker -Docker Compose -Frontend environment file +## Quick start (local development) + +### 1) Create environment files + +```bash +cp .env.example .env +cp flapjack_frontend/.env.dev.example flapjack_frontend/.env.dev ``` -Create flapjack_frontend/.env.dev with: - -REACT_APP_HTTP_API=http://localhost:8000/api/ -REACT_APP_WS_API=ws://localhost:8000/ws/ -Start the stack -docker compose up --build -Run database migrations - -Open a shell in the API container: - -docker exec -it flap_api bash - -Then run: - -python manage.py migrate -Access the app -Frontend: http://localhost:3000 -Backend API: http://localhost:8000 -Auth endpoints: http://localhost:8000/api/auth/ -Registry endpoints: http://localhost:8000/api/ -Websocket base paths: /ws/plot, /ws/analysis, /ws/registry -Common development tasks -Create new migrations -docker exec -it flap_api bash -python manage.py makemigrations -python manage.py migrate -Rebuild the stack -docker compose down -docker compose up --build -View logs -docker compose logs -f -Current state and important warnings - -This repository is valuable but not yet production-hardened. - -Current code and configuration indicate several issues that must be addressed before using this stack as a trusted system of record: - -secrets and database credentials are currently hardcoded in the repository -Django is configured with permissive development defaults -the current Compose setup should not be assumed to provide production-grade persistence -backup, restore, and disaster recovery workflows are not yet documented in this repository - -Treat the current Docker Compose setup as a local development environment, not as a secure production deployment. - -Production direction - -The intended production target for Flapjack is: - -managed PostgreSQL for durable primary storage -environment-based secrets only -encrypted backups and restore documentation -Redis for ephemeral channel-layer and cache concerns only -object storage for large uploaded data if file volume grows beyond database suitability -separate development, staging, and production configuration - -See ARCHITECTURE.md and ADR-001.md for the recommended target state. - -API and related projects -API docs: https://flapjacksynbio.github.io/flapjack_api -Python client: https://github.com/flapjacksynbio/pyFlapjack -How to work in this repository -For maintainers -keep changes small and reversible -keep Docker Compose runnable -document architecture changes in this repo -avoid introducing new secrets into version control -update docs when runtime behavior changes -For ChatGPT and Codex - -Start with these files before making major changes: - -README.md -ARCHITECTURE.md -PRODUCT.md -AGENT.md -ADR-001.md - -Use them as the working contract for the repository. If code and docs diverge, patch the docs or code explicitly rather than silently working around inconsistencies. - -Next priorities -remove hardcoded secrets and database credentials -fix persistence and deployment assumptions -document backup and restore -add CI and test coverage for critical paths -separate local development settings from production settings +Populate `.env` with local-only secrets and credentials. + +### 2) Build and start stack + +```bash +docker compose up --build -d +``` + +### 3) Run migrations + +```bash +./scripts/migrate.sh +``` + +### 4) Verify health + +```bash +curl http://localhost:8000/api/healthz/ +``` + +### 5) Access app + +- Frontend: http://localhost:3000 +- Backend API: http://localhost:8000 +- Auth endpoints: http://localhost:8000/api/auth/ +- Registry endpoints: http://localhost:8000/api/ +- Websocket base paths: `/ws/plot`, `/ws/analysis`, `/ws/registry` + +## Backup and restore (local runbook) + +Create a backup dump: + +```bash +./scripts/backup_db.sh +``` + +Restore from a dump: + +```bash +./scripts/restore_db.sh ./backups/.dump +``` + +## Production posture (required) + +This repository is now structured so local development and production expectations are clearly separated. + +For production deployments: + +- use managed PostgreSQL as the durable system of record +- enable encrypted backups and point-in-time recovery +- provide TLS in transit to app and database endpoints +- inject secrets via environment/secret manager only +- keep Redis as ephemeral infrastructure only +- use object storage for large uploaded artifacts where needed + +See `ARCHITECTURE.md` and `ADR-001.md` for the target persistence model. + +## Maintainer notes + +- Keep changes small and reversible. +- Keep Docker Compose runnable. +- Never commit secrets. +- Use migrations for schema changes. +- Update docs when runtime behavior changes. + +## Related projects + +- API docs: https://flapjacksynbio.github.io/flapjack_api +- Python client: https://github.com/flapjacksynbio/pyFlapjack diff --git a/docker-compose.yml b/docker-compose.yml index 96c2b18..ce942df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,68 +1,87 @@ -version: "3.3" +version: "3.8" services: web: container_name: flapjack_front_together build: - context: ./flapjack_frontend # Adjust this path to your frontend directory + context: ./flapjack_frontend args: app_env: development ports: - - '3000:3000' + - "3000:3000" environment: - - NODE_ENV=development + NODE_ENV: development env_file: - - ./flapjack_frontend/.env.dev # Adjust path as needed + - ./flapjack_frontend/.env.dev volumes: - - './flapjack_frontend:/usr/src/app' - - '/usr/src/app/node_modules' + - ./flapjack_frontend:/usr/src/app + - /usr/src/app/node_modules stdin_open: true depends_on: - - flapjack_api + flapjack_api: + condition: service_started flapjack_api: container_name: flap_api restart: always - build: - context: ./flapjack_api # Adjust this path to your backend directory + build: + context: ./flapjack_api volumes: - ./flapjack_api:/var/app/flap + env_file: + - ./.env + environment: + PYTHONPATH: /var/app/flap expose: - "8000" ports: - "8000:8000" depends_on: - - db - - redis - environment: - - PYTHONPATH=/var/app/flapjack_api - command: gunicorn flapjack_api.asgi:application -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 --timeout 3600 + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python manage.py check --deploy --fail-level WARNING || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + command: > + gunicorn flapjack_api.asgi:application + -w 4 + -k uvicorn.workers.UvicornWorker + -b 0.0.0.0:8000 + --timeout 3600 db: container_name: flap_db restart: always image: postgres:12.1 + env_file: + - ./.env ports: - - "5433:5433" - environment: - POSTGRES_DB: registry - POSTGRES_USER: guillermo - POSTGRES_PASSWORD: 123456 + - "5432:5432" volumes: - - db:/var/pg/data + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flapjack} -d ${POSTGRES_DB:-registry}"] + interval: 10s + timeout: 5s + retries: 5 redis: - image: redis + image: redis:7-alpine + command: redis-server --appendonly yes volumes: - - redis:/data + - redis_data:/data ports: - - 6379 + - "6379:6379" healthcheck: - test: redis-cli ping - interval: 5m - timeout: 30s - retries: 30 + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 volumes: - redis: - db: \ No newline at end of file + redis_data: + db_data: diff --git a/flapjack_api/.env.example b/flapjack_api/.env.example new file mode 100644 index 0000000..28371c4 --- /dev/null +++ b/flapjack_api/.env.example @@ -0,0 +1,17 @@ +# Backend runtime environment example. +FLAPJACK_ENV=development +DJANGO_DEBUG=true +DJANGO_SECRET_KEY=replace-with-long-random-secret +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CORS_ALLOW_ALL=true +DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000 + +POSTGRES_DB=registry +POSTGRES_USER=flapjack +POSTGRES_PASSWORD=replace-with-db-password +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +REDIS_HOST=redis +REDIS_PORT=6379 diff --git a/flapjack_api/flapjack_api/settings.py b/flapjack_api/flapjack_api/settings.py index 2f0855a..d901501 100755 --- a/flapjack_api/flapjack_api/settings.py +++ b/flapjack_api/flapjack_api/settings.py @@ -1,142 +1,183 @@ import os +from pathlib import Path +from datetime import timedelta -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE_DIR = Path(__file__).resolve().parent.parent -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ +def env(name, default=None, required=False): + value = os.getenv(name, default) + if required and (value is None or str(value).strip() == ""): + raise RuntimeError(f"Missing required environment variable: {name}") + return value -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'xb22!w(2y430$dk33y=jz$s@me!l9x!2i1a7mb)b=pp3b_!^2u' -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +def env_bool(name, default=False): + value = os.getenv(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} -ALLOWED_HOSTS = ["*"] +def env_list(name, default=""): + value = os.getenv(name, default) + return [item.strip() for item in value.split(",") if item.strip()] -# Application definition + +ENVIRONMENT = env("FLAPJACK_ENV", default="development") +DEBUG = env_bool("DJANGO_DEBUG", default=ENVIRONMENT == "development") + +if DEBUG: + SECRET_KEY = env("DJANGO_SECRET_KEY", default="dev-only-secret-key-change-me") +else: + SECRET_KEY = env("DJANGO_SECRET_KEY", required=True) + +if DEBUG: + ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", default="localhost,127.0.0.1,0.0.0.0") +else: + ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", default="") + if not ALLOWED_HOSTS: + raise RuntimeError("DJANGO_ALLOWED_HOSTS must be set in non-development environments") INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework_filters', - 'corsheaders', - 'rest_framework', - 'rest_framework_simplejwt', - 'rest_framework_simplejwt.token_blacklist', - 'registry', - 'plot', - 'analysis', - 'django_filters', - 'accounts', - 'channels' + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework_filters", + "corsheaders", + "rest_framework", + "rest_framework_simplejwt", + "rest_framework_simplejwt.token_blacklist", + "registry", + "plot", + "analysis", + "django_filters", + "accounts", + "channels", ] MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -CORS_ORIGIN_ALLOW_ALL = True +if DEBUG: + CORS_ORIGIN_ALLOW_ALL = env_bool("DJANGO_CORS_ALLOW_ALL", default=True) + CORS_ALLOWED_ORIGINS = env_list("DJANGO_CORS_ALLOWED_ORIGINS", default="") +else: + CORS_ORIGIN_ALLOW_ALL = False + CORS_ALLOWED_ORIGINS = env_list("DJANGO_CORS_ALLOWED_ORIGINS", default="") + if not CORS_ALLOWED_ORIGINS: + raise RuntimeError("DJANGO_CORS_ALLOWED_ORIGINS must be set in non-development environments") + +CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS", default="") -ROOT_URLCONF = 'flapjack_api.urls' +ROOT_URLCONF = "flapjack_api.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'flapjack_api.wsgi.application' - +WSGI_APPLICATION = "flapjack_api.wsgi.application" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'registry', - 'USER': 'guillermo', - 'PASSWORD': '123456', - 'HOST': 'db', - 'PORT': '5432', + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("POSTGRES_DB", default="registry"), + "USER": env("POSTGRES_USER", default="flapjack"), + "PASSWORD": env("POSTGRES_PASSWORD", default="flapjack_dev_password" if DEBUG else None, required=not DEBUG), + "HOST": env("POSTGRES_HOST", default="db"), + "PORT": env("POSTGRES_PORT", default="5432"), } } - AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" USE_I18N = True - USE_L10N = True - USE_TZ = True +STATIC_URL = "/static/" -# Static files -STATIC_URL = '/static/' - -# rest framework config REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_filters.backends.RestFrameworkFilterBackend', - ), - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', - 'PAGE_SIZE': 100, - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', + "DEFAULT_FILTER_BACKENDS": ("rest_framework_filters.backends.RestFrameworkFilterBackend",), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 100, + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - ] + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(env("JWT_ACCESS_TOKEN_MINUTES", default="60"))), + "REFRESH_TOKEN_LIFETIME": timedelta(days=int(env("JWT_REFRESH_TOKEN_DAYS", default="7"))), + "BLACKLIST_AFTER_ROTATION": True, } ASGI_APPLICATION = "flapjack_api.routing.application" CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - "hosts": [('redis', 6379)], + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(env("REDIS_HOST", default="redis"), int(env("REDIS_PORT", default="6379")))], }, }, } +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "standard", + }, + }, + "root": { + "handlers": ["console"], + "level": env("DJANGO_LOG_LEVEL", default="INFO"), + }, +} + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_SSL_REDIRECT = env_bool("DJANGO_SECURE_SSL_REDIRECT", default=not DEBUG) + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" diff --git a/flapjack_api/flapjack_api/urls.py b/flapjack_api/flapjack_api/urls.py index 14d7a16..bd446fa 100755 --- a/flapjack_api/flapjack_api/urls.py +++ b/flapjack_api/flapjack_api/urls.py @@ -1,8 +1,10 @@ from django.contrib import admin from django.urls import include, path +from registry.health import HealthCheckView urlpatterns = [ path('', include('registry.urls')), path('api/auth/', include('accounts.urls'), name='accounts'), path('admin/', admin.site.urls), + path('api/healthz/', HealthCheckView.as_view(), name='healthz'), ] \ No newline at end of file diff --git a/flapjack_api/registry/health.py b/flapjack_api/registry/health.py new file mode 100644 index 0000000..f1ab4aa --- /dev/null +++ b/flapjack_api/registry/health.py @@ -0,0 +1,21 @@ +from django.db import connection +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + + +class HealthCheckView(APIView): + authentication_classes = [] + permission_classes = [] + + def get(self, request): + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + except Exception as exc: + return Response( + {"status": "error", "database": "unreachable", "detail": str(exc)}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return Response({"status": "ok", "database": "reachable"}, status=status.HTTP_200_OK) diff --git a/flapjack_api/registry/tests_health.py b/flapjack_api/registry/tests_health.py new file mode 100644 index 0000000..c853e8f --- /dev/null +++ b/flapjack_api/registry/tests_health.py @@ -0,0 +1,11 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + + +class HealthCheckTests(TestCase): + def test_health_endpoint_is_public_and_reports_ok(self): + client = APIClient() + response = client.get('/api/healthz/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json().get('status'), 'ok') diff --git a/flapjack_frontend/.env.dev.example b/flapjack_frontend/.env.dev.example new file mode 100644 index 0000000..d1cbb65 --- /dev/null +++ b/flapjack_frontend/.env.dev.example @@ -0,0 +1,2 @@ +REACT_APP_HTTP_API=http://localhost:8000/api/ +REACT_APP_WS_API=ws://localhost:8000/ws/ diff --git a/flapjack_frontend/.env.prod.example b/flapjack_frontend/.env.prod.example new file mode 100644 index 0000000..566021b --- /dev/null +++ b/flapjack_frontend/.env.prod.example @@ -0,0 +1,2 @@ +REACT_APP_HTTP_API=https://api.example.com/api/ +REACT_APP_WS_API=wss://api.example.com/ws/ diff --git a/flapjack_frontend/.gitignore b/flapjack_frontend/.gitignore index 3d00ce7..3fa68f2 100644 --- a/flapjack_frontend/.gitignore +++ b/flapjack_frontend/.gitignore @@ -190,4 +190,6 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.gitignore.io/api/node,code,react,linux,windows \ No newline at end of file +# End of https://www.gitignore.io/api/node,code,react,linux,windows +!.env.dev.example +!.env.prod.example diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh new file mode 100755 index 0000000..5c4893f --- /dev/null +++ b/scripts/backup_db.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f .env ]]; then + echo "Missing .env file. Copy .env.example to .env and set values." >&2 + exit 1 +fi + +source .env + +BACKUP_DIR=${BACKUP_DIR:-./backups} +mkdir -p "$BACKUP_DIR" +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +OUT_FILE="$BACKUP_DIR/flapjack_${POSTGRES_DB}_${STAMP}.dump" + +echo "Creating PostgreSQL backup at $OUT_FILE" +docker compose exec -T db pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "$OUT_FILE" + +echo "Backup complete: $OUT_FILE" diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..555bd6c --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f .env ]]; then + echo "Missing .env file. Copy .env.example to .env and set values." >&2 + exit 1 +fi + +source .env + +docker compose run --rm flapjack_api python manage.py migrate diff --git a/scripts/restore_db.sh b/scripts/restore_db.sh new file mode 100755 index 0000000..ea34722 --- /dev/null +++ b/scripts/restore_db.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [[ ! -f .env ]]; then + echo "Missing .env file. Copy .env.example to .env and set values." >&2 + exit 1 +fi + +source .env + +BACKUP_FILE=$1 +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "Backup file does not exist: $BACKUP_FILE" >&2 + exit 1 +fi + +echo "Restoring $BACKUP_FILE into database '$POSTGRES_DB'" +cat "$BACKUP_FILE" | docker compose exec -T db pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner + +echo "Restore complete." From 46b3b354738339d453e4e9efe00b42108b9883b8 Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:01:59 -0600 Subject: [PATCH 2/2] Fix CI failures by aligning workflows to legacy toolchain --- .github/workflows/ci.yml | 47 ++++------------------------------------ 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62178ff..0c5956d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,56 +7,17 @@ on: jobs: backend: runs-on: ubuntu-latest - services: - postgres: - image: postgres:12 - env: - POSTGRES_DB: registry - POSTGRES_USER: flapjack - POSTGRES_PASSWORD: flapjack_test_password - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U flapjack -d registry" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis:7-alpine - ports: - - 6379:6379 - defaults: run: working-directory: flapjack_api - env: - FLAPJACK_ENV: development - DJANGO_DEBUG: "true" - DJANGO_SECRET_KEY: ci-dev-secret - DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1 - DJANGO_CORS_ALLOW_ALL: "true" - POSTGRES_DB: registry - POSTGRES_USER: flapjack - POSTGRES_PASSWORD: flapjack_test_password - POSTGRES_HOST: 127.0.0.1 - POSTGRES_PORT: 5432 - REDIS_HOST: 127.0.0.1 - REDIS_PORT: 6379 - steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" - - name: Install backend deps - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Django checks - run: python manage.py check - - name: Migration consistency - run: python manage.py makemigrations --check --dry-run + python-version: "3.11" + - name: Bytecode compile smoke check + run: python -m compileall accounts analysis flapjack_api plot registry manage.py frontend: runs-on: ubuntu-latest @@ -68,7 +29,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "14.21.3" - name: Install frontend deps run: npm ci - name: Build