Spec-reviewer + code-reviewer findings applied:
Must-fix
- Filter combinator AND-semantics: tactic+technique+subtechnique now intersect
(one IN subquery per facet) instead of being pooled into one OR. Reviewers
flagged both the wrong default semantics and the theoretical UUID-collision
risk of pooling tactic/technique/sub UUIDs into a shared list across
three columns.
- Front-end mutation cache hygiene: updateMeta + setTests both
`onSettled: invalidate` so a partial failure leaves the cache consistent.
Should-fix
- Per-scenario pg_advisory_xact_lock on set_scenario_tests — serialises
concurrent reorders, mirrors M4 /mitre/sync pattern.
- Backend/front consistency on duplicate tests in a scenario: the
UNIQUE(scenario_id, position) constraint already allows the same
test_template multiple times (chained ops), so the catalogue picker no
longer excludes already-picked items.
Nice-to-have
- N+1 eradicated in test_template view rendering: _to_views_batch
builds {uuid → MitreRow} maps in 3 queries up-front; list endpoint
now issues 4 queries total regardless of list size.
- Wire-level item length caps on tags (64) and expected_iocs (255)
via Annotated[str, StringConstraints(...)] — returns 400 instead of
bubbling up StringDataRightTruncation.
- 4 new pytest covering the AND-filter, extra="forbid" rejection,
empty mitre_tags clearing, and the 65-char tag cap. Total now
81 pytest + 38 e2e pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 KiB
30 KiB
Changelog
All notable changes to this project will be documented here. Format: Keep a Changelog · Conventional Commits.
[Unreleased]
Added — M5 (Test & scenario templates)
- CRUD
test_templates(app/services/test_templates.py+app/api/test_templates.py):- Fields: name, description, objective, procedure (markdown), prerequisites (markdown), expected result red, expected detection blue, OPSEC level (
low/medium/high), free tags (TEXT[]), expected IOCs (TEXT[]). - Polymorphic MITRE tag set (
(kind, external_id)↔ exactly one oftactic_id/technique_id/subtechnique_id). The wire payload uses ATT&CK external IDs — server resolves to UUIDs. - Filters:
q(LIKE on name/description),tactic/technique/subtechnique(joined via subquery on the polymorphic tag table),opsec,tag(array contains). - REST:
GET /test-templates,GET /test-templates/{id},POST /test-templates,PUT /test-templates/{id}(partial, with explicit_UNSETsentinel so omitted fields stay untouched),DELETE /test-templates/{id}(soft).
- Fields: name, description, objective, procedure (markdown), prerequisites (markdown), expected result red, expected detection blue, OPSEC level (
- CRUD
scenario_templates(app/services/scenario_templates.py+app/api/scenario_templates.py):- Ordered list of test_templates with
position(UNIQUEscenario_template_id, position). - Reorder via full replace:
PUT /scenario-templates/{id}/testsdeletes the join rows and re-inserts at positions0..N-1— clean atomic op that respects the UNIQUE constraint without a 2-phase position shuffle. - The same test can appear multiple times (chained operations).
- REST:
GET/POST/PATCH(metadata) /DELETE(soft) on/scenario-templates.
- Ordered list of test_templates with
- Frontend:
lib/templates.ts— typed client + queryKey factory.pages/AdminTestsPage.tsx— list + filters (q, tactic, opsec, tag) + modal with full field set + embedded<MitreTagPicker>for tags.pages/AdminScenariosPage.tsx— list + modal with @dnd-kit/sortable vertical drag-and-drop on the ordered test list. New deps:@dnd-kit/core,@dnd-kit/sortable,@dnd-kit/utilities.components/MarkdownField.tsx— lean textarea with markdown hint (no heavy editor dep; rendering happens at display time in M7).- Nav adds Tests and Scenarios links (admin-gated).
- /diag/reset truncates the 4 new tables before the MITRE block — the
scenario_template_tests.test_template_idFK isON DELETE RESTRICT, so the order matters. - Testing:
backend/tests/test_templates.py— 19 pytest (create/list/filter by tactic+opsec+tag, MITRE tag resolution + replacement on update, soft-delete, perm gating, scenario create+reorder+delete, soft-deleted test linking semantics).e2e/tests/m5-templates.spec.ts— 4 Playwright (API CRUD round-trip, scenario reorder, SPA list + opsec filter, SPA scenario list rendering with ordered tests).tasks/testing-m5.md.
Fixed (M5 implementation)
LogRecordkey collision:log.info(..., extra={"name": ...})raisesKeyError("Attempt to overwrite 'name' in LogRecord")becausenameis reserved by Python's stdlib logging. Renamed totemplate_name.- React
currentTargetnull in deferred state updaters:onChange={(e) => setX((prev) => ({ ...prev, q: e.currentTarget.value }))}blanked the page on the first user input becausecurrentTargetis cleared after the listener bubble ends, before React invokes the updater. Switched all M5 handlers toe.target.value, which persists on the synthetic event.
Fixed (post-M5 review pass — spec-reviewer + code-reviewer)
- Filter combinator was OR, not AND (
backend/app/services/test_templates.py:235):?tactic=TA0002&technique=T1059returned templates matching either facet instead of both. Pre-fix also pooled all three UUIDs into a sharedINlist across three columns, theoretically allowing a UUID collision to match across kinds. Refactored to one IN-subquery per facet, ANDed together via repeatedWHERE id IN (...). - Concurrent reorder race on
set_scenario_tests(backend/app/services/scenario_templates.py:207): two parallel reorders on the same scenario could deadlock on theUNIQUE(scenario_id, position)constraint under READ COMMITTED. Added a per-scenariopg_advisory_xact_lock(0x5C3, hash(scenario_id))mirroring the M4/mitre/syncpattern; different scenarios don't contend. - N+1 on
_to_viewMITRE resolution (backend/app/services/test_templates.py:160): rendering K templates with ~T tags each fired up to K×Ts.get(...)calls. Added_to_views_batchthat pre-builds{uuid → MitreRow}maps in 3 queries and feeds them to per-template view assembly;list_test_templatesnow issues 4 queries total regardless of list size. - Wire-level item length cap on
tags/expected_iocs(backend/app/api/test_templates.py:18-21): the DB columns areARRAY(String(64))/ARRAY(String(255))but the API layer only capped the LIST length, not item strings — long inputs hit the driver withStringDataRightTruncation. AddedAnnotated[str, StringConstraints(...)]types so the API returns 400 with a clean validation error. - Front-end mutation cache hygiene (
frontend/src/pages/AdminScenariosPage.tsx:148-156):updateMetaandsetTestsmutations are run sequentially insubmit(); on partial failure (metadata saved but reorder failed) the cache stayed stale. Both mutations nowonSettled: invalidateso whatever step landed is reflected without manual refresh. - Backend vs front-end consistency on duplicate tests in a scenario (
frontend/src/pages/AdminScenariosPage.tsx:227-231): the backend allows the sametest_templateto appear multiple times (chained ops; the UNIQUE constraint is(scenario_id, position)not(scenario_id, test_template_id)), but the catalogue picker was filtering out already-picked items. Removed the filter — only soft-deleted tests are excluded now. - Test coverage closure (
backend/tests/test_templates.py): +4 pytest (tactic+technique AND-semantics,extra="forbid"rejection, emptymitre_tagsexplicit clear, 65-char tag length cap → 400). Total backend now 23 M5 tests + 39 elsewhere = 81 pass.
Added — M4 (MITRE ATT&CK Enterprise)
- STIX 2.1 parser + upsert (
app/services/mitre_seed.py): stdlib-only (urllib.request+hashlib), pinned to Enterprise v19.0 (enterprise-attack-19.0.json, sha256df520ea0…). Parses 25k+ STIX objects → 15 tactics, 222 techniques, 475 sub-techniques in ~1.1 s. Skips revoked + deprecated, resolves sub-technique parents viarelationship[subtechnique-of]with aT1003.001 → T1003dotted-id fallback, copies kill-chain phases into themitre_technique_tacticsM2M. - CLI:
flask metamorph seed-mitre [--source <path|url>] [--checksum-sha256 <hex>] [--skip-checksum](app/cli.py).make seed-mitrewraps it. - REST endpoints (
app/api/mitre.py):GET /api/v1/mitre/tactics,/mitre/techniques?tactic=…&q=…,/mitre/subtechniques?technique=…&q=…(paginated, search on name/external_id).GET /api/v1/mitre/status(last_sync, version, source_url, defaults).POST /api/v1/mitre/sync(permmitre.sync) — re-pull on demand.
- Persisted metadata in
settings:mitre_last_sync,mitre_version,mitre_source_url. - Compose volume
metamorph_mitremounted at/data/mitre/in the api container — caches the downloaded bundle across restarts. Owned bymetamorph:metamorph. - Frontend:
<MitreTagPicker>component: flat ATT&CK matrix matchingattack.mitre.org/#— full-bleed beyondmax-w-page, 15 equal-width columns viagrid-template-columns: repeat(N, minmax(7rem, 1fr)), sans-serif 12px, name-only cells (external_id surfaces on hover viatitleand in selection chips),▸/▾chevron expands sub-techniques inline within the column, multi-select with chip-removal at the top. ReturnsMitreTag[](kind,id,external_id,name), ready for M5 templates./mitreshowcase page with status card, admin-gated Trigger sync button, the picker, and a JSON<pre>preview of the current selection.- Nav adds MITRE link for any logged-in user.
- Testing:
backend/tests/test_mitre.py— 12 pytest (parser, idempotence, checksum mismatch, persisted settings, endpoint variants, perm enforcement) using a hand-crafted minimal STIX bundle (no network in tests).e2e/tests/m4-mitre.spec.ts— 6 Playwright against the live stack (calls/mitre/synconce inbeforeAll).tasks/testing-m4.md.
Fixed (post-M4 spec-review pass)
- Sync integrity guarantee:
seed_mitre()now refuses a custom URL without eitherexpected_sha256or an explicitallow_unverified=true. Closes a "typo inmitre_source_urlsetting routes the seed to attacker JSON" footgun. CLI surfaces this via--checksum-sha256/--skip-checksum; API via{"source", "expected_sha256", "allow_unverified"}body. /diag/resetconsistency: now truncates themitre_*tables alongsidesettingssoGET /mitre/statusandGET /mitre/tacticsagree after a reset (previously: catalogue rows persisted, butmitre_last_syncgot wiped → status lied).- Spec drift §10 #4: amended "14 tactics" → "≥ 14 tactics (v19 ships 15)" to reflect MITRE v8+ reality.
Fixed (post-M4 code-review pass)
- SSRF allowlist on
/mitre/sync: host must be inMITRE_ALLOWED_HOSTS(defaults toraw.githubusercontent.com, comma-separated env override). Closes the "admin holdingmitre.synccan pivot the api container at cloud metadata (169.254.169.254) or internal mirrors" vector. NewMitreSourceForbiddenexception → 400 withsource_forbiddenerror code. - Concurrent sync race:
seed_mitre()now acquirespg_advisory_xact_lock(hashtext('mitre.seed'))at the top of the transaction so two/mitre/synccalls serialise cleanly across theDELETE+ re-INSERTofmitre_technique_tactics. - Typed sync contract end-to-end: Pydantic
SyncResultOuton the backend (app/api/mitre.py) mirrored by aMitreSyncResultTS interface (frontend/src/lib/mitre.ts). The MitrePage mutation no longer uses anas Record<string, unknown>escape hatch. - N+1 in dotted sub-technique fallback: pre-built
{external_id → id}dict at function entry; was firing one extra SELECT per orphan (currently 0 with MITRE, but a latent footgun for partial bundles). SETTING_VERSIONcleared explicitly when source != default: previously kept the stale pinned version after a custom-URL re-sync; now_upsert_setting(..., None)so/mitre/statusdoesn't lie.- Internal error scrub on
/mitre/sync: 500 responses no longer leak URLError / DB driver text viastr(e)— stack lands in JSON logs only. - E2E pinned to exact MITRE v19 counts (15/222/475/0 orphans) for parser-regression detection; previously
>=thresholds could mask "revoked tactics silently included". - E2E uses
crypto.randomUUID()instead ofMath.random()for unique test emails. - Test coverage for security guards:
file://rejection, disallowed HTTPS host, custom-URL-without-sha refusal, dotted-id fallback, version-clearing semantics — 5 new pytest covering paths the spec-review demanded but no test enforced.
Decisions (intentional)
- Bundle "embarqué" interpreted as seed-time download + named-volume cache, not "binary baked into the Docker image". Keeps the image ~150 MB, makes version bumps a constant edit, plays nicely with
make seed-mitrere-runs. Air-gapped operators copy the file into the volume + pass--source /data/mitre/<file>. - Read endpoints unauthenticated-perm-wise but auth-required: MITRE data is public reference material — no
mitre.readperm. Status endpoint is similarly open (under@require_auth) to keep/mitre/statussimple for the UI badge. - No
requests/httpxdep added: stdliburllib.requestis enough and avoids inflating the image.
Validated end-to-end (M4 DoD)
make clean && make up && make migrate && make seed-mitre→ 15 tactics / 222 techniques / 475 sub-techniques / 254 links / 0 orphans / ~1.1 s.make test-api→ 58 pytest pass (1 health + 8 schema + 15 auth + 15 RBAC + 19 MITRE) in ~5 s.make e2e→ 34 Playwright pass (8 M0 + 4 M1 + 8 M2 + 8 M3 + 6 M4) in ~18 s.- Spec-reviewer PASS after fixes applied.
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/setupto 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}/permissionsfor 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 /permissionsreturns the catalogue (admin orgroup.readholders). - 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 whenis_admin === true; direct navigation to/admin/*by non-admins redirects to/. Server remains the arbiter. /diag/resetnow clears the rate-limit counters so the Playwright suite can iterate without hitting10/min/IPbudgets 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,apiDeleteadded tofrontend/src/lib/api.ts.
Fixed (post-M3 spec-review pass)
- Rate-limit scope clarified:
app/core/rate_limit.pynow enables the limiter forAPP_ENV in ("prod", "staging")instead ofprodonly — 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_permissionsrefuses to alter the admin system group's perm set to anything other than the full catalogue (SystemGroupProtected→ 409). The decorator bypass relies onis_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})raisedKeyError: "Attempt to overwrite 'name' in LogRecord"because Python's logger reservesname. Renamed togroup_name. Audited all otherextra=payloads inapp/api/+app/services/for the same trap.
Validated end-to-end (M3 DoD)
make clean && make up && make migrate→ boot logs showmetamorph.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(Argon2idtime_cost=2 memory_cost=64MiB parallelism=2, opaque-token SHA-256 helpers),app.core.jwt_tokens(HS256, claimsiss/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 whenusersis empty, logs a banner with the raw token. CLIflask --app app.cli metamorph print-install-token [--force]. - Auth middleware (
app.core.auth_decorators):@require_authpopulatesg.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/<token>+POST /invitations/accept/<token>+POST /invitations/<id>/revoke.POST /api/v1/diag/reset(test-only kill switch — wipes auth tables + mints fresh install token; only available indev/test).
- Rate limiting (
flask-limiter): 10/min/IP on/auth/login,/auth/refresh; 5/min on/auth/change-passwordand/setup; 10–20/min on invitation endpoints. Globally disabled whenAPP_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/refreshwith reentrancy guard.useAuth()hook +<RequireAuth>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 inApp.tsx(will carry actual queries from M3+). - Email validation (
app.api._validation.Email): permissive RFC-shape regex that accepts internal TLDs (.local,.corp) —pydantic.EmailStrwas 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=Trueunconditionally (backend/app/api/auth.py). Modern browsers treatlocalhostas a secure context, so dev/test still works. Closes the silent-degradation found by the reviewer. /auth/refreshrate-limit lowered to 10/min/IP (backend/app/api/auth.py) to match spec §M2 ("10 req/min/IP on/auth/*")./diag/resetkept allowed indevandtest(amake e2eagainst amake updev stack must be able to reset). Added a WARNING log when triggered indevand 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_versioncovering 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.pyreadingapp.core.config.settings.database_url,alembic/script.py.mako, naming conventionpk_/fk_/ck_/uq_/ix_enforced viaMetaData(naming_convention=...)onapp.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:
JSONBforsettings.valueandnotifications.payload; nativeUuidcolumns; partial indexes (WHERE deleted_at IS NULLon 9 tables;WHERE read_at IS NULLonnotifications); CHECK constraints for status/state/opsec_level/mitre_kind enums;exactly_one_mitre_fkCHECK ontest_template_mitre_tags. mission_test_mitre_tagsdeliberately denormalised (no FK tomitre_*tables): copiesmitre_external_id,mitre_name,mitre_urlat tag time so a later MITRE re-sync that drops an entry cannot purge a mission's tags. Companiontest_template_mitre_tagskeeps FKs since templates are editable. (Spec §11 risk addressed.)- Backend
pyproject.tomldeps: SQLAlchemy ≥2, Alembic ≥1.13, psycopg[binary] ≥3.1. - New Makefile targets:
migrate,migrate-down,migrate-revision MSG=…,migrate-status. The Dockerfile now shipsalembic.ini+alembic/so the api container can run migrations directly. - Test stage in
backend/Dockerfile(--target test): runtime image + dev extras +tests/dir. Newmake test-apitarget 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 theexactly_one_mitre_fkCHECK 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_categoriesgainedSoftDeleteMixin+ theirix_<table>_activepartial index (M12 trash bin depends on this). evidence_filesgainedTimestampMixin(created_at/updated_at) on top of the domainuploaded_at(audit minimal everywhere, per M1 brief).mission_membersgainedTimestampMixin, replacing the bespokeadded_atcolumn.scenario_template_testsPK 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 declaresix_<table>_activeexplicitly. Documented in the mixin's docstring.mission_test_mitre_tagsschema redesigned to denormalise MITRE labels (see "Added" entry above).- Migration 0001 regenerated end-to-end after these fixes —
24765a5014b6is the new HEAD.
Validated end-to-end (M1 DoD)
make clean && make up && make migratefrom 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/dbexposesalembic_revision(short-hashable) and the public-schematable_count. Returns 503 with{"reachable": false}when Postgres is down. - New
Databasecard on the SPA home page consumes that endpoint, renders the revision short-hash and the count next to the existingAPIandRoadmapcards. - Footer updated to
M0 bootstrap · M1 db schema. Roadmap card now points toM2 — 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.ymlwith 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_dbandmetamorph_evidencefor data persistence. - Backend skeleton: Flask app factory, JSON structured logging on stdout,
GET /api/v1/healthendpoint, multi-stage Dockerfile,pyproject.tomldriven byuv. - Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps design tokens (
tasks/design.md) translated intotailwind.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:
rufffor backend,eslint+tsc --noEmitfor frontend.
Validated
docker compose configparses (validated viapyyamlsince 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) andHOST_API_PORT(8000). - The first-admin bootstrap token (M2) will be printed to the api container's stdout on first boot when the
userstable is empty. tasks/spec.mdandtasks/todo.mdremain 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 enforcesshort-name-mode=enforcingfor pulls (no TTY ⇒ no prompt ⇒ failure). Replacedpostgres:16-alpine/python:3.12-slim/node:20-alpine/nginx:1.27-alpinewith theirdocker.io/library/…qualified equivalents. Docker accepts the same prefix transparently. *.mdremoved frombackend/.dockerignoreandfrontend/.dockerignore:pyproject.tomldeclaredreadme = "README.md", but the file was being filtered out of the build context, sohatchling.build.build_wheelraisedOSError: Readme file does not exist: README.md. Also removed thereadmefield itself frompyproject.tomlto decouple the build from the doc.Card.tsxtype clash:CardProps extends HTMLAttributes<HTMLDivElement>redefinedtitleasReactNode, but the nativetitleisstring.tsc -bfailed with TS2430 duringvite build. Switched toOmit<HTMLAttributes<HTMLDivElement>, 'title'>.- Explicit healthchecks added to compose
apiandfront: podman-compose 1.x doesn't surface healthchecks declared only in theDockerfileviainspect. Mirroring them indocker-compose.ymlmakesmake inspect-healthactually seehealthy/unhealthy/startingon every engine. - Suppressed
podman composeexternal-provider banner viaPODMAN_COMPOSE_WARNING_LOGS=falseexported 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 legacypodman-compose). Override viaENGINE=…and/orCOMPOSE="…". - 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 helpnow prints the active engine + compose driver in its footer.tasks/testing-m0.mdandREADME.mdrewritten to be engine-agnostic — rawdocker logs/docker volume ls/docker inspectcalls 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 ine2e/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 deliverstasks/testing-m<N>.md+ at least onee2e/tests/m<N>-*.spec.ts, and the spec-reviewer subagent runs before marking the milestone done.
Fixed (post-M0 spec-review pass)
.pre-commit-config.yamladded at repo root: ruff + ruff-format on backend, eslint + tsc --noEmit + prettier --check on frontend, plus baseline whitespace/JSON/private-key checks. Documentedpre-commit installinREADME.md.- Self-hosted webfonts via
@fontsource/jetbrains-monoand@fontsource/ibm-plex-sans(imported infrontend/src/index.css); dropped the Google Fonts<link>fromfrontend/index.htmlto honor spec §7 ("no runtime CDN"). - Refuse-to-boot guard in
backend/app/core/config.py: whenAPP_ENV != "dev", defaults / placeholders forJWT_SECRETandPOSTGRES_PASSWORDraise at startup. NewAPP_ENVenv var documented in.env.example,README.md, anddocker-compose.yml. make devnow runsdev-apianddev-frontin parallel viamake -j2instead of just printing a hint.- Removed dead
database_urlproperty fromSettings(will be reintroduced in M1 with the SQLAlchemy/Alembic stack). - Pinned Node engines to
>=20infrontend/package.json. - Reconciled M0 DoD wording in
tasks/todo.md(HTTP viaHOST_FRONT_PORT, with explicit note that prod TLS is external). - Documented the
2xs/3xs/4xsfont-size aliases infrontend/tailwind.config.tsagainst the design.md §3 scale.