PROGRESS.md 5.7 KB

M01 — Monorepo skeleton (done)

Built: repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.

Notes for next milestone:

  • DB schema empty; M02 owns all tables and seeds.
  • entrypoint.sh for api supports migrate mode and calls vendor/bin/phinx.
  • Healthcheck payloads are stubs; later milestones extend them.
  • Service-token bootstrap deferred to M03 (needs api_tokens table first).
  • CI runs locally via ./scripts/ci.sh (Docker-based, no host PHP/Node needed). No GitHub Actions workflow per project decision.
  • composer.json config pins platform.php to 8.3 in both subprojects so dependency resolution matches the FrankenPHP runtime image even when the build host's composer:2 image ships a newer PHP.

Deviations from SPEC: none. Added dependencies beyond SPEC §2: none.

M02 — Database & migrations (done)

Built: all SPEC §4 tables; idempotent seeds; IP/CIDR value objects.

Schema notes for next milestone:

  • users.password_hash is NOT in the schema (per SPEC §4; UI owns local-admin credentials).
  • api_tokens.kind enum values: reporter, consumer, admin, service (CHECK constraint enforced on both SQLite and MySQL: kind=reporter→reporter_id set & consumer_id null; kind=consumer→consumer_id set & reporter_id null; kind∈{admin,service}→both null).
  • All timestamps stored UTC. ISO 8601 strings on SQLite, DATETIME(6) on MySQL. Default CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(6) accordingly.
  • ip_bin always 16 bytes; v4 mapped to ::ffff:0:0/96. Use App\Domain\Ip\IpAddress::fromString() for normalization and Cidr::fromString() for subnets. Internally CIDRs store v4 prefixes as 96 + originalPrefix for unified containment math.
  • DBAL Connection is wired through App\App\Container::build() and applies the four SQLite PRAGMAs (journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000, foreign_keys=ON) on every new SQLite connection.
  • Phinx migrations extend App\Infrastructure\Db\Migrations\BaseMigration for adapter-aware timestamp/binary column helpers. The phinxlog table is unaffected.

Decisions made:

  • FK ON DELETE semantics:
    • policy_category_thresholds.policy_id → CASCADE (thresholds belong to policy).
    • policy_category_thresholds.category_id → RESTRICT (cannot drop a category in active use).
    • consumers.policy_id → RESTRICT (cannot drop a policy in active use).
    • reporters/consumers/manual_blocks/allowlist.created_by_user_id → SET NULL (preserve provenance after user delete).
    • api_tokens.{reporter_id,consumer_id} → CASCADE (deleting a reporter/consumer revokes its tokens).
    • reports.{category_id,reporter_id} → RESTRICT (preserve audit trail per SPEC hint).
    • ip_scores.category_id → CASCADE (scores meaningless without their category).
  • api_tokens is created via raw CREATE TABLE per adapter so the CHECK constraint on kind works on SQLite (which cannot ADD CHECK via ALTER TABLE) and on MySQL.
  • BINARY(16) on MySQL is implemented as Phinx's portable binary type with limit => 16 (yields VARBINARY(16)); this is functionally identical for our fixed-width 16-byte payload and avoids per-adapter raw SQL.
  • Fixed an M01 bug in config/phinx.php where rtrim($path, '.sqlite') mangled the SQLite path because rtrim's second arg is a character set; switched to passing the full path verbatim with empty suffix.

Deviations from SPEC: none. Added dependencies: none beyond SPEC §2.

M03 — API auth foundations (done)

Built: token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.

API contract decisions:

  • 401 = bad/expired/revoked/wrong-kind token (uniform body {"error":"unauthorized"})
  • 403 = authenticated but wrong role
  • 400 = service token without (or malformed) X-Acting-User-Id header
  • last_used_at updated synchronously (move to async in M14 if perf demands)
  • /api/v1/auth/* is service-token-only with no impersonation — these endpoints exist to bootstrap user records the UI can later impersonate, so requiring impersonation would be circular. The controller enforces kind=service directly.
  • X-Acting-User-Id is silently ignored on non-service tokens (per SPEC §8); only its absence on a service token triggers 400.

Notes for next milestone:

  • Reporter and consumer tokens have no role column; their auth carries reporter_id / consumer_id only. Reading principal->reporterId from request attrs is how M04's report endpoint will identify the reporter.
  • Admin endpoints in later milestones can use RbacMiddleware::require($responseFactory, Role::Operator) etc. — the factory takes the role; the response factory is in the container.
  • AuthenticatedPrincipal carries an optional userId so M14 can introduce admin-token-bound-to-user without churn.

Schema deviation: api_tokens.role (nullable VARCHAR(32)) was added in migration 20260428130000_add_role_to_api_tokens.php. SPEC §4 doesn't enumerate it but SPEC §6 mandates that admin tokens carry a role; the column stores it. Non-admin token rows leave it NULL.

Token format: irdb_<kind3>_<32 base32 chars>, where kind3 is one of rep|con|adm|svc. 160 bits of entropy from random_bytes(20). The whole raw string is SHA-256 hashed for storage; token_prefix keeps the first 8 chars (irdb_<kind3>) for log readability. The .env.example documents how to generate a valid UI_SERVICE_TOKEN via TokenIssuer.

Service-token rotation: out of scope this milestone — ServiceTokenBootstrap only handles "set or not set". Rotation means: deploy with the new value, restart api, manually revoke the old hash via a future tool. The bootstrap logs a warning when it inserts a new service token while another already exists.

Added dependencies: none.