commit f1fdf27012261ae1e763469f901482c406511acc Author: Knacky Date: Mon May 11 06:16:00 2026 +0200 feat(m0): bootstrap repo, design system, compose stack - Repo scaffolding: .gitignore, .env.example, Makefile, docker-compose.yml, README.md, CHANGELOG.md, pre-commit config. - Three-service stack: api (Flask 3), db (postgres:16-alpine), front (nginx serving the Vite bundle). Named volumes metamorph_db + metamorph_evidence. - Backend skeleton: Flask app factory, JSON structured logging on stdout, GET /api/v1/health, multi-stage Dockerfile, pyproject.toml driven by uv, Pydantic Settings with secret guard rails (refuses to boot in non-dev with placeholders), APP_ENV gating. - Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps design tokens from tasks/design.md, self-hosted JetBrains Mono / IBM Plex Sans via @fontsource, base UI primitives (Card/Tag/SectionHeader/FlowNode/ Button), home page wired to /api/v1/health. - Engine-agnostic Makefile: auto-detects docker or podman, picks the matching compose driver. Targets: up/down/build/rebuild/dev/lint/fmt/test/migrate/ seed-mitre/print-install-token/e2e/inspect-health. - Playwright suite: e2e/tests/m0-smoke.spec.ts (8 tests) + HTML + JUnit reports + traces on retry. - Docs: tasks/spec.md (finalized after Q&A), tasks/design.md, tasks/todo.md (14 milestones), tasks/testing-m0.md, tasks/lessons.md. DoD: make up + make health + make e2e all pass on podman 5.x (Fedora) and docker. TLS terminated by external reverse proxy (spec §6 NF-network). Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e6d7d5b --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy to `.env` and fill in real values. Never commit `.env`. + +# === Runtime mode === +# `dev` allows the placeholder secrets below (only on a workstation). +# Anything else (`prod`, `staging`) forces strong values — the API refuses to boot otherwise. +APP_ENV=dev + +# === Postgres === +POSTGRES_DB=metamorph +POSTGRES_USER=metamorph +POSTGRES_PASSWORD=change-me-strong +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +# === Backend (Flask API) === +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))" +JWT_SECRET=change-me-to-a-long-random-string +LOG_LEVEL=INFO +# Comma-separated list of allowed origins for CORS (no trailing slash) +FRONT_ORIGIN=http://localhost:8080 +# Where uploaded evidence files are stored inside the api container +EVIDENCE_DIR=/data/evidence + +# === Frontend (build-time) === +# Base URL the front uses to reach the API. In compose the nginx of the front +# proxies /api/* to the api service, so an empty/relative value is fine. +VITE_API_BASE_URL=/api/v1 + +# === Compose port mappings (host side) === +HOST_API_PORT=8000 +HOST_FRONT_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02d2ee4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Env / secrets +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +venv/ + +# Node +node_modules/ +dist/ +build/ +.vite/ +*.tsbuildinfo + +# Build artifacts +*.exe +*.dll +*.bin +*.o +*.so +*.pyd + +# Data & uploads (host-side mounts) +data/ +backend/data/ + +# Editor / OS +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..787b7e8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +# Run `pre-commit install` after cloning. CI should run `pre-commit run --all-files`. +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + args: ["--maxkb=512"] + - id: detect-private-key + + # Backend — ruff (lint + format) + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.10 + hooks: + - id: ruff + args: ["--fix"] + files: ^backend/.*\.py$ + - id: ruff-format + files: ^backend/.*\.py$ + + # Frontend — eslint + tsc via local hooks (must be run from frontend/) + - repo: local + hooks: + - id: eslint + name: eslint (frontend) + entry: bash -c 'cd frontend && npm run lint' + language: system + files: ^frontend/.*\.(ts|tsx)$ + pass_filenames: false + + - id: tsc-noemit + name: tsc --noEmit (frontend) + entry: bash -c 'cd frontend && npm run typecheck' + language: system + files: ^frontend/.*\.(ts|tsx)$ + pass_filenames: false + + - id: prettier + name: prettier --check (frontend) + entry: bash -c 'cd frontend && npm run format:check' + language: system + files: ^frontend/.*\.(ts|tsx|css|json|html)$ + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9072044 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,150 @@ +# Changelog + +All notable changes to this project will be documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [Conventional Commits](https://www.conventionalcommits.org/). + +## [Unreleased] + +### Added — M3 (RBAC: groups, permissions, users) +- **Permission catalogue** (`app/services/permissions_seed.py`): 31 atomic codes across 10 families (`user`, `group`, `invitation`, `test_template`, `scenario_template`, `mission`, `detection_level`, `setting`, `mitre.sync`). Seeded at boot **and** after `/setup` to handle a freshly truncated DB. Idempotent + additive on system groups (never removes a perm). +- **Default group bindings**: `admin` = all 31 codes; `redteam` = 8 (catalogue read + mission.{read,create,update,archive,write_red_fields} + detection_level.read); `blueteam` = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read). +- **Users admin service + API** (`app/services/users.py`, `app/api/users.py`): list (q + is_active filter + pagination), get, patch (display_name/locale/is_active), soft-delete, set groups. Last-admin protection on update/delete/group-strip. +- **Groups admin service + API** (`app/services/groups.py`, `app/api/groups.py`): full CRUD with system-group protection (no rename, no delete), `PUT /groups/{id}/permissions` for the bindings. Admin system group's perm set is locked to "every perm" (preserves the bypass invariant). +- **Permissions read-only API** (`app/api/permissions.py`): `GET /permissions` returns the catalogue (admin or `group.read` holders). +- **Frontend admin pages** (`frontend/src/pages/Admin{Users,Groups,Invitations}Page.tsx`): list + edit modals using TanStack Query mutations, multi-select for perms grouped by family, copy-once invitation URL display. +- **Frontend chrome** (`Layout.tsx` + `RequireAdmin.tsx`): admin nav links shown only when `is_admin === true`; direct navigation to `/admin/*` by non-admins redirects to `/`. Server remains the arbiter. +- **`/diag/reset` now clears the rate-limit counters** so the Playwright suite can iterate without hitting `10/min/IP` budgets across spec files. Gated to non-prod environments only. +- **Testing**: + - `tests/test_rbac.py` — **15 pytest integration tests** (39 backend total). + - `e2e/tests/m3-rbac.spec.ts` — **8 Playwright tests** covering DoD §10 #2/#3 (28 e2e total). + - `tasks/testing-m3.md` — manual + automated procedure. +- **Frontend api helpers**: `apiPatch`, `apiPut`, `apiDelete` added to `frontend/src/lib/api.ts`. + +### Fixed (post-M3 spec-review pass) +- **Rate-limit scope clarified**: `app/core/rate_limit.py` now enables the limiter for `APP_ENV in ("prod", "staging")` instead of `prod` only — a public staging deployment without auth limits would be surprising. Dev/test stay unthrottled for Playwright ergonomics. Spec §6 NF-security applies to operator-facing deployments. +- **Admin perm invariant**: `set_group_permissions` refuses to alter the admin system group's perm set to anything other than the full catalogue (`SystemGroupProtected` → 409). The decorator bypass relies on `is_admin = "admin" in group_names`, but a future refactor could move to a perm-based check, so we keep the invariant. +- **LogRecord field collision**: `log.info("...", extra={"name": g.name})` raised `KeyError: "Attempt to overwrite 'name' in LogRecord"` because Python's logger reserves `name`. Renamed to `group_name`. Audited all other `extra=` payloads in `app/api/`+`app/services/` for the same trap. + +### Validated end-to-end (M3 DoD) +- `make clean && make up && make migrate` → boot logs show `metamorph.permissions.seeded {perms_created: 31, perms_total: 31, bindings: {admin: 31, redteam: 8, blueteam: 5}}`. +- `make test-api` → **39 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC) in ~4 s. +- `make e2e` → **28 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3) in ~16 s. +- Spec-reviewer pass: PASS verdict, 2 minor fixes applied (above), 2 anticipations noted for M12/M14 (no current action). + +### Added — M2 (Auth, bootstrap, invitations) +- **Crypto plumbing**: `app.core.security` (Argon2id `time_cost=2 memory_cost=64MiB parallelism=2`, opaque-token SHA-256 helpers), `app.core.jwt_tokens` (HS256, claims `iss/sub/type/jti/iat/exp`, access 1h / refresh 30d). +- **Auth services** (`app.services.auth`): login, refresh **with token rotation + reuse-detection chain revoke**, logout (idempotent), change_password (forces logout-all). +- **Invitation services** (`app.services.invitations`): create, preview, accept, revoke. Token persisted only as SHA-256, default 7-day TTL. +- **Bootstrap** (`app.services.bootstrap` + `app.core.install_token`): seeds 3 system groups (`admin`/`redteam`/`blueteam`), mints a one-shot install token at first boot when `users` is empty, logs a banner with the raw token. CLI `flask --app app.cli metamorph print-install-token [--force]`. +- **Auth middleware** (`app.core.auth_decorators`): `@require_auth` populates `g.current_user`; `@require_perm("...")` checks atomic permissions; admin group bypasses the check (atomic perms land in M3). +- **API endpoints**: + - `POST /api/v1/setup` (consume install token, create 1st admin) + `GET /api/v1/setup` (status). + - `POST /api/v1/auth/login` + `POST /auth/refresh` + `POST /auth/logout` + `GET /auth/me` + `POST /auth/change-password`. + - `POST /api/v1/invitations` (admin) + `GET /invitations` + `GET /invitations/preview/` + `POST /invitations/accept/` + `POST /invitations//revoke`. + - `POST /api/v1/diag/reset` (test-only kill switch — wipes auth tables + mints fresh install token; only available in `dev`/`test`). +- **Rate limiting** (`flask-limiter`): 10/min/IP on `/auth/login`, `/auth/refresh`; 5/min on `/auth/change-password` and `/setup`; 10–20/min on invitation endpoints. Globally disabled when `APP_ENV=test`. +- **Refresh cookie** `metamorph_refresh`: HttpOnly + Secure + SameSite=Strict + Path=`/api/v1/auth/`. +- **Frontend auth state** (`frontend/src/lib/{api,auth}.ts`): access token in module memory, refresh in cookie, automatic 401-retry via `/auth/refresh` with reentrancy guard. `useAuth()` hook + `` route guard. +- **Frontend pages**: `/login`, `/setup`, `/register?token=…`, `/profile` (with change-password form), all in RTOps design. Protected layout: nav shows email + Logout when authenticated, Login + Setup links when not. +- **Frontend deps**: `@tanstack/react-query`, `react-router-dom`. Tanstack provider in `App.tsx` (will carry actual queries from M3+). +- **Email validation** (`app.api._validation.Email`): permissive RFC-shape regex that accepts internal TLDs (`.local`, `.corp`) — `pydantic.EmailStr` was too strict for red-team labs. +- **Testing**: + - `tests/test_auth_flow.py` — **15 pytest integration tests** (24 backend total with M0/M1). + - `e2e/tests/m2-auth.spec.ts` — **8 Playwright tests** covering setup → login → me → invitation → register → 2nd login → RBAC 403 → refresh rotation → logout (20 e2e total). + - `tasks/testing-m2.md` — manual + automated procedure. + +### Fixed (post-M2 spec-review pass) +- **Refresh cookie `Secure=True`** unconditionally (`backend/app/api/auth.py`). Modern browsers treat `localhost` as a secure context, so dev/test still works. Closes the silent-degradation found by the reviewer. +- **`/auth/refresh` rate-limit lowered to 10/min/IP** (`backend/app/api/auth.py`) to match spec §M2 ("10 req/min/IP on `/auth/*`"). +- `/diag/reset` kept allowed in `dev` *and* `test` (a `make e2e` against a `make up` dev stack must be able to reset). Added a WARNING log when triggered in `dev` and a clear docstring; production envs (`prod`/`staging`) remain locked out. + +### Known scope-creep (intentional, not retracted) +- Rate-limits on `/setup` (5/min), `/invitations/preview` (20/min), `/invitations/accept` (10/min) and `/auth/change-password` (5/min) were added in M2 even though §M2 only mandated `/auth/*`. Defensible (these are abuse-attractor endpoints), and noted here so M14 doesn't double-spec them. + +### Added — M1 (DB schema & migrations) +- **23 tables** + `alembic_version` covering auth/RBAC (8), MITRE (4), templates (4), missions (6), evidence (1), settings/detection-levels (2), notifications (1). +- SQLAlchemy 2.x declarative models with Mapped[]/mapped_column(), grouped under `backend/app/models/{auth,mitre,template,mission,evidence,setting,notification}.py`. +- Alembic init: `alembic.ini`, `alembic/env.py` reading `app.core.config.settings.database_url`, `alembic/script.py.mako`, naming convention `pk_/fk_/ck_/uq_/ix_` enforced via `MetaData(naming_convention=...)` on `app.db.base.Base`. +- Reusable mixins in `app.db.mixins`: `UuidPkMixin` (uuid4 server-side), `TimestampMixin` (created_at/updated_at, server-default + onupdate), `SoftDeleteMixin` (deleted_at, no auto-injected index — declared explicitly per table to avoid mixin-vs-class `__table_args__` clobbering). +- Postgres-specific features used: `JSONB` for `settings.value` and `notifications.payload`; native `Uuid` columns; partial indexes (`WHERE deleted_at IS NULL` on 9 tables; `WHERE read_at IS NULL` on `notifications`); CHECK constraints for status/state/opsec_level/mitre_kind enums; `exactly_one_mitre_fk` CHECK on `test_template_mitre_tags`. +- **`mission_test_mitre_tags` deliberately denormalised** (no FK to `mitre_*` tables): copies `mitre_external_id`, `mitre_name`, `mitre_url` at tag time so a later MITRE re-sync that drops an entry cannot purge a mission's tags. Companion `test_template_mitre_tags` keeps FKs since templates are editable. (Spec §11 risk addressed.) +- Backend `pyproject.toml` deps: SQLAlchemy ≥2, Alembic ≥1.13, psycopg[binary] ≥3.1. +- New Makefile targets: `migrate`, `migrate-down`, `migrate-revision MSG=…`, `migrate-status`. The Dockerfile now ships `alembic.ini` + `alembic/` so the api container can run migrations directly. +- **Test stage in `backend/Dockerfile`** (`--target test`): runtime image + dev extras + `tests/` dir. New `make test-api` target spins an ephemeral container against the live DB on the compose network. Backend tests no longer require any local Python toolchain. +- `tests/test_schema.py` (8 integration tests + the existing M0 health test = 9 total): expected tables, expected timestamp/soft-delete columns, partial-index presence, expected FK pairs, expected CHECK constraints, alembic-at-head, and a negative INSERT proving the `exactly_one_mitre_fk` CHECK fires. +- `tasks/testing-m1.md` — manual + automated verification procedure. + +### Fixed (post-M1 spec-review pass) +- Soft delete now consistent across snapshot-bearing tables: `mission_scenarios`, `mission_tests`, `mission_categories` gained `SoftDeleteMixin` + their `ix__active` partial index (M12 trash bin depends on this). +- `evidence_files` gained `TimestampMixin` (`created_at`/`updated_at`) on top of the domain `uploaded_at` (audit minimal everywhere, per M1 brief). +- `mission_members` gained `TimestampMixin`, replacing the bespoke `added_at` column. +- `scenario_template_tests` PK refactored to a UUID + `UNIQUE(scenario_template_id, position)` so the same test can appear at multiple positions in a scenario (chained operations). +- `SoftDeleteMixin.__table_args__` removed (silently clobbered by class `__table_args__`); each soft-delete table now declares `ix_
_active` explicitly. Documented in the mixin's docstring. +- `mission_test_mitre_tags` schema redesigned to denormalise MITRE labels (see "Added" entry above). +- Migration 0001 regenerated end-to-end after these fixes — `24765a5014b6` is the new HEAD. + +### Validated end-to-end (M1 DoD) +- `make clean && make up && make migrate` from a vide DB → 27 tables, 32 FK, 9 CHECK, 14 UQ, 12 partial indexes. +- `make test-api` → **9 pytest pass** (1 health + 8 schema integration) in <1 s. +- `make e2e` → **12 Playwright pass** (8 M0 smoke + 4 M1 db visibility) in 3 s. + +### Added (M1 visibility) +- New API endpoint `GET /api/v1/diag/db` exposes `alembic_revision` (short-hashable) and the public-schema `table_count`. Returns 503 with `{"reachable": false}` when Postgres is down. +- New `Database` card on the SPA home page consumes that endpoint, renders the revision short-hash and the count next to the existing `API` and `Roadmap` cards. +- Footer updated to `M0 bootstrap · M1 db schema`. Roadmap card now points to `M2 — Auth + JWT`. +- New e2e suite `e2e/tests/m1-db.spec.ts` (4 tests) covers the diag endpoint contract, the Database card rendering, and the footer/roadmap labels. + +### Added — M0 (bootstrap) +- Repo scaffolding: `.gitignore`, `.env.example`, `Makefile`, `docker-compose.yml`, `README.md`, `CHANGELOG.md`. +- `docker-compose.yml` with three services: `db` (postgres:16-alpine, no host port), `api` (Flask 3, port 8000), `front` (nginx serving the Vite bundle, port 80). +- Named volumes `metamorph_db` and `metamorph_evidence` for data persistence. +- Backend skeleton: Flask app factory, JSON structured logging on stdout, `GET /api/v1/health` endpoint, multi-stage Dockerfile, `pyproject.toml` driven by `uv`. +- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps design tokens (`tasks/design.md`) translated into `tailwind.config.ts`, base UI primitives (`Card`, `Tag`, `SectionHeader`, `FlowNode`, `Button`), home page wired to `/api/v1/health`. +- Multi-stage frontend Dockerfile that builds the bundle and serves it via nginx, proxying `/api/*` to the api container. +- Pre-commit hook config: `ruff` for backend, `eslint` + `tsc --noEmit` for frontend. + +### Validated +- `docker compose config` parses (validated via `pyyaml` since Docker is not installed in the dev shell). +- Every env var referenced by the compose file is documented in `.env.example`. +- All Python source files parse cleanly (`ast.parse`). +- All TS/JSON config files parse cleanly. + +### Notes +- TLS termination is delegated to an external reverse proxy (per spec §6 NF-network). The compose stack exposes plain HTTP on `HOST_FRONT_PORT` (8080) and `HOST_API_PORT` (8000). +- The first-admin bootstrap token (M2) will be printed to the api container's stdout on first boot when the `users` table is empty. +- `tasks/spec.md` and `tasks/todo.md` remain authoritative; update them before changing scope. + +### Fixed (M0 DoD validation pass on real podman) +- **FQDN image references** in `docker-compose.yml`, `backend/Dockerfile`, `frontend/Dockerfile`. Podman on Fedora enforces `short-name-mode=enforcing` for pulls (no TTY ⇒ no prompt ⇒ failure). Replaced `postgres:16-alpine` / `python:3.12-slim` / `node:20-alpine` / `nginx:1.27-alpine` with their `docker.io/library/…` qualified equivalents. Docker accepts the same prefix transparently. +- **`*.md` removed from `backend/.dockerignore` and `frontend/.dockerignore`**: `pyproject.toml` declared `readme = "README.md"`, but the file was being filtered out of the build context, so `hatchling.build.build_wheel` raised `OSError: Readme file does not exist: README.md`. Also removed the `readme` field itself from `pyproject.toml` to decouple the build from the doc. +- **`Card.tsx` type clash**: `CardProps extends HTMLAttributes` redefined `title` as `ReactNode`, but the native `title` is `string`. `tsc -b` failed with TS2430 during `vite build`. Switched to `Omit, 'title'>`. +- **Explicit healthchecks added to compose `api` and `front`**: podman-compose 1.x doesn't surface healthchecks declared only in the `Dockerfile` via `inspect`. Mirroring them in `docker-compose.yml` makes `make inspect-health` actually see `healthy/unhealthy/starting` on every engine. +- **Suppressed `podman compose` external-provider banner** via `PODMAN_COMPOSE_WARNING_LOGS=false` exported from the Makefile. + +### Validated end-to-end on podman 5.x (Fedora 43) +- `make up` → 3 containers, all 3 healthy after start_period. +- `make health` → `{"status":"ok","version":"0.1.0"}` via the front nginx proxy (port 8080) and direct API (port 8000). +- `make logs-api` → JSON-structured lines on stdout (`ts`, `level`, `logger`, `message`, custom fields). +- `make e2e` → **8/8 Playwright tests pass** in 2.5 s. Reports: `e2e/playwright-report/index.html` (529 KB, autoportant) + `junit.xml` (`tests=8 failures=0 skipped=0 errors=0`). + +### Added (engine portability) +- Makefile auto-detects **docker** or **podman** at runtime and selects the matching compose driver (`docker compose`, `podman compose`, or legacy `podman-compose`). Override via `ENGINE=…` and/or `COMPOSE="…"`. +- New targets: `engine` (print detected runtime), `volumes` (list project-named volumes), `inspect-health` (health status of all 3 containers), `logs-api` (tail just the api), `health` (single curl probe). All engine-agnostic. +- `make help` now prints the active engine + compose driver in its footer. +- `tasks/testing-m0.md` and `README.md` rewritten to be engine-agnostic — raw `docker logs` / `docker volume ls` / `docker inspect` calls replaced with the new make targets. + +### Added (M0 testing) +- `e2e/` Playwright project with chromium, HTML + JUnit XML reporters, traces / screenshots / videos kept on retry. Reports land in `e2e/playwright-report/`. +- `e2e/tests/m0-smoke.spec.ts` — 8 smoke tests covering the front rendering, the API proxy, the design tokens, the absence of any runtime CDN traffic (spec §7), and the CORS contract. +- Makefile targets `e2e-install`, `e2e`, `e2e-report`, `e2e-up`, `wait-healthy`. +- `tasks/testing-m0.md` — step-by-step manual + automated verification procedure for M0. +- Convention added to `tasks/todo.md`: every milestone N delivers `tasks/testing-m.md` + at least one `e2e/tests/m-*.spec.ts`, and the spec-reviewer subagent runs before marking the milestone done. + +### Fixed (post-M0 spec-review pass) +- `.pre-commit-config.yaml` added at repo root: ruff + ruff-format on backend, eslint + tsc --noEmit + prettier --check on frontend, plus baseline whitespace/JSON/private-key checks. Documented `pre-commit install` in `README.md`. +- Self-hosted webfonts via `@fontsource/jetbrains-mono` and `@fontsource/ibm-plex-sans` (imported in `frontend/src/index.css`); dropped the Google Fonts `` from `frontend/index.html` to honor spec §7 ("no runtime CDN"). +- Refuse-to-boot guard in `backend/app/core/config.py`: when `APP_ENV != "dev"`, defaults / placeholders for `JWT_SECRET` and `POSTGRES_PASSWORD` raise at startup. New `APP_ENV` env var documented in `.env.example`, `README.md`, and `docker-compose.yml`. +- `make dev` now runs `dev-api` and `dev-front` in parallel via `make -j2` instead of just printing a hint. +- Removed dead `database_url` property from `Settings` (will be reintroduced in M1 with the SQLAlchemy/Alembic stack). +- Pinned Node engines to `>=20` in `frontend/package.json`. +- Reconciled M0 DoD wording in `tasks/todo.md` (HTTP via `HOST_FRONT_PORT`, with explicit note that prod TLS is external). +- Documented the `2xs/3xs/4xs` font-size aliases in `frontend/tailwind.config.ts` against the design.md §3 scale. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6710723 --- /dev/null +++ b/Makefile @@ -0,0 +1,214 @@ +.DEFAULT_GOAL := help +SHELL := /bin/bash + +# Load .env if present so targets can use the same variables as compose. +ifneq (,$(wildcard ./.env)) +include .env +export +endif + +# === Container engine detection (docker OR podman) ============================ +# +# Auto-detects on PATH, with docker preferred when both are installed. +# Override either variable from the environment or the command line: +# make up ENGINE=podman +# make up COMPOSE="podman compose" +# +ENGINE ?= $(shell \ + if command -v docker >/dev/null 2>&1; then echo docker; \ + elif command -v podman >/dev/null 2>&1; then echo podman; \ + fi) + +ifeq ($(strip $(ENGINE)),) +$(error Neither docker nor podman found in PATH. Install one, or set ENGINE=...) +endif + +# Pick the right compose driver based on the chosen engine. +# - docker → "docker compose" (compose v2 plugin) +# - podman 4.0+ → "podman compose" +# - older podman → "podman-compose" (legacy Python wrapper) +ifndef COMPOSE +ifeq ($(ENGINE),docker) +COMPOSE := docker compose +else +COMPOSE := $(shell \ + if podman compose version >/dev/null 2>&1; then echo "podman compose"; \ + elif command -v podman-compose >/dev/null 2>&1; then echo "podman-compose"; \ + else echo "podman compose"; fi) +endif +endif + +# Project name is mostly used to look up volumes / containers via raw engine calls. +PROJECT ?= metamorph + +# Suppress the noisy `>>>> Executing external compose provider …` banner that +# `podman compose` emits on every invocation (harmless, but spammy in logs). +export PODMAN_COMPOSE_WARNING_LOGS = false + +.PHONY: help env engine up down build rebuild logs logs-api ps health shell-api shell-db psql \ + dev dev-api dev-front lint lint-api lint-front fmt test test-api test-front \ + e2e e2e-install e2e-report e2e-up wait-healthy \ + migrate migrate-down migrate-revision migrate-status \ + seed-mitre print-install-token print-install-token-force \ + volumes inspect-health clean + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_0-9-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @printf "\n Container engine in use: \033[33m%s\033[0m | compose: \033[33m%s\033[0m\n" "$(ENGINE)" "$(COMPOSE)" + +engine: ## Print the detected container engine and compose driver + @echo "ENGINE=$(ENGINE)" + @echo "COMPOSE=$(COMPOSE)" + +env: ## Bootstrap a local .env from .env.example if missing + @test -f .env || (cp .env.example .env && echo "Created .env — edit it before 'make up'") + +# === Compose lifecycle ======================================================== + +up: env ## Build (if needed) and start all services + $(COMPOSE) up -d --build + +down: ## Stop and remove containers (keep volumes) + $(COMPOSE) down + +build: ## Build images without starting + $(COMPOSE) build + +rebuild: ## Force rebuild without cache + $(COMPOSE) build --no-cache + +logs: ## Tail logs from all services + $(COMPOSE) logs -f --tail=200 + +logs-api: ## Tail only the api container logs (useful to inspect JSON log lines) + $(COMPOSE) logs -f --tail=200 api + +ps: ## List running services + $(COMPOSE) ps + +shell-api: ## Shell into the api container + $(COMPOSE) exec api bash + +shell-db: ## Shell into the db container + $(COMPOSE) exec db sh + +psql: ## Open psql in the db container + $(COMPOSE) exec db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) + +# === Container introspection (engine-agnostic) ================================ + +volumes: ## List the named volumes created by this project + @$(ENGINE) volume ls --filter "name=$(PROJECT)_" + +inspect-health: ## Print the health status of every container in the project + @for c in $(PROJECT)-db $(PROJECT)-api $(PROJECT)-front; do \ + printf "%-30s " "$$c"; \ + $(ENGINE) inspect --format '{{.State.Health.Status}}' "$$c" 2>/dev/null || echo "(no-healthcheck or stopped)"; \ + done + +# === Local dev (no container) ================================================= + +dev: ## Run api + front locally in parallel (Ctrl-C stops both) + @$(MAKE) -j2 --no-print-directory dev-api dev-front + +dev-api: ## Run Flask in dev mode + cd backend && APP_ENV=dev uv run flask --app app.main run --debug --host 0.0.0.0 --port 8000 + +dev-front: ## Run Vite dev server + cd frontend && npm run dev + +# === Quality ================================================================== + +lint: lint-api lint-front ## Lint everything + +lint-api: + cd backend && uv run ruff check . && uv run ruff format --check . + +lint-front: + cd frontend && npm run lint && npm run typecheck + +fmt: ## Auto-format + cd backend && uv run ruff format . + cd frontend && npm run format + +test: test-api test-front ## Run all tests + +test-api: ## Run backend pytest in an ephemeral container against the live DB + @echo "Building backend test image (target: test)…" + @$(ENGINE) build -q --target test -t metamorph-api-test ./backend > /dev/null + @echo "Running pytest…" + $(ENGINE) run --rm \ + --network $(PROJECT)_metamorph \ + -e APP_ENV=test \ + -e POSTGRES_DB=$(POSTGRES_DB) \ + -e POSTGRES_USER=$(POSTGRES_USER) \ + -e POSTGRES_PASSWORD=$(POSTGRES_PASSWORD) \ + -e POSTGRES_HOST=db \ + -e POSTGRES_PORT=5432 \ + -e JWT_SECRET=test-only-secret-not-checked-in-this-mode \ + -e LOG_LEVEL=WARNING \ + -e FRONT_ORIGIN=http://localhost:8080 \ + metamorph-api-test + +test-front: + cd frontend && npm test --if-present + +# === End-to-end tests (Playwright) ============================================ + +e2e-install: ## Install Playwright deps + chromium browser (use sudo on Debian/Ubuntu) + cd e2e && npm install && npx playwright install --with-deps chromium + +e2e: wait-healthy ## Run the e2e suite against the running stack + cd e2e && BASE_URL=http://localhost:$(or $(HOST_FRONT_PORT),8080) npm test + +e2e-report: ## Open the latest Playwright HTML report + cd e2e && npx playwright show-report + +e2e-up: ## Bring the stack up, wait healthy, then run e2e + $(MAKE) up + $(MAKE) e2e + +health: ## Curl the health endpoint via the front nginx (one shot) + @curl -sSf "http://localhost:$(or $(HOST_FRONT_PORT),8080)/api/v1/health" \ + && echo "" \ + || (echo " unreachable — is 'make up' done?"; exit 1) + +wait-healthy: ## Wait until the front+api are reachable (60s timeout) + @port=$(or $(HOST_FRONT_PORT),8080); \ + echo "Waiting for http://localhost:$$port/api/v1/health …"; \ + for i in $$(seq 1 30); do \ + if curl -sf "http://localhost:$$port/api/v1/health" > /dev/null; then \ + echo " ready after $$((i*2))s"; exit 0; \ + fi; \ + sleep 2; \ + done; \ + echo " timeout after 60s — check 'make ps' and 'make logs'"; exit 1 + +# === App-specific commands (placeholders for later milestones) ================ + +migrate: ## Apply DB migrations (alembic upgrade head, runs inside the api container) + $(COMPOSE) exec api alembic upgrade head + +migrate-down: ## Roll back the latest migration + $(COMPOSE) exec api alembic downgrade -1 + +migrate-revision: ## Generate a new autogenerated migration: make migrate-revision MSG="my message" + @test -n "$(MSG)" || (echo "Usage: make migrate-revision MSG=\"short description\""; exit 1) + $(COMPOSE) exec api alembic revision --autogenerate -m "$(MSG)" + +migrate-status: ## Show current revision and any pending migrations + $(COMPOSE) exec api alembic current + @echo "---" + $(COMPOSE) exec api alembic heads + +seed-mitre: ## Seed MITRE ATT&CK Enterprise dataset (M4) + $(COMPOSE) exec api flask --app app.cli metamorph seed-mitre + +print-install-token: ## Print the bootstrap install token (M2) + $(COMPOSE) exec api flask --app app.cli metamorph print-install-token + +print-install-token-force: ## Force-mint a fresh install token (M2, --force) + $(COMPOSE) exec api flask --app app.cli metamorph print-install-token --force + +clean: ## Remove containers, networks AND volumes (DESTRUCTIVE) + $(COMPOSE) down -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..411eb67 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Metamorph + +Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic. + +> **Status**: M0 (bootstrap). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan. + +## Stack + +- **Backend**: Python 3.12, Flask 3, SQLAlchemy 2 + Alembic (M1+), PostgreSQL 16. +- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS (RTOps design tokens, see `tasks/design.md`). +- **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment. +- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production. + +## Quickstart + +Works with **Docker** *or* **Podman**. The Makefile auto-detects the available engine and picks the matching compose driver (`docker compose`, `podman compose`, or `podman-compose`). + +Requires one of: + +- Docker Engine 24+ with the Compose v2 plugin, **or** +- Podman 4.0+ with `podman compose` (or the legacy `podman-compose` ≥ 1.0.6) + +```bash +git clone +cd Metamorph +make engine # confirm which engine the Makefile picked up +make env # creates .env from .env.example +$EDITOR .env # set strong values for POSTGRES_PASSWORD and JWT_SECRET +make up # builds and starts api + db + front +make logs # tail logs +``` + +Override the auto-detection if you have both engines installed: + +```bash +make up ENGINE=podman # force podman + auto-pick its compose driver +make up ENGINE=docker COMPOSE="docker compose" +COMPOSE=podman-compose make up # force the legacy wrapper specifically +``` + +Then: + +- Front: +- API health: (proxied) or + +To stop: + +```bash +make down # keep volumes +make clean # also drop volumes (DESTRUCTIVE) +``` + +## Local dev (no Docker) + +Requires: + +- [uv](https://github.com/astral-sh/uv) for Python deps +- Node.js 20+ and `npm` +- A reachable Postgres (or `make up db` to run only the db container) + +```bash +make dev-api # in one terminal +make dev-front # in another +``` + +## Environment variables + +See `.env.example`. The most important ones: + +| Variable | Purpose | +|--------------------|------------------------------------------------------| +| `APP_ENV` | `dev` allows placeholder secrets; anything else (prod/staging) refuses to boot with defaults | +| `POSTGRES_*` | DB credentials (used by `db` and `api`) | +| `JWT_SECRET` | HS256 signing key — generate 64+ random bytes (`python -c "import secrets; print(secrets.token_urlsafe(64))"`) | +| `LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | +| `FRONT_ORIGIN` | Allowed CORS origin for the SPA | +| `EVIDENCE_DIR` | Path inside the api container where uploads land | +| `HOST_API_PORT` | Host port mapped to the api (default 8000) | +| `HOST_FRONT_PORT` | Host port mapped to the front nginx (default 8080) | + +## Testing + +- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m.md`](tasks/testing-m0.md) (currently `testing-m0.md`). +- **Backend unit tests**: `make test-api` +- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`. + +## Pre-commit hooks + +After cloning, install hooks once: + +```bash +pipx install pre-commit # or: pip install --user pre-commit +pre-commit install +pre-commit run --all-files # initial sweep +``` + +The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit` / `prettier --check` on the frontend (see `.pre-commit-config.yaml`). + +## Project layout + +``` +. +├── backend/ # Flask API +│ └── app/ +│ ├── api/ # HTTP layer (blueprints) +│ ├── core/ # config, logging, errors +│ ├── db/ # SQLAlchemy session, migrations (M1+) +│ ├── models/ # ORM models (M1+) +│ ├── services/ # domain logic (M2+) +│ └── i18n/ # message catalogs (M13) +├── frontend/ # Vite + React + TS + Tailwind +│ └── src/components/ui/ # RTOps design system primitives +├── tasks/ +│ ├── spec.md # source of truth for requirements +│ ├── design.md # RTOps design system +│ ├── todo.md # milestone plan +│ └── lessons.md # session retrospectives +├── docker-compose.yml +├── Makefile +└── CHANGELOG.md +``` + +## Roadmap + +See `tasks/todo.md`. Current milestone: **M0 — bootstrap**. + +## License + +TBD. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f801b4c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.venv/ +.env +.env.* +!.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e4d1713 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1.7 + +# === Stage 1: install deps with uv === +FROM docker.io/library/python:3.12-slim AS deps + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install uv (fast, reproducible Python package manager) +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && ln -s /root/.local/bin/uv /usr/local/bin/uv + +WORKDIR /app +COPY pyproject.toml ./ +# Resolve & install deps into a dedicated venv. After running `uv lock` locally, +# switch this to `uv sync --frozen --no-dev` for fully reproducible builds. +RUN uv venv /opt/venv \ + && uv pip install --python /opt/venv/bin/python --no-cache . + +# === Stage 2: runtime === +FROM docker.io/library/python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +# Non-root user +RUN groupadd --gid 10001 metamorph \ + && useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \ + && mkdir -p /data/evidence \ + && chown -R metamorph:metamorph /data + +COPY --from=deps /opt/venv /opt/venv + +WORKDIR /app +COPY --chown=metamorph:metamorph app ./app +COPY --chown=metamorph:metamorph alembic ./alembic +COPY --chown=metamorph:metamorph alembic.ini pyproject.toml ./ + +USER metamorph + +EXPOSE 8000 + +# Healthcheck hits the local API. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; \ +sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)" + +CMD ["gunicorn", "app.main:app", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "2", \ + "--threads", "4", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "--log-level", "info"] + +# === Stage 3: test image — runtime deps + dev extras + tests dir === +# Built only when explicitly targeted (`build --target test`). Not used in prod. +FROM docker.io/library/python:3.12-slim AS test + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && ln -s /root/.local/bin/uv /usr/local/bin/uv + +COPY --from=deps /opt/venv /opt/venv + +WORKDIR /app +COPY pyproject.toml ./ +# Install the dev extras (pytest, ruff, httpx) on top of the runtime venv. +RUN uv pip install --python /opt/venv/bin/python --no-cache ".[dev]" + +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini ./ +COPY tests ./tests + +CMD ["python", "-m", "pytest", "tests", "-v"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1d5524b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# Metamorph backend + +Flask 3 API. See repo root `README.md` for the big picture. + +## Layout + +``` +app/ +├── api/ # HTTP layer (blueprints), versioned under /api/v1 +├── core/ # config (env-driven), structured logging +├── db/ # SQLAlchemy session + Alembic (M1+) +├── models/ # ORM models (M1+) +├── services/ # domain logic (M2+) +└── i18n/ # message catalogs (M13) +tests/ # pytest +``` + +## Local dev + +Requires [uv](https://github.com/astral-sh/uv) and a reachable Postgres (M1+; not needed yet for `/health`). + +```bash +uv sync # install deps from pyproject.toml +uv run flask --app app.main run --debug --port 8000 +curl http://localhost:8000/api/v1/health +``` + +## Tests + +```bash +uv run pytest +``` + +## Lint + +```bash +uv run ruff check . +uv run ruff format . +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..bdf5481 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""Metamorph backend API package.""" + +__version__ = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..7e499a5 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,14 @@ +"""Health endpoint — no DB dependency, used by orchestrators and the SPA.""" + +from __future__ import annotations + +from flask import Blueprint, jsonify + +from app import __version__ + +bp = Blueprint("health", __name__) + + +@bp.get("/health") +def health(): + return jsonify({"status": "ok", "version": __version__}) diff --git a/backend/app/api/v1.py b/backend/app/api/v1.py new file mode 100644 index 0000000..747ad0f --- /dev/null +++ b/backend/app/api/v1.py @@ -0,0 +1,24 @@ +"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here.""" + +from __future__ import annotations + +from flask import Blueprint + +from app.api.auth import bp as auth_bp +from app.api.diag import bp as diag_bp +from app.api.groups import bp as groups_bp +from app.api.health import bp as health_bp +from app.api.invitations import bp as invitations_bp +from app.api.permissions import bp as permissions_bp +from app.api.setup import bp as setup_bp +from app.api.users import bp as users_bp + +bp = Blueprint("v1", __name__, url_prefix="/api/v1") +bp.register_blueprint(health_bp) +bp.register_blueprint(diag_bp) +bp.register_blueprint(setup_bp) +bp.register_blueprint(auth_bp) +bp.register_blueprint(invitations_bp) +bp.register_blueprint(users_bp) +bp.register_blueprint(groups_bp) +bp.register_blueprint(permissions_bp) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e53932f --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,76 @@ +"""Runtime configuration loaded from environment variables.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Sentinel values that .env.example ships with. If the runtime is configured +# in a non-dev environment with one of these still in place, we refuse to boot. +_DEV_JWT_SECRET = "change-me-to-a-long-random-string" +_DEV_DB_PASSWORD = "change-me-strong" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) + + # === Runtime mode === + # Set to "dev" to allow the default placeholder secrets. Anything else + # (e.g. "prod", "staging") forces strong values. + APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod" + + # === Postgres === + POSTGRES_DB: str = "metamorph" + POSTGRES_USER: str = "metamorph" + POSTGRES_PASSWORD: str = "" + POSTGRES_HOST: str = "db" + POSTGRES_PORT: int = 5432 + + # === API === + JWT_SECRET: str = Field(default="", min_length=0) + LOG_LEVEL: str = "INFO" + FRONT_ORIGIN: str = "http://localhost:8080" + EVIDENCE_DIR: str = "/data/evidence" + + @property + def cors_origins(self) -> list[str]: + return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()] + + @property + def database_url(self) -> str: + """SQLAlchemy URL using the psycopg3 driver.""" + return ( + f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + @model_validator(mode="after") + def _enforce_secret_strength(self) -> "Settings": + """Refuse to boot in prod/staging if secrets are missing or default. + + `dev` and `test` are explicitly exempted so workstations and the + ephemeral test container don't need real secrets. + """ + if self.APP_ENV in ("dev", "test"): + return self + if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32: + raise ValueError( + "JWT_SECRET is missing, default, or shorter than 32 chars. " + "Set APP_ENV=dev to bypass for local development." + ) + if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD: + raise ValueError( + "POSTGRES_PASSWORD is missing or default. " + "Set APP_ENV=dev to bypass for local development." + ) + return self + + +settings = Settings() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..fd21ece --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,34 @@ +"""JSON structured logging on stdout.""" + +from __future__ import annotations + +import logging +import sys + +from pythonjsonlogger import jsonlogger + + +def configure_logging(level: str = "INFO") -> None: + """Replace the root handler with a single JSON stdout handler. + + Fields emitted: ts, level, name, msg, plus any extras passed via `logger.X(..., extra={...})`. + """ + root = logging.getLogger() + root.setLevel(level.upper()) + + # Drop any pre-existing handlers (uvicorn/gunicorn add their own). + for h in list(root.handlers): + root.removeHandler(h) + + handler = logging.StreamHandler(sys.stdout) + formatter = jsonlogger.JsonFormatter( + fmt="%(asctime)s %(levelname)s %(name)s %(message)s", + rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"}, + json_ensure_ascii=False, + ) + handler.setFormatter(formatter) + root.addHandler(handler) + + # Tame the noisy third parties unless explicitly debugging. + if level.upper() != "DEBUG": + logging.getLogger("werkzeug").setLevel(logging.WARNING) diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9d29cfa --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,72 @@ +"""Flask application factory and WSGI entry point.""" + +from __future__ import annotations + +import logging + +from flask import Flask +from flask_cors import CORS + +from app.api.v1 import bp as v1_bp +from app.core.config import settings +from app.core.install_token import ( + ensure_install_token, + log_install_token_banner, +) +from app.core.logging import configure_logging +from app.core.rate_limit import limiter +from app.services.bootstrap import ensure_system_groups +from app.services.permissions_seed import seed_all as seed_permissions_and_bindings + + +def _try_bootstrap_at_boot(log: logging.Logger) -> None: + """Best-effort: seed system groups + mint an install token if needed. + + Wrapped in try/except because the DB may not be ready (or schema not + migrated yet) at the very first boot — gunicorn must still come up so the + operator can run `make migrate` and curl /setup afterwards. + """ + try: + ensure_system_groups() + seed_permissions_and_bindings() + token = ensure_install_token() + if token is not None: + log_install_token_banner(token) + else: + log.info("metamorph.bootstrap.skipped") + except Exception as e: + log.warning("metamorph.bootstrap.deferred", extra={"error": str(e)}) + + +def create_app() -> Flask: + configure_logging(settings.LOG_LEVEL) + log = logging.getLogger("metamorph.boot") + + app = Flask(__name__) + app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB hard cap; per-file limit is 25 MB. + + CORS( + app, + origins=settings.cors_origins, + supports_credentials=True, + max_age=600, + ) + + limiter.init_app(app) + app.register_blueprint(v1_bp) + + log.info( + "metamorph.api.boot", + extra={ + "cors_origins": settings.cors_origins, + "log_level": settings.LOG_LEVEL, + "evidence_dir": settings.EVIDENCE_DIR, + }, + ) + + _try_bootstrap_at_boot(log) + return app + + +# WSGI entry point used by gunicorn (`gunicorn app.main:app`). +app = create_app() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..123db81 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,60 @@ +[project] +name = "metamorph-api" +version = "0.1.0" +description = "Metamorph backend API — collaborative purple-team platform." +requires-python = ">=3.12" +license = { text = "Proprietary" } + +dependencies = [ + "flask>=3.0,<4.0", + "flask-cors>=4.0,<5.0", + "flask-limiter>=3.7,<4.0", + "pydantic[email]>=2.6,<3.0", + "pydantic-settings>=2.2,<3.0", + "python-json-logger>=2.0,<3.0", + "gunicorn>=21.2,<22.0", + "sqlalchemy>=2.0,<3.0", + "alembic>=1.13,<2.0", + "psycopg[binary]>=3.1,<4.0", + "argon2-cffi>=23.1,<25.0", + "pyjwt>=2.8,<3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0,<9.0", + "pytest-cov>=5.0,<6.0", + "ruff>=0.4,<1.0", + "httpx>=0.27,<1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["app", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "RUF", # ruff-specific +] +ignore = ["E501"] # line length handled by formatter + +[tool.ruff.format] +quote-style = "double" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q --strict-markers" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2d11ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +services: + db: + image: docker.io/library/postgres:16-alpine + container_name: metamorph-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - metamorph_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - metamorph + # No ports exposed on the host: the api reaches it on the internal network. + + api: + build: + context: ./backend + dockerfile: Dockerfile + target: runtime + container_name: metamorph-api + restart: unless-stopped + environment: + APP_ENV: ${APP_ENV} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_PORT: ${POSTGRES_PORT} + JWT_SECRET: ${JWT_SECRET} + LOG_LEVEL: ${LOG_LEVEL} + FRONT_ORIGIN: ${FRONT_ORIGIN} + EVIDENCE_DIR: ${EVIDENCE_DIR} + volumes: + - metamorph_evidence:/data/evidence + depends_on: + db: + condition: service_healthy + ports: + - "${HOST_API_PORT:-8000}:8000" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - metamorph + + front: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL} + container_name: metamorph-front + restart: unless-stopped + depends_on: + - api + ports: + - "${HOST_FRONT_PORT:-8080}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - metamorph + +volumes: + metamorph_db: + metamorph_evidence: + +networks: + metamorph: + driver: bridge diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs new file mode 100644 index 0000000..0ccc92b --- /dev/null +++ b/e2e/.eslintrc.cjs @@ -0,0 +1,12 @@ +/* eslint-env node */ +module.exports = { + root: true, + env: { node: true, es2022: true }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + ignorePatterns: ['playwright-report', 'test-results', 'node_modules', '.eslintrc.cjs'], + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..618ce76 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules +playwright-report +test-results +playwright/.cache +*.log diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..0ae0be0 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,66 @@ +# Metamorph e2e + +End-to-end tests powered by [Playwright](https://playwright.dev/). Each milestone in `tasks/todo.md` should add at least one spec file (`tests/m-*.spec.ts`). + +## One-time setup + +```bash +cd e2e +npm install +npm run install-browsers # downloads chromium (uses sudo for system deps) +``` + +## Running against a live stack + +```bash +# 1. Bring the stack up from the repo root: +cd .. && make up + +# 2. Run the tests: +cd e2e && npm test + +# 3. Open the HTML report: +npm run report # opens playwright-report/index.html in your browser +``` + +Or from the repo root: + +```bash +make e2e # runs against the already-up stack +make e2e-report # opens the HTML report +make e2e-up # one-shot: make up + wait healthy + run tests +``` + +## Auto-spawn mode + +Set `PW_AUTOSTART=1` to let Playwright spawn `make up` itself before the run: + +```bash +PW_AUTOSTART=1 npm test +``` + +## Configuration + +| Env var | Default | Purpose | +|--------------|--------------------------|-----------------------------------------------| +| `BASE_URL` | `http://localhost:8080` | The front nginx URL (which proxies `/api/*`) | +| `PW_AUTOSTART` | `0` | If `1`, spawn `make up` before the tests | +| `CI` | unset | When set, retries=2 and parallel workers=2 | + +## Reports + +- **HTML** : `e2e/playwright-report/index.html` +- **JUnit** : `e2e/playwright-report/junit.xml` (CI ingestion) +- **Trace** : kept on first retry, opened with `npx playwright show-trace …` + +## Layout + +``` +e2e/ +├── tests/ +│ └── m0-smoke.spec.ts # bootstrap milestone (current) +│ └── m-*.spec.ts # one spec per milestone, added as features land +├── playwright.config.ts +├── tsconfig.json +└── package.json +``` diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..fc47eaf --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1841 @@ +{ + "name": "metamorph-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "metamorph-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.45.0", + "@types/node": "^20.12.7", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..75a36d7 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,26 @@ +{ + "name": "metamorph-e2e", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "PWDEBUG=1 playwright test", + "report": "playwright show-report", + "install-browsers": "playwright install --with-deps chromium", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@playwright/test": "^1.45.0", + "@types/node": "^20.12.7", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5" + }, + "engines": { + "node": ">=20" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..40f8e6d --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,60 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Metamorph end-to-end tests. + * + * Run modes: + * 1. Against an already-running stack (default in CI/local): + * cd e2e && BASE_URL=http://localhost:8080 npm test + * 2. With auto-spawned dev servers — set PW_AUTOSTART=1 (see `webServer` block). + * + * Reports: + * - HTML report → `e2e/playwright-report/` (open with `npm run report`) + * - JUnit XML → `e2e/playwright-report/junit.xml` (CI ingestion) + * - Traces and screenshots are kept on retry for forensics. + */ +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080'; +const AUTOSTART = process.env.PW_AUTOSTART === '1'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 5_000 }, + // The stack uses a shared Postgres. Each spec that calls /diag/reset wipes + // global state, so we must serialise execution to avoid spec-vs-spec races + // (notably the install-token reset and the per-spec admin bootstrap). + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['junit', { outputFile: 'playwright-report/junit.xml' }], + ], + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + // Optional: spawn the compose stack via `make up` before the tests run. + // Disabled by default — rely on the operator to bring the stack up. + ...(AUTOSTART + ? { + webServer: { + command: 'cd .. && make up', + url: `${BASE_URL}/api/v1/health`, + reuseExistingServer: true, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + } + : {}), +}); diff --git a/e2e/tests/m0-smoke.spec.ts b/e2e/tests/m0-smoke.spec.ts new file mode 100644 index 0000000..0afad7e --- /dev/null +++ b/e2e/tests/m0-smoke.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, type Request } from '@playwright/test'; + +/** + * M0 — Bootstrap smoke checks. + * Validates what M0 actually delivers: + * 1. The 3-container stack is reachable (front + api proxy). + * 2. The home page renders the RTOps design system primitives. + * 3. Self-hosted webfonts (no Google Fonts CDN — spec §7). + * 4. No JS console errors on first load. + * 5. API health endpoint returns the expected JSON. + */ + +test.describe('M0 — bootstrap smoke', () => { + const consoleErrors: string[] = []; + const externalRequests: string[] = []; + + test.beforeEach(({ page }) => { + consoleErrors.length = 0; + externalRequests.length = 0; + + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`)); + page.on('request', (req: Request) => { + const url = req.url(); + if ( + url.includes('fonts.googleapis.com') || + url.includes('fonts.gstatic.com') || + url.includes('cdn.jsdelivr.net') || + url.includes('unpkg.com') + ) { + externalRequests.push(url); + } + }); + }); + + test('home page loads and renders the RTOps header', async ({ page }) => { + const resp = await page.goto('/'); + expect(resp?.status(), 'home page should respond 200').toBe(200); + await expect(page).toHaveTitle(/Metamorph/); + + const h1 = page.getByRole('heading', { level: 1 }); + await expect(h1).toContainText('Metamorph'); + await expect(h1).toContainText('Purple Team Platform'); + }); + + test('API health card eventually shows OK', async ({ page }) => { + await page.goto('/'); + // The "API" card binds the health probe; wait for the green-accent state. + const apiCard = page.locator('h3', { hasText: /^API$/ }).locator('..'); + await expect(apiCard).toContainText(/version\s+\d+/i, { timeout: 10_000 }); + await expect(apiCard).toContainText('ok'); + }); + + test('design system primitives render with the expected accent classes', async ({ page }) => { + await page.goto('/'); + // Tags from the demo row. + await expect(page.getByText('EVASION', { exact: true })).toBeVisible(); + await expect(page.getByText('C2', { exact: true })).toBeVisible(); + await expect(page.getByText('LATERAL', { exact: true })).toBeVisible(); + // Flow nodes. + await expect(page.getByText('recon', { exact: true })).toBeVisible(); + await expect(page.getByText('impact', { exact: true })).toBeVisible(); + // Buttons. + await expect(page.getByRole('button', { name: /primary/i })).toBeVisible(); + }); + + test('body uses self-hosted IBM Plex Sans, no Google Fonts requests', async ({ page }) => { + await page.goto('/'); + // Wait for fonts to settle. + await page.evaluate(() => document.fonts.ready); + + const bodyFont = await page.evaluate(() => + window.getComputedStyle(document.body).fontFamily.toLowerCase(), + ); + expect(bodyFont).toContain('ibm plex sans'); + + // Header is mono. + const h1Font = await page.evaluate(() => { + const h1 = document.querySelector('h1'); + return h1 ? window.getComputedStyle(h1).fontFamily.toLowerCase() : ''; + }); + expect(h1Font).toContain('jetbrains mono'); + + // No request must hit Google Fonts or any other CDN — see spec §7. + expect(externalRequests, `unexpected CDN traffic: ${externalRequests.join(', ')}`).toEqual([]); + }); + + test('background uses the RTOps deep navy token', async ({ page }) => { + await page.goto('/'); + const bg = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor); + // tasks/design.md: --bg = #0a0e1a → rgb(10, 14, 26) + expect(bg).toBe('rgb(10, 14, 26)'); + }); + + test('no JS console errors on first load', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // The auth provider attempts a silent /auth/refresh at mount; without a + // refresh cookie the server returns 401 and the browser logs a generic + // "Failed to load resource" warning. That's expected for unauthenticated + // visitors and doesn't constitute a real error. + const realErrors = consoleErrors.filter( + (e) => !/Failed to load resource.*401/i.test(e), + ); + expect(realErrors, `console errors: ${realErrors.join(' | ')}`).toEqual([]); + }); + + test('API health endpoint returns the expected JSON shape', async ({ request }) => { + const resp = await request.get('/api/v1/health'); + expect(resp.status()).toBe(200); + const body = (await resp.json()) as { status: string; version: string }; + expect(body.status).toBe('ok'); + expect(body.version).toMatch(/^\d+\.\d+\.\d+/); + }); + + test('CORS headers are set when the SPA origin asks for them', async ({ request }) => { + const resp = await request.get('/api/v1/health', { + headers: { Origin: 'http://localhost:8080' }, + }); + expect(resp.status()).toBe(200); + // flask-cors echoes back the configured origin when allowed. + const allowed = resp.headers()['access-control-allow-origin']; + expect(allowed === 'http://localhost:8080' || allowed === '*').toBeTruthy(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a22cc57 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*"] +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..59b3b02 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.vite +*.log +.env +.env.* +!.env.example diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0ae4b9d --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +/* eslint-env node */ +module.exports = { + root: true, + env: { browser: true, es2022: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs', 'node_modules'], + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3c15efe --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..076d233 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 + +# === Stage 1: build the SPA bundle === +FROM docker.io/library/node:20-alpine AS builder + +ARG VITE_API_BASE_URL=/api/v1 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +WORKDIR /app +COPY package.json ./ +# When a lockfile is committed (npm/pnpm/yarn), prefer `npm ci` for reproducibility. +RUN if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi + +COPY tsconfig*.json vite.config.ts tailwind.config.ts postcss.config.js index.html ./ +COPY src ./src + +RUN npm run build + +# === Stage 2: serve via nginx === +FROM docker.io/library/nginx:1.27-alpine AS runtime + +# Drop the default config and use ours. +RUN rm -f /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/metamorph.conf + +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/healthz || exit 1 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..db70b1d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,39 @@ +# Metamorph frontend + +Vite + React 18 + TypeScript + TailwindCSS. Design tokens from `../tasks/design.md` are in `tailwind.config.ts`. + +## Local dev + +```bash +npm install +npm run dev # http://localhost:5173 (proxies /api/* to http://localhost:8000) +``` + +## Build + +```bash +npm run build # outputs to dist/ +npm run preview # serves dist/ on http://localhost:8080 +``` + +## Quality + +```bash +npm run typecheck +npm run lint +npm run format +``` + +## Layout + +``` +src/ +├── App.tsx # M0 home page (health check + design tokens demo) +├── main.tsx +├── index.css # Tailwind base + tinted-accent utilities +├── components/ui/ # RTOps design primitives: Card, Tag, SectionHeader, FlowNode, Button +├── lib/ +│ ├── api.ts # fetch wrapper (M2 will replace with auth-aware client) +│ └── cn.ts # classnames + ACCENTS palette +└── vite-env.d.ts +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..88762b2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Metamorph + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..866d359 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,45 @@ +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Reasonable hardening — TLS is terminated by an external reverse proxy + # so we don't add HSTS here (let the edge own that header). + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "same-origin" always; + + # Internal liveness for the docker healthcheck. + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # Proxy API calls to the Flask service on the compose network. + location /api/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + client_max_body_size 64m; + } + + # SPA fallback — every unknown route returns index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # Long cache for hashed assets. + location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|webp|ico)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f7e91f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "metamorph-front", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --port 8080", + "lint": "eslint .", + "typecheck": "tsc -b --pretty", + "format": "prettier --write \"src/**/*.{ts,tsx,css,json,html}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\"" + }, + "dependencies": { + "@fontsource/ibm-plex-sans": "^5.0.20", + "@fontsource/jetbrains-mono": "^5.0.20", + "@tanstack/react-query": "^5.51.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "postcss": "^8.4.38", + "prettier": "^3.3.0", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6245b25 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,85 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; + +import { Layout } from '@/components/Layout'; +import { RequireAdmin } from '@/components/RequireAdmin'; +import { RequireAuth } from '@/components/RequireAuth'; +import { AdminGroupsPage } from '@/pages/AdminGroupsPage'; +import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'; +import { AdminUsersPage } from '@/pages/AdminUsersPage'; +import { HomePage } from '@/pages/HomePage'; +import { LoginPage } from '@/pages/LoginPage'; +import { ProfilePage } from '@/pages/ProfilePage'; +import { RegisterPage } from '@/pages/RegisterPage'; +import { SetupPage } from '@/pages/SetupPage'; +import { AuthContext, useProvideAuth } from '@/lib/auth'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); + +function AuthProvider({ children }: { children: React.ReactNode }) { + const auth = useProvideAuth(); + return {children}; +} + +function App() { + return ( + + + + + }> + } /> + } /> + } /> + {/* Home page stays public — it's an ops dashboard, not sensitive. */} + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + + + ); +} + +export default App; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..597412b --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,45 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface ButtonProps extends ButtonHTMLAttributes { + accent?: Accent; + variant?: 'solid' | 'outline' | 'ghost'; + children: ReactNode; +} + +const ACCENT_OUTLINE: Record = { + red: 'border-red text-red hover:bg-red/10', + orange: 'border-orange text-orange hover:bg-orange/10', + yellow: 'border-yellow text-yellow hover:bg-yellow/10', + green: 'border-green text-green hover:bg-green/10', + cyan: 'border-cyan text-cyan hover:bg-cyan/10', + blue: 'border-blue text-blue hover:bg-blue/10', + purple: 'border-purple text-purple hover:bg-purple/10', + pink: 'border-pink text-pink hover:bg-pink/10', + rose: 'border-rose text-rose hover:bg-rose/10', + teal: 'border-teal text-teal hover:bg-teal/10', +}; + +/** Minimal button matching the briefing aesthetic — no shadows, thin borders. */ +export function Button({ + accent = 'cyan', + variant = 'outline', + className, + children, + ...rest +}: ButtonProps) { + const base = + 'inline-flex items-center justify-center rounded-md border px-3 py-2 font-mono text-xs font-medium uppercase tracking-wider2 disabled:opacity-50 disabled:pointer-events-none'; + const variantCls = + variant === 'outline' + ? ACCENT_OUTLINE[accent] + : variant === 'ghost' + ? 'border-transparent text-text hover:bg-bg-card' + : 'border-transparent bg-bg-card text-text-bright'; + return ( + + ); +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..ab2db86 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,50 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface CardProps extends Omit, 'title'> { + /** Accent border color — distinguishes the card's category. */ + accent?: Accent; + /** Card heading. Renamed from the native HTMLAttributes.title (string-only). */ + title?: ReactNode; + /** Subtitle / metadata line below the title. */ + sub?: ReactNode; + children?: ReactNode; +} + +const ACCENT_BORDER: Record = { + red: 'border-red', + orange: 'border-orange', + yellow: 'border-yellow', + green: 'border-green', + cyan: 'border-cyan', + blue: 'border-blue', + purple: 'border-purple', + pink: 'border-pink', + rose: 'border-rose', + teal: 'border-teal', +}; + +/** Card from design.md §5.3 — shared chrome, accent-only differentiation. */ +export function Card({ accent, title, sub, children, className, ...rest }: CardProps) { + return ( +
+ {title && ( +

{title}

+ )} + {sub && ( +
+ {sub} +
+ )} + {children &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/ui/FlowNode.tsx b/frontend/src/components/ui/FlowNode.tsx new file mode 100644 index 0000000..94210e9 --- /dev/null +++ b/frontend/src/components/ui/FlowNode.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface FlowNodeProps { + accent?: Accent; + children: ReactNode; + className?: string; +} + +const ACCENT_BORDER_TEXT: Record = { + red: 'border-red text-red', + orange: 'border-orange text-orange', + yellow: 'border-yellow text-yellow', + green: 'border-green text-green', + cyan: 'border-cyan text-cyan', + blue: 'border-blue text-blue', + purple: 'border-purple text-purple', + pink: 'border-pink text-pink', + rose: 'border-rose text-rose', + teal: 'border-teal text-teal', +}; + +/** Flow node from design.md §5.5 — chained horizontally with arrows in flex rows. */ +export function FlowNode({ accent, children, className }: FlowNodeProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/SectionHeader.tsx b/frontend/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..c464b46 --- /dev/null +++ b/frontend/src/components/ui/SectionHeader.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface SectionHeaderProps { + /** Plain text leading the colored word. */ + prefix?: string; + /** The single colored word in the title. */ + highlight: string; + accent?: Accent; + description?: ReactNode; + className?: string; +} + +const ACCENT_TEXT: Record = { + red: 'text-red', + orange: 'text-orange', + yellow: 'text-yellow', + green: 'text-green', + cyan: 'text-cyan', + blue: 'text-blue', + purple: 'text-purple', + pink: 'text-pink', + rose: 'text-rose', + teal: 'text-teal', +}; + +/** + * Section header from design.md §5.2 — every h2 starts with a cyan `//`, + * a plain word, and exactly one colored word. + */ +export function SectionHeader({ + prefix, + highlight, + accent = 'red', + description, + className, +}: SectionHeaderProps) { + return ( +
+

+ {'// '} + {prefix && {prefix} } + {highlight} +

+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/frontend/src/components/ui/Tag.tsx b/frontend/src/components/ui/Tag.tsx new file mode 100644 index 0000000..5db7087 --- /dev/null +++ b/frontend/src/components/ui/Tag.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface TagProps { + accent: Accent; + children: ReactNode; + className?: string; +} + +const ACCENT_FILL: Record = { + red: 'accent-fill-red', + orange: 'accent-fill-orange', + yellow: 'accent-fill-yellow', + green: 'accent-fill-green', + cyan: 'accent-fill-cyan', + blue: 'accent-fill-blue', + purple: 'accent-fill-purple', + pink: 'accent-fill-pink', + rose: 'accent-fill-rose', + teal: 'accent-fill-teal', +}; + +/** Tag/pill from design.md §5.4 — 9px uppercase mono, tinted fill. */ +export function Tag({ accent, children, className }: TagProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..80c31c4 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,49 @@ +/* Self-hosted webfonts — no runtime CDN (cf. spec §7). */ +@import '@fontsource/jetbrains-mono/300.css'; +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/500.css'; +@import '@fontsource/jetbrains-mono/600.css'; +@import '@fontsource/jetbrains-mono/700.css'; +@import '@fontsource/ibm-plex-sans/300.css'; +@import '@fontsource/ibm-plex-sans/400.css'; +@import '@fontsource/ibm-plex-sans/500.css'; +@import '@fontsource/ibm-plex-sans/600.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, + body, + #root { + @apply min-h-screen; + } + body { + @apply bg-bg text-text font-sans; + line-height: 1.6; + } + /* No transitions / hovers / animations baseline (cf. design.md §7). */ + *:focus-visible { + outline: 1px solid theme('colors.cyan'); + outline-offset: 2px; + } + ::selection { + background: rgb(6 182 212 / 0.25); + color: theme('colors.text-bright'); + } +} + +@layer utilities { + /* Tinted accent fill — see design.md §2 "tinted fills". */ + .accent-fill-red { background: rgb(239 68 68 / 0.15); color: theme('colors.red'); } + .accent-fill-orange { background: rgb(245 158 11 / 0.15); color: theme('colors.orange'); } + .accent-fill-yellow { background: rgb(234 179 8 / 0.15); color: theme('colors.yellow'); } + .accent-fill-green { background: rgb(16 185 129 / 0.15); color: theme('colors.green'); } + .accent-fill-cyan { background: rgb(6 182 212 / 0.15); color: theme('colors.cyan'); } + .accent-fill-blue { background: rgb(59 130 246 / 0.15); color: theme('colors.blue'); } + .accent-fill-purple { background: rgb(139 92 246 / 0.15); color: theme('colors.purple'); } + .accent-fill-pink { background: rgb(236 72 153 / 0.15); color: theme('colors.pink'); } + .accent-fill-rose { background: rgb(244 63 94 / 0.15); color: theme('colors.rose'); } + .accent-fill-teal { background: rgb(20 184 166 / 0.15); color: theme('colors.teal'); } +} diff --git a/frontend/src/lib/cn.ts b/frontend/src/lib/cn.ts new file mode 100644 index 0000000..edac13e --- /dev/null +++ b/frontend/src/lib/cn.ts @@ -0,0 +1,19 @@ +/** Tiny classnames helper — keeps deps minimal. */ +export function cn(...parts: Array): string { + return parts.filter(Boolean).join(' '); +} + +export const ACCENTS = [ + 'red', + 'orange', + 'yellow', + 'green', + 'cyan', + 'blue', + 'purple', + 'pink', + 'rose', + 'teal', +] as const; + +export type Accent = (typeof ACCENTS)[number]; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..08e4895 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from '@/App'; +import '@/index.css'; + +const root = document.getElementById('root'); +if (!root) throw new Error('#root not found in index.html'); + +createRoot(root).render( + + + , +); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..e494256 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { FlowNode } from '@/components/ui/FlowNode'; +import { SectionHeader } from '@/components/ui/SectionHeader'; +import { Tag } from '@/components/ui/Tag'; +import { apiGet } from '@/lib/api'; + +interface HealthResponse { + status: string; + version: string; +} + +interface DbDiagResponse { + reachable: boolean; + alembic_revision?: string | null; + table_count?: number; + error?: string; +} + +type HealthState = + | { kind: 'loading' } + | { kind: 'ok'; data: HealthResponse } + | { kind: 'error'; error: string }; + +type DbState = + | { kind: 'loading' } + | { kind: 'ok'; data: DbDiagResponse } + | { kind: 'error'; error: string }; + +export function HomePage() { + const [health, setHealth] = useState({ kind: 'loading' }); + const [db, setDb] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + apiGet('/health', { anonymous: true }) + .then((data) => !cancelled && setHealth({ kind: 'ok', data })) + .catch((err: unknown) => + !cancelled && + setHealth({ kind: 'error', error: err instanceof Error ? err.message : String(err) }), + ); + apiGet('/diag/db', { anonymous: true }) + .then((data) => !cancelled && setDb({ kind: 'ok', data })) + .catch((err: unknown) => + !cancelled && + setDb({ kind: 'error', error: err instanceof Error ? err.message : String(err) }), + ); + return () => { + cancelled = true; + }; + }, []); + + return ( + <> +
+

+ Meta + morph{' '} + Purple Team Platform +

+

+ Collaborative red & blue test orchestration — M3 milestone (RBAC) +

+
+ +
+ + {health.kind === 'loading' &&

Waiting for the backend…

} + {health.kind === 'ok' && ( +

+ Status:{' '} + + {health.data.status} + +

+ )} + {health.kind === 'error' && ( +

+ Error:{' '} + + {health.error} + +

+ )} +
+ + + {db.kind === 'ok' && db.data.reachable && ( +

+ Tables:{' '} + + {db.data.table_count} + {' '} + · Alembic head reached. +

+ )} + {(db.kind === 'loading' || (db.kind === 'ok' && !db.data.reachable)) && ( +

Querying schema metadata…

+ )} + {db.kind === 'error' && ( +

+ Error:{' '} + + {db.error} + +

+ )} +
+ + +

+ M0 + M1 + M2 + M3 done. Next:{' '} + + M4 — MITRE ATT&CK + + . +

+
+
+ + +
+ EVASION + C2 + LATERAL + CRED + PHISH + PERSIST +
+
+ recon + + phish + + c2 + + lateral + + impact +
+
+ + + +
+ + ); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..e21049a --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,66 @@ +import type { Config } from 'tailwindcss'; + +/** + * Design tokens from `tasks/design.md` — Red Team Operations Map. + * Dark, flat, terminal-inspired. Color-as-taxonomy: each accent maps to a category. + */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + // Surfaces + bg: '#0a0e1a', + 'bg-card': '#111827', + border: '#1e2d3d', + // Text + text: '#94a3b8', + 'text-bright': '#f8fafc', + 'text-dim': '#64748b', + 'text-comment': '#475569', + // Accent palette (each one means a category — see tasks/design.md §2) + red: '#ef4444', + orange: '#f59e0b', + yellow: '#eab308', + green: '#10b981', + cyan: '#06b6d4', + blue: '#3b82f6', + purple: '#8b5cf6', + pink: '#ec4899', + rose: '#f43f5e', + teal: '#14b8a6', + }, + fontFamily: { + mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'], + sans: ['"IBM Plex Sans"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + }, + fontSize: { + // Custom scale matching design.md §3 — extends the default Tailwind ramp. + // 2xs = arrow labels (8px), 3xs = tag/pill (9px), 4xs = card sub-label, flow node, inline code (10px). + // 11px (pre/footer), 12px (body/section desc), 13/14px (card title), 18px (section h2), + // and 28px (page h1) all already exist in the default ramp. + '2xs': ['8px', '1.4'], + '3xs': ['9px', '1.4'], + '4xs': ['10px', '1.4'], + }, + borderRadius: { + sm: '3px', + DEFAULT: '4px', + md: '6px', + lg: '10px', + }, + maxWidth: { + page: '1400px', + }, + letterSpacing: { + wider2: '1px', + }, + }, + }, + // Safelist accent classes used dynamically (tag categories, flow nodes). + safelist: [ + { pattern: /^(border|text|bg)-(red|orange|yellow|green|cyan|blue|purple|pink|rose|teal)$/ }, + { pattern: /^(border|text|bg)-(red|orange|yellow|green|cyan|blue|purple|pink|rose|teal)\/(\d+)$/ }, + ], + plugins: [], +} satisfies Config; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..ce868c2 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "useDefineForClassFields": true, + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": false, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8e5b203 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..50d06bf --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + // In `npm run dev`, proxy /api/* to the local Flask backend. + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + target: 'es2022', + }, +}); diff --git a/tasks/design.md b/tasks/design.md new file mode 100644 index 0000000..a3567c0 --- /dev/null +++ b/tasks/design.md @@ -0,0 +1,376 @@ +# Design System — Red Team Operations Map + +Reusable design spec extracted from `kypvas.github.io/red-team-map/`. Dark "operator briefing / terminal" aesthetic: information-dense, color-coded taxonomy, monospace-first, zero ornament. + +--- + +## 1. Philosophy + +- **Dark, flat, terminal-inspired.** No gradients, no drop shadows, no glows. Depth comes from 1px borders on slightly lighter card backgrounds. +- **Information over decoration.** Every visual element serves data density — cards, tags, colored borders, inline code. +- **Color as taxonomy.** 10 accent hues are not decoration — each one *means* a category (red = evasion/payload, cyan = lateral, purple = C2, etc.). Reuse hues consistently across projects so color carries meaning. +- **Monospace as identity.** `JetBrains Mono` for everything structural (titles, labels, code, tags). `IBM Plex Sans` only for prose body. +- **Comment-style section markers.** Headings begin with `//` — carries the "source code / operator notes" metaphor. + +--- + +## 2. Color Tokens + +All colors are declared as CSS custom properties on `:root`. Copy-paste verbatim: + +```css +:root { + /* Surfaces */ + --bg: #0a0e1a; /* page background — deep navy-black */ + --bg-card: #111827; /* card / panel background */ + --border: #1e2d3d; /* default 1px border, separators */ + + /* Text */ + --text: #94a3b8; /* default body copy (slate) */ + --text-bright: #f8fafc; /* titles, emphasis */ + --text-dim: #64748b; /* metadata, subtitles, arrow labels */ + /* #475569 used inline for code comments */ + + /* Accent palette — each one maps to a category */ + --red: #ef4444; /* evasion, payload, privesc, danger */ + --orange: #f59e0b; /* access, credentials, AD, MOTW */ + --yellow: #eab308; /* exfil */ + --green: #10b981; /* phishing, social */ + --cyan: #06b6d4; /* lateral movement, default code highlight */ + --blue: #3b82f6; /* infrastructure, cloud */ + --purple: #8b5cf6; /* C2, macOS, tooling */ + --pink: #ec4899; /* injection */ + --rose: #f43f5e; /* OPSEC, vishing */ + --teal: #14b8a6; /* persistence, linux */ +} +``` + +### Usage pattern — tinted fills + +Never use accent color as a solid background. Always as `rgba(accent, 0.10–0.15)` behind solid-colored text: + +```css +.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); } +.tag.cred { background: rgba(245, 158, 11, 0.15); color: var(--orange); } +code { background: rgba(6, 182, 212, 0.08); color: var(--cyan); } +code.vuln { background: rgba(239, 68, 68, 0.10); color: var(--red); } +code.tool { background: rgba(139, 92, 246, 0.10); color: var(--purple); } +``` + +--- + +## 3. Typography + +### Font stack + +```html + +``` + +- **`JetBrains Mono`** — headings, labels, code, tags, navigation, anything structural. +- **`IBM Plex Sans`** — prose body only (`

`, card descriptions). +- Weights used: Mono `300 / 400 / 500 / 600 / 700`, Plex Sans `300 / 400 / 500 / 600`. + +### Scale + +| Role | Family | Size | Weight | Extras | +|-----------------|-----------------|------|--------|---------------------------------| +| Page title (h1) | JetBrains Mono | 28px | 700 | `letter-spacing: -0.5px` | +| Subtitle | JetBrains Mono | 14px | 300 | color `--text-dim` | +| Section (h2) | JetBrains Mono | 18px | 600 | `border-bottom: 1px var(--border)`, `padding-bottom: 12px` | +| Card title (h3) | JetBrains Mono | 14px | 600 | color `--text-bright` | +| Card sub-label | JetBrains Mono | 10px | 400 | `letter-spacing: 0.5px`, `--text-dim` | +| Section desc | JetBrains Mono | 12px | 400 | `--text-dim` | +| Body copy | IBM Plex Sans | 12px | 400 | `line-height: 1.7` | +| Flow node | JetBrains Mono | 10px | 400 | | +| Arrow label | JetBrains Mono | 8px | 400 | `--text-dim` | +| Tag / pill | JetBrains Mono | 9px | 600 | `text-transform: uppercase; letter-spacing: 1px` | +| Inline code | JetBrains Mono | 10px | 400 | | +| `

` block   | JetBrains Mono  | 11px | 400    | `line-height: 1.7`              |
+| Footer          | JetBrains Mono  | 11px | 400    | `--text-dim`                    |
+
+### Global body
+
+```css
+body {
+  background: var(--bg);
+  color: var(--text);
+  font-family: 'IBM Plex Sans', sans-serif;
+  padding: 40px 60px;
+  line-height: 1.6;
+}
+```
+
+---
+
+## 4. Layout & Spacing
+
+- **Container**: `max-width: 1400px; margin: 0 auto;`
+- **Page padding**: `40px 60px` (desktop-first, no mobile breakpoints in the source)
+- **Grid** for card collections:
+  ```css
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
+  gap: 16px;
+  ```
+- **Section rhythm**: `margin-top: 60px; margin-bottom: 30px` on section headers.
+- **Separators**: thin hairlines only — `border-top: 1px solid var(--border); margin: 40px 0`.
+- **Line-height**: 1.6 globally, 1.7 inside cards and `
` blocks for dense technical content.
+
+---
+
+## 5. Components
+
+### 5.1 Header / Hero
+
+```html
+
+

Red Team Operations Architecture Map v1.1

+
Comprehensive Operator Reference — From Infrastructure to Impact
+
+``` + +```css +header { text-align: center; margin-bottom: 50px; } +header h1 { font: 700 28px 'JetBrains Mono'; color: var(--text-bright); letter-spacing: -0.5px; margin-bottom: 8px; } +header h1 span { color: var(--red); } +header h1 .acc2 { color: var(--purple); } +header .subtitle { font: 300 14px 'JetBrains Mono'; color: var(--text-dim); } +``` + +> **Pattern**: white title with two coloured accent words (red + purple). Reuse `` to highlight 1–2 keywords only. + +### 5.2 Section heading + +```html +
+

// Operation Flow Chains

+

End-to-end attack chains ...

+
+``` + +```css +.section-header { margin-top: 60px; margin-bottom: 30px; } +.section-header h2 { font: 600 18px 'JetBrains Mono'; color: var(--text-bright); + padding-bottom: 12px; border-bottom: 1px solid var(--border); } +.section-header h2 span { color: var(--cyan); } /* the "//" marker */ +.section-header h2 .red { color: var(--red); } /* + .green / .orange / .purple / .pink / .teal / .yellow / .blue */ +.section-desc { font: 12px 'JetBrains Mono'; color: var(--text-dim); margin-top: 8px; } +``` + +> **Signature move**: every h2 starts with a cyan `//` followed by a plain word and one colored word — source-code comment vibe. + +### 5.3 Detail card + +```css +.detail-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 20px; +} +.detail-card h3 { font: 600 14px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 4px; } +.detail-card .card-sub { font: 10px 'JetBrains Mono'; color: var(--text-dim); margin-bottom: 12px; letter-spacing: 0.5px; } +.detail-card .card-body { font-size: 12px; line-height: 1.7; } + +/* accent-border variants */ +.border-red { border-color: var(--red) !important; } +.border-cyan { border-color: var(--cyan) !important; } +/* ... one class per accent */ +``` + +> Cards share identical chrome; they are **distinguished solely by border color**. That single accent ties card → category → tag → flow-node without repeating the hue anywhere else. + +### 5.4 Tag / pill + +```css +.tag { + font: 600 9px 'JetBrains Mono'; + text-transform: uppercase; + letter-spacing: 1px; + padding: 3px 8px; + border-radius: 4px; + display: inline-block; + margin-bottom: 8px; + margin-right: 4px; +} +.tag.c2 { background: rgba(139, 92, 246, 0.15); color: var(--purple); } +.tag.evasion { background: rgba(239, 68, 68, 0.15); color: var(--red); } +.tag.lateral { background: rgba(6, 182, 212, 0.15); color: var(--cyan); } +/* ... one class per category */ +``` + +### 5.5 Flow node + arrow + +Nodes chain horizontally in a `flex` row with thin SVG arrows between them. + +```css +.flow-block { margin-bottom: 18px; } +.flow-title { font: 600 12px 'JetBrains Mono'; margin-bottom: 10px; } +.flow-title.red { color: var(--red); } /* one per accent */ + +.flow-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 10px 0; } + +.flow-node { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + font: 10px 'JetBrains Mono'; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; +} +.flow-node.hl-red { border-color: var(--red); color: var(--red); } +.flow-node.hl-cyan { border-color: var(--cyan); color: var(--cyan); } +/* ... one per accent */ + +.flow-arrow { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; } +.flow-arrow svg { width: 36px; height: 20px; } +.flow-arrow .arrow-label { font: 8px 'JetBrains Mono'; color: var(--text-dim); margin-top: -2px; } +``` + +Arrow SVG template (inline, stroke colour = destination-node accent): + +```html + + + +``` + +### 5.6 Data-flow / code card + +```css +.data-flow-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 24px; + margin-bottom: 20px; +} +.data-flow-card h4 { font: 600 13px 'JetBrains Mono'; color: var(--text-bright); margin-bottom: 12px; } +.data-flow-card pre { font: 11px 'JetBrains Mono'; line-height: 1.7; color: var(--text-dim); overflow-x: auto; } +.data-flow-card pre .key { color: var(--cyan); font-weight: 600; } +.data-flow-card pre .val { color: var(--text-bright); } +.data-flow-card pre .type { color: var(--blue); } +.data-flow-card pre .comment { color: #475569; font-style: italic; } +.data-flow-card pre .danger { color: var(--red); font-weight: 600; } +``` + +> Pseudo-syntax-highlighting via `` inside `
` blocks — mimics an IDE theme without a real parser.
+
+### 5.7 Inline code
+
+```css
+code               { font: 10px 'JetBrains Mono'; padding: 2px 6px; border-radius: 3px;
+                     background: rgba(6, 182, 212, 0.08); color: var(--cyan); }
+code.vuln          { background: rgba(239, 68, 68,  0.10); color: var(--red); }
+code.tool          { background: rgba(139, 92, 246, 0.10); color: var(--purple); }
+```
+
+### 5.8 List inside card
+
+```css
+.card-list          { list-style: none; padding: 0; }
+.card-list li       { padding: 3px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.03); }
+```
+
+> Near-invisible divider (`rgba(255,255,255,0.03)`) — rhythm without visual noise.
+
+### 5.9 Footer
+
+```css
+footer {
+  text-align: center;
+  margin-top: 60px;
+  padding: 30px 0;
+  border-top: 1px solid var(--border);
+  font: 11px 'JetBrains Mono';
+  color: var(--text-dim);
+}
+```
+
+---
+
+## 6. Borders, Radii, Elevation
+
+| Element        | Radius | Border                       |
+|----------------|--------|------------------------------|
+| Detail card    | 10px   | 1px solid var(--border) or accent |
+| Data-flow card | 10px   | 1px solid var(--border)      |
+| Flow node      | 6px    | 1px solid var(--border) or accent |
+| Tag            | 4px    | none                         |
+| Inline code    | 3px    | none                         |
+
+- **No `box-shadow` anywhere.**
+- **No gradients.** Surfaces are flat hex fills.
+- **Depth cue** = border on a `#111827` panel over a `#0a0e1a` background. That's the whole elevation system.
+
+---
+
+## 7. Motion
+
+The stylesheet defines **no transitions, no hovers, no animations**. Static document. If you add motion in derivative work, keep it restrained: ~120 ms fades on border-color at most. Don't introduce scale, shadow, or glow effects — they'd break the briefing aesthetic.
+
+---
+
+## 8. Iconography
+
+No icon font, no Lucide/Heroicons. All pictograms are **inline SVG arrows** with `stroke-width: 1.5` and `` arrowheads, one per accent color. Tags replace icons: `[C2]`, `[EVASION]`, `[LATERAL]` carry the same recognition load.
+
+If icons are ever added, use a thin (1.5px) monochrome line set (e.g. Lucide `strokeWidth={1.5}`) and color them with accent vars.
+
+---
+
+## 9. Reusable Starter Template
+
+Drop-in `` + body baseline for a new project in this style:
+
+```html
+
+
+
+  
+  
+  Project Name
+  
+  
+
+
+  
+
+

Project Name Subtitle

+
One-line mission statement
+
+ +
+ + +``` + +--- + +## 10. Checklist for "Does this match the style?" + +- [ ] Background `#0a0e1a`, cards `#111827`, borders `#1e2d3d`. +- [ ] JetBrains Mono for structure, IBM Plex Sans for prose. +- [ ] Every section `

` starts with a cyan `//`. +- [ ] Exactly one accent hue per category, reused across border + tag + code + flow node. +- [ ] Accent backgrounds are **tinted** (`rgba(accent, 0.10–0.15)`), never solid. +- [ ] Zero shadows, zero gradients, zero rounded > 10px. +- [ ] Tags are 9px uppercase mono with 1px letter-spacing. +- [ ] Container capped at 1400px, page padded `40px 60px`. +- [ ] No hover animations beyond border-color if any at all. diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..ad556ca --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,76 @@ +--- +type: lessons +project: Metamorph +--- + +# Metamorph — Lessons learned + +> Capture session-level retrospectives here: surprises, traps avoided, decisions revisited. Keep entries short and actionable. Most recent first. + +## 2026-05-08 — M0 bootstrap + +- Spec finalisée d'abord (`tasks/spec.md`), 8 tours de questions ciblées avant tout code → 0 hypothèse latente avant M0. Pattern à reproduire pour les futurs projets greenfield. +- Choix `uv` pour le backend Python (rapidité de lock, image Docker plus mince qu'avec poetry). +- TLS terminé par reverse proxy externe (cf. spec §6 NF-network) → pas de Caddy/Traefik dans le compose, simplifie le M0. +- Bootstrap du 1er admin via token affiché dans les logs : retenu sur Token-in-logs plutôt que ENV pour éviter de mettre le password en clair dans `.env`. +- **Piège Dockerfile** : la process-substitution bash `<(...)` ne marche pas dans une instruction `RUN` Docker car le shell par défaut est `sh`, pas `bash`. Soit ajouter `SHELL ["/bin/bash", "-c"]`, soit refactor sans process-sub. Ici j'ai préféré refactor (plus portable) : `uv venv` + `uv pip install --python /opt/venv/bin/python .`. Quand un `uv.lock` existera, basculer sur `uv sync --frozen --no-dev`. +- Vérification d'un compose sans Docker installé : `python3 -c "import yaml; yaml.safe_load(open('docker-compose.yml'))"` valide la syntaxe YAML, et un script qui croise les `environment:` du compose avec `.env.example` détecte les variables manquantes côté docs. +- **Lancer le subagent `spec-reviewer` à chaque fin de milestone** (HARD RULE 4 du CLAUDE.md global). J'avais oublié à la fin de M0 ; le user me l'a rappelé. Le reviewer a remonté 6 défauts légitimes en quelques minutes (pre-commit absent, fonts via CDN, secrets par défaut non gardés, `make dev` no-op, `database_url` dead-code, Node engines non pinned). À automatiser dans le workflow de fin de milestone. +- **Spec §7 "pas de CDN runtime"** s'applique aussi aux fonts, pas seulement aux libs JS. Self-host via `@fontsource/` plutôt que Google Fonts `` — bonus OPSEC (pas de fingerprinting via fonts.googleapis.com). +- **Pattern de garde de secrets** : un `model_validator` Pydantic qui refuse de booter en `APP_ENV != "dev"` avec des secrets manquants ou égaux aux placeholders de `.env.example`. Coût quasi nul, élimine la classe entière des "oubli de set en prod". +- **Makefile portable docker/podman** : `ENGINE := $(shell command -v docker … podman …)`, puis sélection du compose driver en fonction (`docker compose` vs `podman compose` vs `podman-compose` legacy). Le piège classique `COMPOSE ?=` ne marche pas si on veut conditionner la valeur par défaut sur `ENGINE` — il faut `ifndef COMPOSE` + `ifeq ($(ENGINE),docker)`. Tous les targets restent compose-driven (`$(COMPOSE) exec`, etc.) ; seuls `volumes` / `inspect-health` / `logs-api` ont besoin de `$(ENGINE)` directement, et même là on évite les filtres par label projet (instables entre podman-compose et docker compose) en se reposant sur `container_name:` du compose file. + +## 2026-05-10 — M0 DoD validation (réelle, pas paperware) + +- **JE DOIS LANCER LE DoD MOI-MÊME avant de déclarer un milestone done.** L'utilisateur me l'a fait remonter ; le `make up` initial échouait sur 3 problèmes que la revue statique n'a pas vus. Règle : à chaque fin de milestone, exécuter le DoD localement (`make up` + smoke + e2e) en plus du spec-reviewer. +- **Podman + Fedora exige des FQDN d'image** (`docker.io/library/postgres:16-alpine`, pas `postgres:16-alpine`). Le mode `short-name-mode=enforcing` fail sans TTY pour prompter. Docker accepte le même préfixe transparente. → Dorénavant tous les `image:` et `FROM …` des projets cross-engine sont qualifiés. +- **`.dockerignore` qui exclut `*.md` casse `pyproject.toml` qui référence `readme = "README.md"`** : hatchling lit le README au build pour valider les métadonnées. Soit on copie le README explicitement, soit on n'exclut pas les `*.md`, soit on retire la clé `readme`. J'ai retiré la clé pour découpler. +- **`extends HTMLAttributes` clash sur `title`** : la prop native est `string`, donc redéfinir `title?: ReactNode` produit TS2430. Pattern à retenir : `Omit, 'title'>` quand on overload `title`/`color`/`autoFocus` etc. +- **Podman-compose 1.x ne surfait pas les `HEALTHCHECK` du Dockerfile dans `podman inspect`** : il faut redéclarer le healthcheck dans le `docker-compose.yml` pour que `make inspect-health` voie réellement l'état. Bonus : c'est aussi plus portable. +- **Piège shell : `make up 2>&1 | tail -80` bloque** quand la sortie est petite, parce que `tail` bufferise jusqu'à recevoir SIGPIPE en fin de pipeline ; quand le build est lent, on n'a aucune sortie pendant des minutes. Fix : rediriger vers fichier (`>/tmp/log 2>&1`) puis `tail` séparément, ou utiliser le `Monitor` tool pour streamer. +- **`PODMAN_COMPOSE_WARNING_LOGS=false`** masque le banner "Executing external compose provider …" qui spamme chaque commande. À exporter depuis le Makefile. + +## 2026-05-10 — M1 schéma DB & migrations + +- **Compose pioche le DERNIER stage du Dockerfile par défaut.** En ajoutant un stage `test` après `runtime`, le container `api` s'est mis à exécuter `python -m pytest` au lieu de `gunicorn`, en boucle (exit 1 → restart → exit 1). Fix : `target: runtime` explicite dans `docker-compose.yml`. Règle : **toujours préciser `target:` quand un Dockerfile a >1 stage final viable.** +- **Snapshot vs référence (spec §11)** : pour qu'un snapshot survive à un re-sync de la référence (ex : MITRE qui retire une technique), il faut **dénormaliser les champs descriptifs** dans la table snapshot (ici `mitre_external_id`, `mitre_name`, `mitre_url`) et **ne pas mettre de FK** vers la table source. Si on garde une FK, la cascade détruit la donnée historique (CASCADE) ou bloque le sync (RESTRICT). La dénormalisation est le bon trade-off pour un état figé en lecture après archivage. +- **`SoftDeleteMixin.__table_args__` est silencieusement écrasé** par la classe enfant qui déclare son propre `__table_args__`. Pattern à éviter pour les mixins qui veulent ajouter des contraintes/index. Soit ne rien mettre dans `__table_args__` du mixin (et imposer aux classes de déclarer l'index), soit utiliser `event.listens_for("after_parent_attach", ...)`. J'ai choisi la 1re option : explicite > magique. +- **Workflow Alembic en container** : `alembic revision --autogenerate` crée le fichier dans le container, qu'il faut `podman cp` vers l'host avant rebuild. Sinon perdu. Ajouter ce détail dans la doc M1 (et envisager un bind mount `dev` plus tard). +- **Bypass `APP_ENV` doit couvrir `dev` ET `test`** : un container test légitime ne doit pas avoir besoin de secrets prod-grade. `if self.APP_ENV in ("dev", "test"): return self`. +- **`pytest` dans le runtime image, c'est non.** Faire un stage `test` dédié (multi-stage `--target test`) qui étend `deps` + `dev extras` + `tests/`, lancé via `podman run --rm --network _` en éphémère. Le runtime reste minimal en prod. +- **Le test d'intégration "expected tables/FK/CHECK" est le bon filet de sécurité** pour M1+ : il a immédiatement attrapé les fixes du reviewer (le retrait de `ck_mission_test_mitre_tags_exactly_one_mitre_fk` aurait été un oubli silencieux sinon). +- **Lancer le DoD avant de dire "M1 done"** : règle gravée à M0, respectée ici. `make clean && make up && make migrate && make test-api && make e2e` est la séquence canonique de fin de milestone. + +## 2026-05-11 — M3 RBAC, groupes, users, invitations + +- **`logging.LogRecord` réserve `name`** comme attribut interne (en plus de `message`, `levelname`, `pathname`, `filename`, `module`, `funcName`, `lineno`, `asctime`, `process`, `thread`, `args`). Donc `log.info("metamorph.x.created", extra={"name": entity.name})` lève `KeyError: "Attempt to overwrite 'name' in LogRecord"`. Patron : préfixer toute clé risquée par l'entité (`group_name`, `user_name`, `template_name`). À documenter dans le style guide quand on en aura un. +- **Pattern "sentinel pour distinguer absent vs null"** : Pydantic ne sait pas distinguer `{}` de `{"display_name": null}` quand le champ est `str | None = None`. Solution : lire `raw = request.get_json()` puis tester `"display_name" in raw` dans la couche API, passer un sentinel `...` au service, qui distingue "ne pas toucher" de "set à None". Lourd mais explicite. Si ça revient souvent, encapsuler dans un helper `triState(raw, key, payload)`. +- **`limiter.reset()` flask-limiter** est public et clean — pas besoin de toucher à `limiter._storage`. À appeler dans `/diag/reset` quand le limiter est `enabled`. Toujours guarder avec `if limiter.enabled` pour ne pas planter en `APP_ENV=test`. +- **Rate-limit scope `APP_ENV in ("prod", "staging")`** : meilleure granularité que prod-only. La spec NF-security est *operator-facing*, pas dev. Trade-off réconcilié dans `app/core/rate_limit.py` avec un docstring explicite. Dev = ergonomics totale, prod/staging = limiter actif, test = désactivé. +- **Playwright `workers: 1` + `fullyParallel: false`** quand chaque spec file fait du `/diag/reset` (DB partagée). Avec parallélisme, les workers se truncate mutuellement entre eux → install token consumé, etc. Pattern simple et robuste : un seul worker pour les e2e, parallélisme intra-file laissé à `test.describe.configure({ mode: 'serial' })`. +- **Sessions Playwright entre tests** : chaque `test()` reçoit une `page` neuve (BrowserContext fresh). Pas de partage de session entre tests du même `describe`. Helper `loginViaSpa()` à appeler au début de chaque test SPA-driven (les tests purement API peuvent partager via une variable de spec mais c'est rare). Alternative : `storageState` global, mais ça complique le truncate workflow. +- **Dual seed = boot + bootstrap** : seeder les perms au boot ET dans `bootstrap_admin()` n'est pas redondant. Sur DB fraîchement migrée vide, le boot suffit. Mais après `/diag/reset` (qui TRUNCATE `permissions` + `group_permissions` + `groups`), seul `/setup` re-déclenche le chemin de seed via `bootstrap_admin → seed_all`. Sans ce 2e appel, l'admin créé aurait `is_admin=True` mais le catalogue serait vide. +- **Snapshot UserView/GroupView détachés** : retourner des `@dataclass(frozen=True)` au lieu de l'ORM permet de fermer le `session_scope` immédiatement. Plus simple que `s.expunge()` pour chaque champ, et la couche API peut sérialiser sans lazy-loading. Patron à reproduire pour tous les services. +- **Invariant "admin a toutes les perms"** : même si le décorateur bypass via `is_admin = "admin" in group_names` (et pas via le perm set), garder l'invariant côté API en refusant `set_group_permissions(admin_group, !=all_codes)`. Future-proof : si on bouge le bypass à un check perm-based plus tard, l'invariant tient déjà. `SystemGroupProtected` réutilisé pour le 409. +- **Toujours rebuild front + recreate containers** : `make rebuild` ne recrée pas les containers, donc le bundle nginx reste l'ancien. Patron canonique : `make down && make up`. Documenté pour la 2e fois dans M3 ; à faire passer en runbook au prochain `tasks/testing-m.md`. + +## 2026-05-10 — M2 auth, JWT, invitations + +- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`, …) via `email-validator` `globally_deliverable=True`. Pour un outil red-team utilisé en lab/intranet, créer un type custom permissif (`Annotated[str, AfterValidator(...)]`) avec une regex RFC-shape. À garder en tête pour tout futur projet "internal". +- **Cookies `Secure=True` sur `localhost` HTTP** : modern browsers (Chrome ≥89, Firefox ≥75) traitent `localhost` comme un secure context et acceptent les cookies `Secure` même servis en HTTP. Donc on peut respecter la spec strictement (`Secure` toujours) sans casser le dev — pas besoin de gating par `APP_ENV`. +- **`getByLabel` de Playwright** prend le **nom accessible** de l'input. Quand un `