瀏覽代碼

feat(M02): database schema, migrations, seeds, IP/CIDR helpers

- phinx migrations for all SPEC §4 tables (sqlite + mysql)
- default seeds: 5 categories, 3 policies (strict/moderate/paranoid)
- IpAddress and Cidr value objects with 100% coverage on Domain/Ip
- DBAL connection factory with SQLite WAL pragmas, DI container wiring
- bin/console gains db:seed and db:rollback alongside db:migrate
- integration test runs full migration set against in-memory SQLite,
  asserts table list, composite PKs, FK enforcement, and the
  api_tokens.kind CHECK constraint
- fix M01 phinx config bug where rtrim(\$path, '.sqlite') mangled the
  SQLite path because rtrim's second arg is a character set

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 周之前
父節點
當前提交
77759c77de
共有 33 個文件被更改,包括 2188 次插入10 次删除
  1. 3 0
      .gitignore
  2. 28 0
      PROGRESS.md
  3. 31 8
      api/bin/console
  4. 5 2
      api/config/phinx.php
  5. 32 0
      api/db/migrations/20260428120000_create_users.php
  6. 22 0
      api/db/migrations/20260428120001_create_oidc_role_mappings.php
  7. 22 0
      api/db/migrations/20260428120002_create_categories.php
  8. 23 0
      api/db/migrations/20260428120003_create_policies.php
  9. 34 0
      api/db/migrations/20260428120004_create_policy_category_thresholds.php
  10. 32 0
      api/db/migrations/20260428120005_create_reporters.php
  11. 40 0
      api/db/migrations/20260428120006_create_consumers.php
  12. 83 0
      api/db/migrations/20260428120007_create_api_tokens.php
  13. 43 0
      api/db/migrations/20260428120008_create_reports.php
  14. 36 0
      api/db/migrations/20260428120009_create_ip_scores.php
  15. 29 0
      api/db/migrations/20260428120010_create_ip_enrichment.php
  16. 37 0
      api/db/migrations/20260428120011_create_manual_blocks.php
  17. 36 0
      api/db/migrations/20260428120012_create_allowlist.php
  18. 29 0
      api/db/migrations/20260428120013_create_audit_log.php
  19. 24 0
      api/db/migrations/20260428120014_create_job_locks.php
  20. 27 0
      api/db/migrations/20260428120015_create_job_runs.php
  21. 69 0
      api/db/seeds/DefaultCategoriesSeeder.php
  22. 133 0
      api/db/seeds/DefaultPoliciesSeeder.php
  23. 52 0
      api/src/App/Container.php
  24. 160 0
      api/src/Domain/Ip/Cidr.php
  25. 11 0
      api/src/Domain/Ip/InvalidCidrException.php
  26. 11 0
      api/src/Domain/Ip/InvalidIpException.php
  27. 157 0
      api/src/Domain/Ip/IpAddress.php
  28. 82 0
      api/src/Infrastructure/Db/ConnectionFactory.php
  29. 59 0
      api/src/Infrastructure/Db/Migrations/BaseMigration.php
  30. 107 0
      api/src/Infrastructure/Db/RepositoryBase.php
  31. 299 0
      api/tests/Integration/MigrationsTest.php
  32. 232 0
      api/tests/Unit/Ip/CidrTest.php
  33. 200 0
      api/tests/Unit/Ip/IpAddressTest.php

+ 3 - 0
.gitignore

@@ -31,3 +31,6 @@ data/
 *.swp
 *~
 .DS_Store
+
+# Claude Code session state
+.claude/

+ 28 - 0
PROGRESS.md

@@ -12,3 +12,31 @@
 
 **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.

+ 31 - 8
api/bin/console

@@ -8,20 +8,43 @@ require __DIR__ . '/../vendor/autoload.php';
 $argv = $_SERVER['argv'] ?? [];
 $command = $argv[1] ?? null;
 
+$phinxBin = __DIR__ . '/../vendor/bin/phinx';
+$phinxConfig = __DIR__ . '/../config/phinx.php';
+
+$run = static function (string $phinxCommand) use ($phinxBin, $phinxConfig): never {
+    $cmd = sprintf(
+        '%s %s --configuration=%s',
+        escapeshellarg($phinxBin),
+        $phinxCommand,
+        escapeshellarg($phinxConfig)
+    );
+    passthru($cmd, $exitCode);
+    exit($exitCode);
+};
+
 switch ($command) {
     case 'db:migrate':
-        $cmd = sprintf(
-            '%s --configuration=%s',
-            escapeshellarg(__DIR__ . '/../vendor/bin/phinx') . ' migrate',
-            escapeshellarg(__DIR__ . '/../config/phinx.php')
-        );
-        passthru($cmd, $exitCode);
-        exit($exitCode);
+        $run('migrate');
+        // no break
+    case 'db:rollback':
+        $run('rollback');
+        // no break
+    case 'db:seed':
+        $run('seed:run');
+        // no break
 
     case null:
     case '--help':
     case '-h':
-        fwrite(STDOUT, "Usage: console <command>\n\nCommands:\n  db:migrate   Run Phinx migrations\n");
+        fwrite(STDOUT, <<<TXT
+            Usage: console <command>
+
+            Commands:
+              db:migrate    Run Phinx migrations
+              db:rollback   Roll back the most recent migration
+              db:seed       Run all seeders idempotently
+
+            TXT);
         exit(0);
 
     default:

+ 5 - 2
api/config/phinx.php

@@ -25,8 +25,11 @@ $envConfig = $driver === 'mysql'
     ]
     : [
         'adapter' => 'sqlite',
-        'name' => rtrim((string) (getenv('DB_SQLITE_PATH') ?: '/data/irdb.sqlite'), '.sqlite'),
-        'suffix' => '.sqlite',
+        // Pass the full path through verbatim (incl. extension or `:memory:`).
+        // Phinx appends `suffix` to `name` when computing the DSN; an empty
+        // suffix avoids the trim-the-extension dance that bit M01.
+        'name' => (string) (getenv('DB_SQLITE_PATH') ?: '/data/irdb.sqlite'),
+        'suffix' => '',
     ];
 
 return [

+ 32 - 0
api/db/migrations/20260428120000_create_users.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateUsers extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('users');
+        $table
+            ->addColumn('subject', 'string', ['limit' => 255, 'null' => true])
+            ->addColumn('email', 'string', ['limit' => 255, 'null' => true])
+            ->addColumn('display_name', 'string', ['limit' => 255, 'null' => true])
+            ->addColumn('role', 'string', ['limit' => 32, 'null' => false])
+            ->addColumn('is_local', 'boolean', ['null' => false, 'default' => false]);
+
+        $this->addTimestampColumn($table, 'last_login_at', ['null' => true]);
+        $this->addTimestampColumn($table, 'created_at');
+
+        // SQLite and MySQL both treat NULLs as distinct in unique indexes, so a
+        // full unique index on `subject` works for either driver — local users
+        // simply have NULL subjects.
+        $table
+            ->addIndex(['subject'], ['unique' => true, 'name' => 'uniq_users_subject'])
+            ->addIndex(['email'])
+            ->addIndex(['is_local']);
+
+        $table->create();
+    }
+}

+ 22 - 0
api/db/migrations/20260428120001_create_oidc_role_mappings.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateOidcRoleMappings extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('oidc_role_mappings');
+        $table
+            ->addColumn('group_id', 'string', ['limit' => 128, 'null' => false])
+            ->addColumn('role', 'string', ['limit' => 32, 'null' => false]);
+
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['group_id'], ['unique' => true, 'name' => 'uniq_oidc_role_mappings_group_id'])
+            ->create();
+    }
+}

+ 22 - 0
api/db/migrations/20260428120002_create_categories.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateCategories extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('categories');
+        $table
+            ->addColumn('slug', 'string', ['limit' => 64, 'null' => false])
+            ->addColumn('name', 'string', ['limit' => 128, 'null' => false])
+            ->addColumn('description', 'text', ['null' => true])
+            ->addColumn('decay_function', 'string', ['limit' => 32, 'null' => false])
+            ->addColumn('decay_param', 'decimal', ['precision' => 10, 'scale' => 4, 'null' => false])
+            ->addColumn('is_active', 'boolean', ['null' => false, 'default' => true])
+            ->addIndex(['slug'], ['unique' => true, 'name' => 'uniq_categories_slug'])
+            ->create();
+    }
+}

+ 23 - 0
api/db/migrations/20260428120003_create_policies.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreatePolicies extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('policies');
+        $table
+            ->addColumn('name', 'string', ['limit' => 128, 'null' => false])
+            ->addColumn('description', 'text', ['null' => true])
+            ->addColumn('include_manual_blocks', 'boolean', ['null' => false, 'default' => true]);
+
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['name'], ['unique' => true, 'name' => 'uniq_policies_name'])
+            ->create();
+    }
+}

+ 34 - 0
api/db/migrations/20260428120004_create_policy_category_thresholds.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreatePolicyCategoryThresholds extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('policy_category_thresholds', [
+            'id' => false,
+            'primary_key' => ['policy_id', 'category_id'],
+        ]);
+        $table
+            ->addColumn('policy_id', 'integer', ['null' => false, 'signed' => false])
+            ->addColumn('category_id', 'integer', ['null' => false, 'signed' => false])
+            ->addColumn('threshold', 'decimal', ['precision' => 10, 'scale' => 4, 'null' => false])
+            ->addForeignKey(
+                'policy_id',
+                'policies',
+                'id',
+                ['delete' => 'CASCADE', 'update' => 'NO_ACTION', 'constraint' => 'fk_pct_policy']
+            )
+            ->addForeignKey(
+                'category_id',
+                'categories',
+                'id',
+                ['delete' => 'RESTRICT', 'update' => 'NO_ACTION', 'constraint' => 'fk_pct_category']
+            )
+            ->addIndex(['category_id'])
+            ->create();
+    }
+}

+ 32 - 0
api/db/migrations/20260428120005_create_reporters.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateReporters extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('reporters');
+        $table
+            ->addColumn('name', 'string', ['limit' => 128, 'null' => false])
+            ->addColumn('description', 'text', ['null' => true])
+            ->addColumn('trust_weight', 'decimal', ['precision' => 5, 'scale' => 2, 'null' => false, 'default' => '1.00'])
+            ->addColumn('is_active', 'boolean', ['null' => false, 'default' => true])
+            ->addColumn('created_by_user_id', 'integer', ['null' => true, 'signed' => false]);
+
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['name'], ['unique' => true, 'name' => 'uniq_reporters_name'])
+            ->addIndex(['created_by_user_id'])
+            ->addForeignKey(
+                'created_by_user_id',
+                'users',
+                'id',
+                ['delete' => 'SET_NULL', 'update' => 'NO_ACTION', 'constraint' => 'fk_reporters_created_by']
+            )
+            ->create();
+    }
+}

+ 40 - 0
api/db/migrations/20260428120006_create_consumers.php

@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateConsumers extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('consumers');
+        $table
+            ->addColumn('name', 'string', ['limit' => 128, 'null' => false])
+            ->addColumn('description', 'text', ['null' => true])
+            ->addColumn('policy_id', 'integer', ['null' => false, 'signed' => false])
+            ->addColumn('is_active', 'boolean', ['null' => false, 'default' => true])
+            ->addColumn('created_by_user_id', 'integer', ['null' => true, 'signed' => false]);
+
+        $this->addTimestampColumn($table, 'created_at');
+        $this->addTimestampColumn($table, 'last_pulled_at', ['null' => true]);
+
+        $table
+            ->addIndex(['name'], ['unique' => true, 'name' => 'uniq_consumers_name'])
+            ->addIndex(['policy_id'])
+            ->addIndex(['created_by_user_id'])
+            ->addForeignKey(
+                'policy_id',
+                'policies',
+                'id',
+                ['delete' => 'RESTRICT', 'update' => 'NO_ACTION', 'constraint' => 'fk_consumers_policy']
+            )
+            ->addForeignKey(
+                'created_by_user_id',
+                'users',
+                'id',
+                ['delete' => 'SET_NULL', 'update' => 'NO_ACTION', 'constraint' => 'fk_consumers_created_by']
+            )
+            ->create();
+    }
+}

+ 83 - 0
api/db/migrations/20260428120007_create_api_tokens.php

@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * api_tokens has a CHECK constraint correlating `kind` with which of
+ * `reporter_id` / `consumer_id` is set. Phinx's high-level API doesn't
+ * expose CHECK constraints directly, and SQLite cannot add CHECK via
+ * ALTER TABLE — so this migration writes raw CREATE TABLE per adapter.
+ */
+final class CreateApiTokens extends BaseMigration
+{
+    public function up(): void
+    {
+        if ($this->isMysql()) {
+            $this->execute(<<<'SQL'
+                CREATE TABLE api_tokens (
+                    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+                    token_hash VARCHAR(64) NOT NULL,
+                    token_prefix VARCHAR(16) NOT NULL,
+                    kind VARCHAR(32) NOT NULL,
+                    reporter_id INT UNSIGNED NULL,
+                    consumer_id INT UNSIGNED NULL,
+                    expires_at DATETIME(6) NULL,
+                    revoked_at DATETIME(6) NULL,
+                    last_used_at DATETIME(6) NULL,
+                    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+                    CONSTRAINT chk_api_tokens_kind CHECK (
+                        (kind = 'reporter' AND reporter_id IS NOT NULL AND consumer_id IS NULL) OR
+                        (kind = 'consumer' AND consumer_id IS NOT NULL AND reporter_id IS NULL) OR
+                        (kind IN ('admin', 'service') AND reporter_id IS NULL AND consumer_id IS NULL)
+                    ),
+                    CONSTRAINT fk_api_tokens_reporter FOREIGN KEY (reporter_id)
+                        REFERENCES reporters(id) ON DELETE CASCADE,
+                    CONSTRAINT fk_api_tokens_consumer FOREIGN KEY (consumer_id)
+                        REFERENCES consumers(id) ON DELETE CASCADE,
+                    UNIQUE KEY uniq_api_tokens_hash (token_hash),
+                    KEY idx_api_tokens_kind (kind),
+                    KEY idx_api_tokens_reporter_id (reporter_id),
+                    KEY idx_api_tokens_consumer_id (consumer_id),
+                    KEY idx_api_tokens_revoked_at (revoked_at)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+            SQL);
+
+            return;
+        }
+
+        // SQLite path
+        $this->execute(<<<'SQL'
+            CREATE TABLE api_tokens (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                token_hash VARCHAR(64) NOT NULL,
+                token_prefix VARCHAR(16) NOT NULL,
+                kind VARCHAR(32) NOT NULL,
+                reporter_id INTEGER NULL,
+                consumer_id INTEGER NULL,
+                expires_at TEXT NULL,
+                revoked_at TEXT NULL,
+                last_used_at TEXT NULL,
+                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT chk_api_tokens_kind CHECK (
+                    (kind = 'reporter' AND reporter_id IS NOT NULL AND consumer_id IS NULL) OR
+                    (kind = 'consumer' AND consumer_id IS NOT NULL AND reporter_id IS NULL) OR
+                    (kind IN ('admin', 'service') AND reporter_id IS NULL AND consumer_id IS NULL)
+                ),
+                FOREIGN KEY (reporter_id) REFERENCES reporters(id) ON DELETE CASCADE,
+                FOREIGN KEY (consumer_id) REFERENCES consumers(id) ON DELETE CASCADE
+            )
+        SQL);
+        $this->execute('CREATE UNIQUE INDEX uniq_api_tokens_hash ON api_tokens(token_hash)');
+        $this->execute('CREATE INDEX idx_api_tokens_kind ON api_tokens(kind)');
+        $this->execute('CREATE INDEX idx_api_tokens_reporter_id ON api_tokens(reporter_id)');
+        $this->execute('CREATE INDEX idx_api_tokens_consumer_id ON api_tokens(consumer_id)');
+        $this->execute('CREATE INDEX idx_api_tokens_revoked_at ON api_tokens(revoked_at)');
+    }
+
+    public function down(): void
+    {
+        $this->execute('DROP TABLE IF EXISTS api_tokens');
+    }
+}

+ 43 - 0
api/db/migrations/20260428120008_create_reports.php

@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateReports extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('reports');
+        $table->addColumn('category_id', 'integer', ['null' => false, 'signed' => false]);
+        $table->addColumn('reporter_id', 'integer', ['null' => false, 'signed' => false]);
+
+        $this->addIpBinaryColumn($table, 'ip_bin', ['null' => false]);
+        $table->addColumn('ip_text', 'string', ['limit' => 45, 'null' => false]);
+
+        $table->addColumn('weight_at_report', 'decimal', ['precision' => 5, 'scale' => 2, 'null' => false]);
+        $table->addColumn('metadata_json', 'text', ['null' => true]);
+
+        $this->addTimestampColumn($table, 'received_at');
+
+        $table
+            ->addIndex(['ip_bin', 'category_id', 'received_at'], ['name' => 'idx_reports_ip_cat_received'])
+            ->addIndex(['ip_bin'], ['name' => 'idx_reports_ip_bin'])
+            ->addIndex(['category_id'])
+            ->addIndex(['reporter_id'])
+            ->addIndex(['received_at'])
+            ->addForeignKey(
+                'category_id',
+                'categories',
+                'id',
+                ['delete' => 'RESTRICT', 'update' => 'NO_ACTION', 'constraint' => 'fk_reports_category']
+            )
+            ->addForeignKey(
+                'reporter_id',
+                'reporters',
+                'id',
+                ['delete' => 'RESTRICT', 'update' => 'NO_ACTION', 'constraint' => 'fk_reports_reporter']
+            )
+            ->create();
+    }
+}

+ 36 - 0
api/db/migrations/20260428120009_create_ip_scores.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateIpScores extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('ip_scores', [
+            'id' => false,
+            'primary_key' => ['ip_bin', 'category_id'],
+        ]);
+
+        $this->addIpBinaryColumn($table, 'ip_bin', ['null' => false]);
+        $table->addColumn('category_id', 'integer', ['null' => false, 'signed' => false]);
+        $table->addColumn('ip_text', 'string', ['limit' => 45, 'null' => false]);
+        $table->addColumn('score', 'decimal', ['precision' => 12, 'scale' => 4, 'null' => false, 'default' => '0.0000']);
+        $table->addColumn('report_count_30d', 'integer', ['null' => false, 'default' => 0, 'signed' => false]);
+
+        $this->addTimestampColumn($table, 'last_report_at', ['null' => true]);
+        $this->addTimestampColumn($table, 'recomputed_at', ['null' => true]);
+
+        $table
+            ->addIndex(['category_id'])
+            ->addIndex(['score'])
+            ->addForeignKey(
+                'category_id',
+                'categories',
+                'id',
+                ['delete' => 'CASCADE', 'update' => 'NO_ACTION', 'constraint' => 'fk_ip_scores_category']
+            )
+            ->create();
+    }
+}

+ 29 - 0
api/db/migrations/20260428120010_create_ip_enrichment.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateIpEnrichment extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('ip_enrichment', [
+            'id' => false,
+            'primary_key' => ['ip_bin'],
+        ]);
+
+        $this->addIpBinaryColumn($table, 'ip_bin', ['null' => false]);
+        $table
+            ->addColumn('country_code', 'string', ['limit' => 2, 'null' => true])
+            ->addColumn('asn', 'integer', ['null' => true, 'signed' => false])
+            ->addColumn('as_org', 'string', ['limit' => 255, 'null' => true]);
+
+        $this->addTimestampColumn($table, 'enriched_at');
+
+        $table
+            ->addIndex(['country_code'])
+            ->addIndex(['asn'])
+            ->create();
+    }
+}

+ 37 - 0
api/db/migrations/20260428120011_create_manual_blocks.php

@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateManualBlocks extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('manual_blocks');
+        $table->addColumn('kind', 'string', ['limit' => 16, 'null' => false]);
+
+        $this->addIpBinaryColumn($table, 'ip_bin', ['null' => true]);
+        $this->addIpBinaryColumn($table, 'network_bin', ['null' => true]);
+        $table->addColumn('prefix_length', 'smallinteger', ['null' => true, 'signed' => false]);
+
+        $table->addColumn('reason', 'text', ['null' => true]);
+        $table->addColumn('created_by_user_id', 'integer', ['null' => true, 'signed' => false]);
+
+        $this->addTimestampColumn($table, 'expires_at', ['null' => true]);
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['ip_bin'], ['name' => 'idx_manual_blocks_ip_bin'])
+            ->addIndex(['network_bin'], ['name' => 'idx_manual_blocks_network_bin'])
+            ->addIndex(['kind'])
+            ->addIndex(['created_by_user_id'])
+            ->addForeignKey(
+                'created_by_user_id',
+                'users',
+                'id',
+                ['delete' => 'SET_NULL', 'update' => 'NO_ACTION', 'constraint' => 'fk_manual_blocks_created_by']
+            )
+            ->create();
+    }
+}

+ 36 - 0
api/db/migrations/20260428120012_create_allowlist.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateAllowlist extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('allowlist');
+        $table->addColumn('kind', 'string', ['limit' => 16, 'null' => false]);
+
+        $this->addIpBinaryColumn($table, 'ip_bin', ['null' => true]);
+        $this->addIpBinaryColumn($table, 'network_bin', ['null' => true]);
+        $table->addColumn('prefix_length', 'smallinteger', ['null' => true, 'signed' => false]);
+
+        $table->addColumn('reason', 'text', ['null' => true]);
+        $table->addColumn('created_by_user_id', 'integer', ['null' => true, 'signed' => false]);
+
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['ip_bin'], ['name' => 'idx_allowlist_ip_bin'])
+            ->addIndex(['network_bin'], ['name' => 'idx_allowlist_network_bin'])
+            ->addIndex(['kind'])
+            ->addIndex(['created_by_user_id'])
+            ->addForeignKey(
+                'created_by_user_id',
+                'users',
+                'id',
+                ['delete' => 'SET_NULL', 'update' => 'NO_ACTION', 'constraint' => 'fk_allowlist_created_by']
+            )
+            ->create();
+    }
+}

+ 29 - 0
api/db/migrations/20260428120013_create_audit_log.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateAuditLog extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('audit_log');
+        $table
+            ->addColumn('actor_kind', 'string', ['limit' => 16, 'null' => false])
+            ->addColumn('actor_id', 'string', ['limit' => 64, 'null' => true])
+            ->addColumn('action', 'string', ['limit' => 64, 'null' => false])
+            ->addColumn('target_type', 'string', ['limit' => 64, 'null' => true])
+            ->addColumn('target_id', 'string', ['limit' => 64, 'null' => true])
+            ->addColumn('details_json', 'text', ['null' => true])
+            ->addColumn('ip_address', 'string', ['limit' => 45, 'null' => true]);
+
+        $this->addTimestampColumn($table, 'created_at');
+
+        $table
+            ->addIndex(['created_at'])
+            ->addIndex(['actor_kind', 'actor_id'])
+            ->addIndex(['target_type', 'target_id'])
+            ->create();
+    }
+}

+ 24 - 0
api/db/migrations/20260428120014_create_job_locks.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateJobLocks extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('job_locks', [
+            'id' => false,
+            'primary_key' => ['job_name'],
+        ]);
+        $table
+            ->addColumn('job_name', 'string', ['limit' => 64, 'null' => false])
+            ->addColumn('acquired_by', 'string', ['limit' => 128, 'null' => false]);
+
+        $this->addTimestampColumn($table, 'acquired_at');
+        $this->addTimestampColumn($table, 'expires_at');
+
+        $table->addIndex(['expires_at'])->create();
+    }
+}

+ 27 - 0
api/db/migrations/20260428120015_create_job_runs.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+final class CreateJobRuns extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('job_runs');
+        $table
+            ->addColumn('job_name', 'string', ['limit' => 64, 'null' => false])
+            ->addColumn('status', 'string', ['limit' => 32, 'null' => false])
+            ->addColumn('items_processed', 'integer', ['null' => false, 'default' => 0])
+            ->addColumn('error_message', 'text', ['null' => true])
+            ->addColumn('triggered_by', 'string', ['limit' => 32, 'null' => false]);
+
+        $this->addTimestampColumn($table, 'started_at');
+        $this->addTimestampColumn($table, 'finished_at', ['null' => true]);
+
+        $table
+            ->addIndex(['job_name', 'started_at'], ['name' => 'idx_job_runs_job_started'])
+            ->addIndex(['status'])
+            ->create();
+    }
+}

+ 69 - 0
api/db/seeds/DefaultCategoriesSeeder.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+use Phinx\Seed\AbstractSeed;
+
+/**
+ * Seeds the five default abuse categories. Idempotent: each category is
+ * inserted only if no row with the same `slug` exists.
+ *
+ * Default decay function across all five is exponential with a 14-day half-life
+ * (per SPEC §5 default).
+ */
+final class DefaultCategoriesSeeder extends AbstractSeed
+{
+    public function run(): void
+    {
+        $categories = [
+            [
+                'slug' => 'brute_force',
+                'name' => 'Brute force',
+                'description' => 'Repeated authentication failures (SSH, web logins, SMTP AUTH).',
+            ],
+            [
+                'slug' => 'spam',
+                'name' => 'Spam',
+                'description' => 'Unsolicited email/comment/forum spam from this source.',
+            ],
+            [
+                'slug' => 'scanner',
+                'name' => 'Scanner',
+                'description' => 'Port/vulnerability scanning, recon traffic without follow-through exploitation.',
+            ],
+            [
+                'slug' => 'malware_c2',
+                'name' => 'Malware C2',
+                'description' => 'Suspected command-and-control endpoint or malware distribution host.',
+            ],
+            [
+                'slug' => 'web_attack',
+                'name' => 'Web attack',
+                'description' => 'Active web exploitation attempts: SQLi, XSS, LFI, RCE payloads.',
+            ],
+        ];
+
+        $existing = $this->fetchAll('SELECT slug FROM categories');
+        $existingSlugs = array_column($existing, 'slug');
+
+        $rows = [];
+        foreach ($categories as $cat) {
+            if (in_array($cat['slug'], $existingSlugs, true)) {
+                continue;
+            }
+
+            $rows[] = [
+                'slug' => $cat['slug'],
+                'name' => $cat['name'],
+                'description' => $cat['description'],
+                'decay_function' => 'exponential',
+                'decay_param' => '14.0000',
+                'is_active' => 1,
+            ];
+        }
+
+        if ($rows !== []) {
+            $this->table('categories')->insert($rows)->save();
+        }
+    }
+}

+ 133 - 0
api/db/seeds/DefaultPoliciesSeeder.php

@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+use Phinx\Seed\AbstractSeed;
+
+/**
+ * Seeds three baseline policies — paranoid (most aggressive), moderate
+ * (balanced), strict (highest signal-to-noise) — with thresholds across all
+ * five default categories.
+ *
+ * Naming nuance: "strict" here means "strictly only block when confidence is
+ * high" (high threshold = fewer entries) while "paranoid" means "block at the
+ * slightest hint" (low threshold = many entries). This mirrors operator usage
+ * where "strict" describes the inclusion bar, not the resulting blocklist size.
+ *
+ * Idempotent: skips policies and threshold rows that already exist.
+ */
+final class DefaultPoliciesSeeder extends AbstractSeed
+{
+    /**
+     * @return array<class-string<\Phinx\Seed\AbstractSeed>>
+     */
+    public function getDependencies(): array
+    {
+        return ['DefaultCategoriesSeeder'];
+    }
+
+    public function run(): void
+    {
+        $policiesSpec = [
+            'strict' => [
+                'description' => 'Conservative: block only IPs with strong signal across categories.',
+                'thresholds' => [
+                    'brute_force' => '2.5000',
+                    'spam' => '2.5000',
+                    'scanner' => '2.5000',
+                    'malware_c2' => '2.5000',
+                    'web_attack' => '2.5000',
+                ],
+            ],
+            'moderate' => [
+                'description' => 'Balanced: block IPs with moderate accumulated abuse signal.',
+                'thresholds' => [
+                    'brute_force' => '1.0000',
+                    'spam' => '1.0000',
+                    'scanner' => '1.0000',
+                    'malware_c2' => '1.0000',
+                    'web_attack' => '1.0000',
+                ],
+            ],
+            'paranoid' => [
+                'description' => 'Aggressive: block on faint signal; expect more false positives.',
+                'thresholds' => [
+                    'brute_force' => '0.3000',
+                    'spam' => '0.3000',
+                    'scanner' => '0.3000',
+                    'malware_c2' => '0.3000',
+                    'web_attack' => '0.3000',
+                ],
+            ],
+        ];
+
+        $existingPolicies = $this->fetchAll('SELECT id, name FROM policies');
+        $policyByName = [];
+        foreach ($existingPolicies as $row) {
+            $policyByName[$row['name']] = (int) $row['id'];
+        }
+
+        $insertPolicies = [];
+        foreach ($policiesSpec as $name => $spec) {
+            if (isset($policyByName[$name])) {
+                continue;
+            }
+            $insertPolicies[] = [
+                'name' => $name,
+                'description' => $spec['description'],
+                'include_manual_blocks' => 1,
+            ];
+        }
+
+        if ($insertPolicies !== []) {
+            $this->table('policies')->insert($insertPolicies)->save();
+        }
+
+        // Re-fetch to include newly inserted policies.
+        $allPolicies = $this->fetchAll('SELECT id, name FROM policies');
+        $policyByName = [];
+        foreach ($allPolicies as $row) {
+            $policyByName[$row['name']] = (int) $row['id'];
+        }
+
+        $allCategories = $this->fetchAll('SELECT id, slug FROM categories');
+        $categoryBySlug = [];
+        foreach ($allCategories as $row) {
+            $categoryBySlug[$row['slug']] = (int) $row['id'];
+        }
+
+        $existingThresholds = $this->fetchAll('SELECT policy_id, category_id FROM policy_category_thresholds');
+        $existingPairs = [];
+        foreach ($existingThresholds as $row) {
+            $existingPairs[(int) $row['policy_id'] . ':' . (int) $row['category_id']] = true;
+        }
+
+        $thresholdRows = [];
+        foreach ($policiesSpec as $name => $spec) {
+            if (!isset($policyByName[$name])) {
+                continue;
+            }
+            $policyId = $policyByName[$name];
+
+            foreach ($spec['thresholds'] as $slug => $threshold) {
+                if (!isset($categoryBySlug[$slug])) {
+                    continue;
+                }
+                $categoryId = $categoryBySlug[$slug];
+                $key = $policyId . ':' . $categoryId;
+                if (isset($existingPairs[$key])) {
+                    continue;
+                }
+                $thresholdRows[] = [
+                    'policy_id' => $policyId,
+                    'category_id' => $categoryId,
+                    'threshold' => $threshold,
+                ];
+            }
+        }
+
+        if ($thresholdRows !== []) {
+            $this->table('policy_category_thresholds')->insert($thresholdRows)->save();
+        }
+    }
+}

+ 52 - 0
api/src/App/Container.php

@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\App;
+
+use App\Infrastructure\Db\ConnectionFactory;
+use DI\ContainerBuilder;
+
+use function DI\factory;
+
+use Doctrine\DBAL\Connection;
+use Psr\Container\ContainerInterface;
+
+/**
+ * Builds the api's DI container.
+ *
+ * Kept deliberately small in M02 — only application settings and the DBAL
+ * Connection are registered. Subsequent milestones add repositories,
+ * services, middleware, etc. on top of this base.
+ */
+final class Container
+{
+    /**
+     * @param array<string, mixed>|null $settings Optional override (tests pass in fixtures).
+     */
+    public static function build(?array $settings = null): ContainerInterface
+    {
+        $settings ??= require __DIR__ . '/../../config/settings.php';
+
+        $builder = new ContainerBuilder();
+        $builder->useAutowiring(true);
+        $builder->addDefinitions([
+            'settings' => $settings,
+            'settings.db' => $settings['db'],
+            ConnectionFactory::class => factory(static function (ContainerInterface $c): ConnectionFactory {
+                /** @var array{driver: string, sqlite_path: string, mysql_host: string, mysql_port: int, mysql_database: string, mysql_username: string, mysql_password: string} $db */
+                $db = $c->get('settings.db');
+
+                return new ConnectionFactory($db);
+            }),
+            Connection::class => factory(static function (ContainerInterface $c): Connection {
+                /** @var ConnectionFactory $factory */
+                $factory = $c->get(ConnectionFactory::class);
+
+                return $factory->create();
+            }),
+        ]);
+
+        return $builder->build();
+    }
+}

+ 160 - 0
api/src/Domain/Ip/Cidr.php

@@ -0,0 +1,160 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Ip;
+
+/**
+ * Immutable CIDR (network + prefix length) value object.
+ *
+ * Internally the network is stored as a 16-byte binary plus a prefix length in
+ * the [0, 128] range. IPv4 prefixes are stored as 96 + originalPrefix so that
+ * containment math is uniform across families: an IPv4 /24 becomes a /120 over
+ * the IPv4-mapped IPv6 representation.
+ */
+final class Cidr
+{
+    private function __construct(
+        private readonly string $network,
+        private readonly int $prefixLength,
+        private readonly int $originalPrefix,
+        private readonly bool $isIpv4,
+    ) {
+    }
+
+    public static function fromString(string $input): self
+    {
+        if ($input === '') {
+            throw new InvalidCidrException('CIDR cannot be empty.');
+        }
+
+        if ($input !== trim($input)) {
+            throw new InvalidCidrException('CIDR must not contain surrounding whitespace.');
+        }
+
+        if (!str_contains($input, '/')) {
+            throw new InvalidCidrException(sprintf('CIDR must contain a "/" separator: %s', $input));
+        }
+
+        $parts = explode('/', $input);
+        if (count($parts) !== 2) {
+            throw new InvalidCidrException(sprintf('CIDR must have exactly one "/": %s', $input));
+        }
+
+        [$ipPart, $prefixPart] = $parts;
+
+        if ($prefixPart === '' || !ctype_digit($prefixPart)) {
+            throw new InvalidCidrException(sprintf('CIDR prefix must be a non-negative integer: %s', $input));
+        }
+
+        try {
+            $ip = IpAddress::fromString($ipPart);
+        } catch (InvalidIpException $e) {
+            throw new InvalidCidrException(
+                sprintf('Invalid IP in CIDR "%s": %s', $input, $e->getMessage()),
+                0,
+                $e
+            );
+        }
+
+        $original = (int) $prefixPart;
+
+        if ($ip->isIpv4()) {
+            if ($original > 32) {
+                throw new InvalidCidrException(sprintf('IPv4 prefix out of range 0-32: %s', $input));
+            }
+            $internal = 96 + $original;
+        } else {
+            if ($original > 128) {
+                throw new InvalidCidrException(sprintf('IPv6 prefix out of range 0-128: %s', $input));
+            }
+            $internal = $original;
+        }
+
+        $network = self::applyMask($ip->binary(), $internal);
+
+        return new self($network, $internal, $original, $ip->isIpv4());
+    }
+
+    public function contains(IpAddress $ip): bool
+    {
+        return self::applyMask($ip->binary(), $this->prefixLength) === $this->network;
+    }
+
+    public function network(): string
+    {
+        return $this->network;
+    }
+
+    /**
+     * Prefix length in the unified IPv6 space (0-128).
+     */
+    public function prefixLength(): int
+    {
+        return $this->prefixLength;
+    }
+
+    /**
+     * The prefix length the user provided (0-32 for IPv4, 0-128 for IPv6).
+     */
+    public function originalPrefix(): int
+    {
+        return $this->originalPrefix;
+    }
+
+    public function isIpv4(): bool
+    {
+        return $this->isIpv4;
+    }
+
+    public function text(): string
+    {
+        if ($this->isIpv4) {
+            $v4Bin = substr($this->network, 12, 4);
+            $text = inet_ntop($v4Bin);
+            if ($text === false) {
+                $text = '0.0.0.0'; // @codeCoverageIgnore
+            }
+
+            return $text . '/' . $this->originalPrefix;
+        }
+
+        $text = inet_ntop($this->network);
+        if ($text === false) {
+            $text = '::'; // @codeCoverageIgnore
+        }
+
+        return strtolower($text) . '/' . $this->originalPrefix;
+    }
+
+    private static function applyMask(string $binary, int $prefix): string
+    {
+        if ($prefix < 0 || $prefix > 128) {
+            throw new InvalidCidrException(sprintf('Prefix out of range: %d', $prefix)); // @codeCoverageIgnore
+        }
+
+        if ($prefix === 128) {
+            return $binary;
+        }
+
+        $fullBytes = intdiv($prefix, 8);
+        $extraBits = $prefix % 8;
+
+        $masked = substr($binary, 0, $fullBytes);
+
+        if ($extraBits > 0) {
+            $byte = ord($binary[$fullBytes]);
+            $mask = (0xff << (8 - $extraBits)) & 0xff;
+            $masked .= chr($byte & $mask);
+            $tailLength = 16 - $fullBytes - 1;
+        } else {
+            $tailLength = 16 - $fullBytes;
+        }
+
+        if ($tailLength > 0) {
+            $masked .= str_repeat("\x00", $tailLength);
+        }
+
+        return $masked;
+    }
+}

+ 11 - 0
api/src/Domain/Ip/InvalidCidrException.php

@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Ip;
+
+use InvalidArgumentException;
+
+final class InvalidCidrException extends InvalidArgumentException
+{
+}

+ 11 - 0
api/src/Domain/Ip/InvalidIpException.php

@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Ip;
+
+use InvalidArgumentException;
+
+final class InvalidIpException extends InvalidArgumentException
+{
+}

+ 157 - 0
api/src/Domain/Ip/IpAddress.php

@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Ip;
+
+/**
+ * Immutable IP address value object.
+ *
+ * Internally every address is stored as a 16-byte big-endian binary string.
+ * IPv4 inputs are mapped into the IPv4-mapped IPv6 prefix `::ffff:0:0/96`
+ * (RFC 4291 §2.5.5.2) so that all containment math is uniform across families.
+ */
+final class IpAddress
+{
+    private const V4_MAPPED_PREFIX = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";
+
+    private function __construct(
+        private readonly string $binary,
+        private readonly string $text,
+        private readonly bool $isIpv4,
+    ) {
+    }
+
+    public static function fromString(string $input): self
+    {
+        if ($input === '') {
+            throw new InvalidIpException('IP address cannot be empty.');
+        }
+
+        if ($input !== trim($input)) {
+            throw new InvalidIpException('IP address must not contain surrounding whitespace.');
+        }
+
+        if (preg_match('/\s/', $input) === 1) {
+            throw new InvalidIpException('IP address must not contain whitespace.');
+        }
+
+        if (str_contains($input, '.') && !str_contains($input, ':')) {
+            return self::fromIpv4($input);
+        }
+
+        if (str_contains($input, ':')) {
+            return self::fromIpv6($input);
+        }
+
+        throw new InvalidIpException(sprintf('Not a recognizable IP address: %s', $input));
+    }
+
+    public static function fromBinary(string $binary): self
+    {
+        if (strlen($binary) !== 16) {
+            throw new InvalidIpException('Binary IP must be exactly 16 bytes.');
+        }
+
+        $isV4Mapped = substr($binary, 0, 12) === self::V4_MAPPED_PREFIX;
+
+        if ($isV4Mapped) {
+            $textV4 = inet_ntop(substr($binary, 12, 4));
+            if ($textV4 === false) {
+                throw new InvalidIpException('Failed to render binary as IPv4.'); // @codeCoverageIgnore
+            }
+
+            return new self($binary, $textV4, true);
+        }
+
+        $textV6 = inet_ntop($binary);
+        if ($textV6 === false) {
+            throw new InvalidIpException('Failed to render binary as IPv6.'); // @codeCoverageIgnore
+        }
+
+        return new self($binary, strtolower($textV6), false);
+    }
+
+    public function binary(): string
+    {
+        return $this->binary;
+    }
+
+    public function text(): string
+    {
+        return $this->text;
+    }
+
+    public function isIpv4(): bool
+    {
+        return $this->isIpv4;
+    }
+
+    public function equals(self $other): bool
+    {
+        return $this->binary === $other->binary;
+    }
+
+    private static function fromIpv4(string $input): self
+    {
+        $parts = explode('.', $input);
+
+        if (count($parts) !== 4) {
+            throw new InvalidIpException(sprintf('IPv4 must have four octets: %s', $input));
+        }
+
+        foreach ($parts as $part) {
+            if ($part === '') {
+                throw new InvalidIpException(sprintf('IPv4 octet must not be empty: %s', $input));
+            }
+
+            if (!ctype_digit($part)) {
+                throw new InvalidIpException(sprintf('IPv4 octet must be decimal digits: %s', $input));
+            }
+
+            // RFC 6943 / strict parsers: reject leading zeros (e.g. "010.0.0.1").
+            if (strlen($part) > 1 && $part[0] === '0') {
+                throw new InvalidIpException(
+                    sprintf('IPv4 octet must not have leading zeros: %s', $input)
+                );
+            }
+
+            if ((int) $part > 255) {
+                throw new InvalidIpException(sprintf('IPv4 octet out of range 0-255: %s', $input));
+            }
+        }
+
+        $packed = inet_pton($input);
+        if ($packed === false) {
+            throw new InvalidIpException(sprintf('inet_pton failed for %s', $input)); // @codeCoverageIgnore
+        }
+
+        $mapped = self::V4_MAPPED_PREFIX . $packed;
+
+        return new self($mapped, $input, true);
+    }
+
+    private static function fromIpv6(string $input): self
+    {
+        $bracketed = preg_match('/^\[.*\]$/', $input) === 1;
+        if ($bracketed) {
+            throw new InvalidIpException(sprintf('Bracketed IPv6 not accepted: %s', $input));
+        }
+
+        if (str_contains($input, '%')) {
+            throw new InvalidIpException(sprintf('Zone identifiers not accepted: %s', $input));
+        }
+
+        $packed = @inet_pton($input);
+        if ($packed === false || strlen($packed) !== 16) {
+            throw new InvalidIpException(sprintf('Invalid IPv6 address: %s', $input));
+        }
+
+        $canonical = inet_ntop($packed);
+        if ($canonical === false) {
+            throw new InvalidIpException(sprintf('Failed to canonicalize IPv6: %s', $input)); // @codeCoverageIgnore
+        }
+
+        return new self($packed, strtolower($canonical), false);
+    }
+}

+ 82 - 0
api/src/Infrastructure/Db/ConnectionFactory.php

@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Db;
+
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DriverManager;
+use InvalidArgumentException;
+
+/**
+ * Builds a Doctrine DBAL Connection from the api's `db` settings.
+ *
+ * For SQLite we apply the four PRAGMAs the spec mandates on each new
+ * connection (WAL journaling, normal sync, busy timeout, foreign keys on).
+ */
+final class ConnectionFactory
+{
+    /**
+     * @param array{
+     *     driver: string,
+     *     sqlite_path: string,
+     *     mysql_host: string,
+     *     mysql_port: int,
+     *     mysql_database: string,
+     *     mysql_username: string,
+     *     mysql_password: string
+     * } $settings
+     */
+    public function __construct(private readonly array $settings)
+    {
+    }
+
+    public function create(): Connection
+    {
+        $driver = $this->settings['driver'];
+
+        if ($driver === 'sqlite') {
+            $path = $this->settings['sqlite_path'];
+            if ($path === ':memory:') {
+                $params = [
+                    'driver' => 'pdo_sqlite',
+                    'memory' => true,
+                ];
+            } else {
+                $params = [
+                    'driver' => 'pdo_sqlite',
+                    'path' => $path,
+                ];
+            }
+
+            $connection = DriverManager::getConnection($params);
+            self::applySqlitePragmas($connection);
+
+            return $connection;
+        }
+
+        if ($driver === 'mysql') {
+            $params = [
+                'driver' => 'pdo_mysql',
+                'host' => $this->settings['mysql_host'],
+                'port' => $this->settings['mysql_port'],
+                'dbname' => $this->settings['mysql_database'],
+                'user' => $this->settings['mysql_username'],
+                'password' => $this->settings['mysql_password'],
+                'charset' => 'utf8mb4',
+            ];
+
+            return DriverManager::getConnection($params);
+        }
+
+        throw new InvalidArgumentException(sprintf('Unsupported DB_DRIVER: %s', $driver));
+    }
+
+    private static function applySqlitePragmas(Connection $connection): void
+    {
+        $connection->executeStatement('PRAGMA journal_mode = WAL');
+        $connection->executeStatement('PRAGMA synchronous = NORMAL');
+        $connection->executeStatement('PRAGMA busy_timeout = 5000');
+        $connection->executeStatement('PRAGMA foreign_keys = ON');
+    }
+}

+ 59 - 0
api/src/Infrastructure/Db/Migrations/BaseMigration.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Db\Migrations;
+
+use Phinx\Db\Table;
+use Phinx\Migration\AbstractMigration;
+use Phinx\Util\Literal;
+
+/**
+ * Shared helpers for IRDB migrations.
+ *
+ * Hides per-adapter quirks: timestamp columns must be DATETIME(6) on MySQL but
+ * plain TEXT (ISO-8601) on SQLite; binary columns must accept exactly 16 raw
+ * bytes; default expressions need a Phinx Literal so they're emitted unquoted.
+ */
+abstract class BaseMigration extends AbstractMigration
+{
+    protected function isMysql(): bool
+    {
+        return $this->getAdapter()->getAdapterType() === 'mysql';
+    }
+
+    /**
+     * Add a UTC timestamp column. MySQL gets DATETIME(6); SQLite gets the
+     * datetime affinity (TEXT) per SPEC §4.
+     *
+     * @param array<string, mixed> $extra Extra Phinx column options (e.g. ['null' => true]).
+     */
+    protected function addTimestampColumn(Table $table, string $name, array $extra = []): Table
+    {
+        $opts = $extra;
+
+        if ($this->isMysql()) {
+            $opts['precision'] = 6;
+        }
+
+        if (!array_key_exists('default', $opts) && (!array_key_exists('null', $opts) || $opts['null'] === false)) {
+            $opts['default'] = $this->isMysql()
+                ? Literal::from('CURRENT_TIMESTAMP(6)')
+                : Literal::from('CURRENT_TIMESTAMP');
+        }
+
+        return $table->addColumn($name, 'datetime', $opts);
+    }
+
+    /**
+     * 16-byte binary column for IP/network storage.
+     *
+     * @param array<string, mixed> $extra
+     */
+    protected function addIpBinaryColumn(Table $table, string $name, array $extra = []): Table
+    {
+        $opts = array_merge(['limit' => 16], $extra);
+
+        return $table->addColumn($name, 'binary', $opts);
+    }
+}

+ 107 - 0
api/src/Infrastructure/Db/RepositoryBase.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Db;
+
+use Closure;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Base class for DBAL-backed repositories.
+ *
+ * Centralises the awkward bits of dealing with binary columns (`ip_bin`,
+ * `network_bin`) across SQLite and MySQL: PDO returns BLOB columns as PHP
+ * strings (octet sequences); we hide the bind/fetch detail here so concrete
+ * repositories can pass and receive raw 16-byte strings without thinking
+ * about driver quirks.
+ */
+abstract class RepositoryBase
+{
+    public function __construct(protected readonly Connection $connection)
+    {
+    }
+
+    protected function connection(): Connection
+    {
+        return $this->connection;
+    }
+
+    /**
+     * @param array<string, mixed> $data
+     * @param array<string, ParameterType> $types Maps column name to PDO param type.
+     *     Use ParameterType::LARGE_OBJECT for binary columns.
+     */
+    protected function insertRow(string $table, array $data, array $types = []): int
+    {
+        $columns = array_keys($data);
+        $placeholders = array_map(static fn (string $c): string => ':' . $c, $columns);
+
+        $sql = sprintf(
+            'INSERT INTO %s (%s) VALUES (%s)',
+            $this->connection->quoteIdentifier($table),
+            implode(', ', array_map(fn ($c) => $this->connection->quoteIdentifier($c), $columns)),
+            implode(', ', $placeholders)
+        );
+
+        $stmt = $this->connection->prepare($sql);
+        foreach ($data as $col => $value) {
+            $type = $types[$col] ?? self::inferType($value);
+            $stmt->bindValue($col, $value, $type);
+        }
+
+        return (int) $stmt->executeStatement();
+    }
+
+    /**
+     * Fetch a single row keyed by `ip_bin`. Returns the row as an associative
+     * array, or null if not found. The `ip_bin` value is returned as the raw
+     * 16-byte binary string regardless of underlying driver.
+     *
+     * @return array<string, mixed>|null
+     */
+    protected function fetchByIpBin(string $table, string $ipBin, string $column = 'ip_bin'): ?array
+    {
+        $sql = sprintf(
+            'SELECT * FROM %s WHERE %s = :bin LIMIT 1',
+            $this->connection->quoteIdentifier($table),
+            $this->connection->quoteIdentifier($column)
+        );
+
+        $stmt = $this->connection->prepare($sql);
+        $stmt->bindValue('bin', $ipBin, ParameterType::LARGE_OBJECT);
+        $result = $stmt->executeQuery();
+        $row = $result->fetchAssociative();
+
+        return $row === false ? null : $row;
+    }
+
+    /**
+     * Run a closure inside a transaction. Re-throws on failure after rolling
+     * back; commits on clean exit.
+     *
+     * @template T
+     * @param Closure(Connection): T $work
+     * @return T
+     */
+    protected function transactional(Closure $work): mixed
+    {
+        return $this->connection->transactional($work);
+    }
+
+    private static function inferType(mixed $value): ParameterType
+    {
+        if (is_int($value)) {
+            return ParameterType::INTEGER;
+        }
+        if (is_bool($value)) {
+            return ParameterType::BOOLEAN;
+        }
+        if ($value === null) {
+            return ParameterType::NULL;
+        }
+
+        return ParameterType::STRING;
+    }
+}

+ 299 - 0
api/tests/Integration/MigrationsTest.php

@@ -0,0 +1,299 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration;
+
+use App\Infrastructure\Db\ConnectionFactory;
+use Doctrine\DBAL\Connection;
+use Phinx\Config\Config;
+use Phinx\Migration\Manager;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\NullOutput;
+
+/**
+ * Boots an in-memory SQLite database, runs every Phinx migration against it,
+ * and asserts that the resulting schema has every SPEC §4 table with the
+ * expected key columns. This catches gross mistakes (missing tables, missing
+ * binary columns) without depending on MySQL being available locally.
+ */
+final class MigrationsTest extends TestCase
+{
+    private string $sqlitePath;
+    private Connection $connection;
+
+    protected function setUp(): void
+    {
+        $this->sqlitePath = sys_get_temp_dir() . '/irdb-migrations-' . bin2hex(random_bytes(6)) . '.sqlite';
+
+        $config = new Config([
+            'paths' => [
+                'migrations' => __DIR__ . '/../../db/migrations',
+                'seeds' => __DIR__ . '/../../db/seeds',
+            ],
+            'environments' => [
+                'default_migration_table' => 'phinxlog',
+                'default_environment' => 'test',
+                'test' => [
+                    'adapter' => 'sqlite',
+                    'name' => $this->sqlitePath,
+                    'suffix' => '',
+                ],
+            ],
+            'version_order' => 'creation',
+        ]);
+
+        $manager = new Manager($config, new ArrayInput([]), new NullOutput());
+        $manager->migrate('test');
+        $manager->seed('test');
+
+        $factory = new ConnectionFactory([
+            'driver' => 'sqlite',
+            'sqlite_path' => $this->sqlitePath,
+            'mysql_host' => '',
+            'mysql_port' => 3306,
+            'mysql_database' => '',
+            'mysql_username' => '',
+            'mysql_password' => '',
+        ]);
+        $this->connection = $factory->create();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->connection->close();
+        if (file_exists($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+    }
+
+    public function testEverySpecTableExists(): void
+    {
+        $expected = [
+            'users',
+            'oidc_role_mappings',
+            'reporters',
+            'consumers',
+            'policies',
+            'policy_category_thresholds',
+            'categories',
+            'api_tokens',
+            'reports',
+            'ip_scores',
+            'ip_enrichment',
+            'manual_blocks',
+            'allowlist',
+            'audit_log',
+            'job_locks',
+            'job_runs',
+        ];
+
+        $rows = $this->connection
+            ->executeQuery("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
+            ->fetchAllAssociative();
+        $tables = array_column($rows, 'name');
+
+        foreach ($expected as $name) {
+            self::assertContains($name, $tables, "Missing table: {$name}");
+        }
+    }
+
+    public function testReportsHasExpectedColumns(): void
+    {
+        $columns = $this->columnsOf('reports');
+
+        foreach (['id', 'ip_bin', 'ip_text', 'category_id', 'reporter_id', 'weight_at_report', 'received_at', 'metadata_json'] as $col) {
+            self::assertArrayHasKey($col, $columns, "reports missing column {$col}");
+        }
+    }
+
+    public function testIpScoresHasCompositePrimaryKey(): void
+    {
+        $rows = $this->connection
+            ->executeQuery('PRAGMA table_info(ip_scores)')
+            ->fetchAllAssociative();
+
+        $pkCols = [];
+        foreach ($rows as $r) {
+            if ((int) $r['pk'] > 0) {
+                $pkCols[(int) $r['pk']] = $r['name'];
+            }
+        }
+        ksort($pkCols);
+
+        self::assertSame(['ip_bin', 'category_id'], array_values($pkCols));
+    }
+
+    public function testPolicyCategoryThresholdsHasCompositePrimaryKey(): void
+    {
+        $rows = $this->connection
+            ->executeQuery('PRAGMA table_info(policy_category_thresholds)')
+            ->fetchAllAssociative();
+
+        $pkCols = [];
+        foreach ($rows as $r) {
+            if ((int) $r['pk'] > 0) {
+                $pkCols[(int) $r['pk']] = $r['name'];
+            }
+        }
+        ksort($pkCols);
+
+        self::assertSame(['policy_id', 'category_id'], array_values($pkCols));
+    }
+
+    public function testJobLocksPkIsJobName(): void
+    {
+        $rows = $this->connection
+            ->executeQuery('PRAGMA table_info(job_locks)')
+            ->fetchAllAssociative();
+
+        $pkCols = [];
+        foreach ($rows as $r) {
+            if ((int) $r['pk'] > 0) {
+                $pkCols[(int) $r['pk']] = $r['name'];
+            }
+        }
+
+        self::assertSame(['job_name'], array_values($pkCols));
+    }
+
+    public function testIpEnrichmentPkIsIpBin(): void
+    {
+        $rows = $this->connection
+            ->executeQuery('PRAGMA table_info(ip_enrichment)')
+            ->fetchAllAssociative();
+
+        $pkCols = [];
+        foreach ($rows as $r) {
+            if ((int) $r['pk'] > 0) {
+                $pkCols[(int) $r['pk']] = $r['name'];
+            }
+        }
+
+        self::assertSame(['ip_bin'], array_values($pkCols));
+    }
+
+    public function testReportsHasIpBinIndex(): void
+    {
+        $rows = $this->connection
+            ->executeQuery("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reports'")
+            ->fetchAllAssociative();
+        $indexNames = array_column($rows, 'name');
+
+        $matchesCompound = false;
+        foreach ($indexNames as $n) {
+            if (str_contains($n, 'ip_cat_received') || str_contains($n, 'ip_bin')) {
+                $matchesCompound = true;
+            }
+        }
+
+        self::assertTrue($matchesCompound, 'reports should have an index covering ip_bin');
+    }
+
+    public function testApiTokensCheckConstraintEnforced(): void
+    {
+        // valid: kind=admin, both NULL
+        $this->connection->executeStatement(
+            "INSERT INTO api_tokens (token_hash, token_prefix, kind) VALUES ('a', 'admxxxx', 'admin')"
+        );
+
+        // invalid: kind=reporter without reporter_id
+        $threw = false;
+        try {
+            $this->connection->executeStatement(
+                "INSERT INTO api_tokens (token_hash, token_prefix, kind) VALUES ('b', 'repxxxx', 'reporter')"
+            );
+        } catch (\Throwable) {
+            $threw = true;
+        }
+        self::assertTrue($threw, 'CHECK constraint should reject kind=reporter without reporter_id');
+
+        // invalid: kind=service with reporter_id set
+        $threw = false;
+        try {
+            // Need a reporter row first for the FK to be satisfiable.
+            $this->connection->executeStatement(
+                "INSERT INTO reporters (name, trust_weight, is_active) VALUES ('rx', 1.0, 1)"
+            );
+            $rid = (int) $this->connection->lastInsertId();
+            $this->connection->executeStatement(
+                'INSERT INTO api_tokens (token_hash, token_prefix, kind, reporter_id) VALUES (?, ?, ?, ?)',
+                ['c', 'svcxxxx', 'service', $rid]
+            );
+        } catch (\Throwable) {
+            $threw = true;
+        }
+        self::assertTrue($threw, 'CHECK constraint should reject kind=service with reporter_id set');
+    }
+
+    public function testForeignKeysEnforcedOnSqlite(): void
+    {
+        // foreign_keys PRAGMA must be on for the constraint to fire.
+        $threw = false;
+        try {
+            $this->connection->executeStatement(
+                'INSERT INTO consumers (name, policy_id, is_active) VALUES (?, ?, ?)',
+                ['x', 99999, 1]
+            );
+        } catch (\Throwable) {
+            $threw = true;
+        }
+        self::assertTrue($threw, 'consumers.policy_id FK should reject unknown policy id');
+    }
+
+    public function testSeedsPopulatedDefaults(): void
+    {
+        $catCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM categories');
+        $polCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policies');
+        $thrCount = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policy_category_thresholds');
+
+        self::assertSame(5, $catCount);
+        self::assertSame(3, $polCount);
+        self::assertSame(15, $thrCount);
+    }
+
+    public function testSeedersAreIdempotent(): void
+    {
+        // Run seed again; counts must not change.
+        $config = new Config([
+            'paths' => [
+                'migrations' => __DIR__ . '/../../db/migrations',
+                'seeds' => __DIR__ . '/../../db/seeds',
+            ],
+            'environments' => [
+                'default_migration_table' => 'phinxlog',
+                'default_environment' => 'test',
+                'test' => [
+                    'adapter' => 'sqlite',
+                    'name' => $this->sqlitePath,
+                    'suffix' => '',
+                ],
+            ],
+            'version_order' => 'creation',
+        ]);
+        $manager = new Manager($config, new ArrayInput([]), new NullOutput());
+        $manager->seed('test');
+
+        self::assertSame(5, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM categories'));
+        self::assertSame(3, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policies'));
+        self::assertSame(15, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM policy_category_thresholds'));
+    }
+
+    /**
+     * @return array<string, array<string, mixed>>
+     */
+    private function columnsOf(string $table): array
+    {
+        $rows = $this->connection
+            ->executeQuery(sprintf('PRAGMA table_info(%s)', $table))
+            ->fetchAllAssociative();
+
+        $cols = [];
+        foreach ($rows as $r) {
+            $cols[$r['name']] = $r;
+        }
+
+        return $cols;
+    }
+}

+ 232 - 0
api/tests/Unit/Ip/CidrTest.php

@@ -0,0 +1,232 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Ip;
+
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\InvalidCidrException;
+use App\Domain\Ip\IpAddress;
+use PHPUnit\Framework\TestCase;
+
+final class CidrTest extends TestCase
+{
+    public function testParsesSimpleIpv4Cidr(): void
+    {
+        $cidr = Cidr::fromString('10.0.0.0/8');
+
+        $this->assertTrue($cidr->isIpv4());
+        $this->assertSame(8, $cidr->originalPrefix());
+        $this->assertSame(104, $cidr->prefixLength());
+        $this->assertSame('10.0.0.0/8', $cidr->text());
+    }
+
+    public function testParsesSlash32SingleHost(): void
+    {
+        $cidr = Cidr::fromString('192.168.1.42/32');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('192.168.1.42')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('192.168.1.43')));
+    }
+
+    public function testV4Slash24Containment(): void
+    {
+        $cidr = Cidr::fromString('10.0.0.0/24');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.0')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.1')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.0.0.255')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('10.0.1.0')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('11.0.0.0')));
+    }
+
+    public function testV4Slash16Containment(): void
+    {
+        $cidr = Cidr::fromString('172.16.0.0/16');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('172.16.0.0')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('172.16.255.255')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('172.17.0.0')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('172.15.255.255')));
+    }
+
+    public function testV4Slash0MatchesAllV4(): void
+    {
+        $cidr = Cidr::fromString('0.0.0.0/0');
+
+        $this->assertSame(96, $cidr->prefixLength());
+        $this->assertTrue($cidr->contains(IpAddress::fromString('0.0.0.0')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('255.255.255.255')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('1.2.3.4')));
+
+        // /0 of v4 = ::ffff:0:0/96 → does NOT match pure IPv6
+        $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db8::1')));
+    }
+
+    public function testV6Slash0MatchesEverything(): void
+    {
+        $cidr = Cidr::fromString('::/0');
+
+        $this->assertSame(0, $cidr->prefixLength());
+        $this->assertTrue($cidr->contains(IpAddress::fromString('::1')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
+        // Even v4 (which is stored as v4-mapped v6) matches /0
+        $this->assertTrue($cidr->contains(IpAddress::fromString('1.2.3.4')));
+    }
+
+    public function testV6Slash128SingleHost(): void
+    {
+        $cidr = Cidr::fromString('2001:db8::1/128');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db8::2')));
+    }
+
+    public function testV6Slash64Containment(): void
+    {
+        $cidr = Cidr::fromString('2001:db8::/64');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::1')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:db8::ffff:ffff:ffff:ffff')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('2001:db9::1')));
+    }
+
+    public function testV6CidrDoesNotContainV4(): void
+    {
+        $cidr = Cidr::fromString('2001:db8::/32');
+
+        $this->assertFalse($cidr->contains(IpAddress::fromString('1.2.3.4')));
+    }
+
+    public function testV4InV4MappedV6Cidr(): void
+    {
+        // ::ffff:1.0.0.0/120 covers v4-mapped 1.0.0.0/24
+        $cidr = Cidr::fromString('::ffff:1.0.0.0/120');
+
+        $this->assertFalse($cidr->isIpv4(), 'CIDR is in v6 syntax even though it covers v4');
+        $this->assertSame(120, $cidr->prefixLength());
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('1.0.0.5')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('1.0.0.255')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('1.0.1.0')));
+    }
+
+    public function testV4MappedV6AddressInsideV4Cidr(): void
+    {
+        $cidr = Cidr::fromString('1.0.0.0/24');
+
+        // The v4-mapped IPv6 form must still be contained because internal storage is unified.
+        $this->assertTrue($cidr->contains(IpAddress::fromString('::ffff:1.0.0.7')));
+    }
+
+    public function testNetworkAddressMaskedFromHostBits(): void
+    {
+        $cidr = Cidr::fromString('10.1.2.99/24');
+        $expected = IpAddress::fromString('10.1.2.0');
+
+        $this->assertSame($expected->binary(), $cidr->network());
+        $this->assertSame('10.1.2.0/24', $cidr->text());
+    }
+
+    public function testV6NetworkAddressMaskedFromHostBits(): void
+    {
+        $cidr = Cidr::fromString('2001:db8:abcd:1234::ff/56');
+
+        $this->assertSame('2001:db8:abcd:1200::/56', $cidr->text());
+    }
+
+    public function testRejectsMissingSlash(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0');
+    }
+
+    public function testRejectsEmpty(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('');
+    }
+
+    public function testRejectsBadIp(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('garbage/8');
+    }
+
+    public function testRejectsNegativePrefix(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0/-1');
+    }
+
+    public function testRejectsV4PrefixOver32(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0/33');
+    }
+
+    public function testRejectsV6PrefixOver128(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('2001:db8::/129');
+    }
+
+    public function testRejectsNonNumericPrefix(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0/abc');
+    }
+
+    public function testRejectsMultipleSlashes(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0/24/8');
+    }
+
+    public function testRejectsLeadingWhitespace(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString(' 10.0.0.0/24');
+    }
+
+    public function testRejectsEmptyPrefix(): void
+    {
+        $this->expectException(InvalidCidrException::class);
+        Cidr::fromString('10.0.0.0/');
+    }
+
+    public function testNonByteAlignedV4Prefix(): void
+    {
+        // /20 fixes the first 20 bits = 8 (byte 0) + 8 (byte 1) + 4 (high
+        // nibble of byte 2). Exercises the partial-byte mask path.
+        // 10.16.0.0/20 covers 10.16.0.0 — 10.16.15.255 (4096 addresses).
+        $cidr = Cidr::fromString('10.16.0.0/20');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.0.0')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.15.255')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('10.16.5.42')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('10.16.16.0')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('10.15.255.255')));
+        $this->assertSame('10.16.0.0/20', $cidr->text());
+    }
+
+    public function testNonByteAlignedV6Prefix(): void
+    {
+        // /17 fixes 16 bits + 1 high bit of byte 2. 2001:8000::/17 covers
+        // any address whose first 17 bits start with 0x2001 followed by a 1.
+        $cidr = Cidr::fromString('2001:8000::/17');
+
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:8000::')));
+        $this->assertTrue($cidr->contains(IpAddress::fromString('2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('2001:7fff:ffff::')));
+        $this->assertFalse($cidr->contains(IpAddress::fromString('2002::')));
+    }
+
+    public function testNonByteAlignedHostMaskingClearsBits(): void
+    {
+        // 10.16.5.7/20 should mask the host bits to 10.16.0.0/20
+        $cidr = Cidr::fromString('10.16.5.7/20');
+
+        $this->assertSame('10.16.0.0/20', $cidr->text());
+    }
+}

+ 200 - 0
api/tests/Unit/Ip/IpAddressTest.php

@@ -0,0 +1,200 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Ip;
+
+use App\Domain\Ip\InvalidIpException;
+use App\Domain\Ip\IpAddress;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+final class IpAddressTest extends TestCase
+{
+    public function testParsesSimpleIpv4(): void
+    {
+        $ip = IpAddress::fromString('203.0.113.42');
+
+        $this->assertTrue($ip->isIpv4());
+        $this->assertSame('203.0.113.42', $ip->text());
+        $this->assertSame(16, strlen($ip->binary()));
+        $this->assertSame(
+            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xcb\x00\x71\x2a",
+            $ip->binary()
+        );
+    }
+
+    public function testParsesIpv4Zero(): void
+    {
+        $ip = IpAddress::fromString('0.0.0.0');
+
+        $this->assertTrue($ip->isIpv4());
+        $this->assertSame('0.0.0.0', $ip->text());
+        $this->assertSame(
+            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00",
+            $ip->binary()
+        );
+    }
+
+    public function testParsesIpv4Max(): void
+    {
+        $ip = IpAddress::fromString('255.255.255.255');
+
+        $this->assertTrue($ip->isIpv4());
+        $this->assertSame('255.255.255.255', $ip->text());
+    }
+
+    public function testParsesFullIpv6(): void
+    {
+        $ip = IpAddress::fromString('2001:0db8:0000:0000:0000:0000:0000:0001');
+
+        $this->assertFalse($ip->isIpv4());
+        $this->assertSame('2001:db8::1', $ip->text());
+        $this->assertSame(16, strlen($ip->binary()));
+    }
+
+    public function testParsesZeroCompressedIpv6(): void
+    {
+        $ip = IpAddress::fromString('2001:db8::1');
+
+        $this->assertFalse($ip->isIpv4());
+        $this->assertSame('2001:db8::1', $ip->text());
+    }
+
+    public function testParsesIpv6Loopback(): void
+    {
+        $ip = IpAddress::fromString('::1');
+
+        $this->assertFalse($ip->isIpv4());
+        $this->assertSame('::1', $ip->text());
+    }
+
+    public function testParsesIpv6Unspecified(): void
+    {
+        $ip = IpAddress::fromString('::');
+
+        $this->assertFalse($ip->isIpv4());
+        $this->assertSame('::', $ip->text());
+    }
+
+    public function testParsesV4MappedIpv6KeepsIpv6Form(): void
+    {
+        $ip = IpAddress::fromString('::ffff:1.2.3.4');
+
+        $this->assertFalse($ip->isIpv4(), 'v4-mapped passed in IPv6 form should report as IPv6');
+        $this->assertSame(
+            "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x02\x03\x04",
+            $ip->binary()
+        );
+    }
+
+    public function testIpv4MapsIntoSameBinaryAsV4MappedIpv6(): void
+    {
+        $v4 = IpAddress::fromString('1.2.3.4');
+        $v6 = IpAddress::fromString('::ffff:1.2.3.4');
+
+        $this->assertSame($v4->binary(), $v6->binary());
+    }
+
+    public function testIpv6IsLowercased(): void
+    {
+        $ip = IpAddress::fromString('2001:DB8:ABCD::FFFF');
+
+        $this->assertSame('2001:db8:abcd::ffff', $ip->text());
+    }
+
+    public function testIpv6FullyExpandedCanonicalizes(): void
+    {
+        $ip = IpAddress::fromString('fe80:0000:0000:0000:0000:0000:0000:0001');
+
+        $this->assertSame('fe80::1', $ip->text());
+    }
+
+    public function testIpv6LinkLocalLowercased(): void
+    {
+        $ip = IpAddress::fromString('FE80::1');
+
+        $this->assertSame('fe80::1', $ip->text());
+    }
+
+    public function testFromBinaryRoundtripIpv4(): void
+    {
+        $original = IpAddress::fromString('10.20.30.40');
+        $copy = IpAddress::fromBinary($original->binary());
+
+        $this->assertTrue($copy->isIpv4());
+        $this->assertSame('10.20.30.40', $copy->text());
+    }
+
+    public function testFromBinaryRoundtripIpv6(): void
+    {
+        $original = IpAddress::fromString('2001:db8::dead:beef');
+        $copy = IpAddress::fromBinary($original->binary());
+
+        $this->assertFalse($copy->isIpv4());
+        $this->assertSame('2001:db8::dead:beef', $copy->text());
+    }
+
+    public function testFromBinaryRejectsWrongLength(): void
+    {
+        $this->expectException(InvalidIpException::class);
+
+        IpAddress::fromBinary("\x01\x02\x03");
+    }
+
+    public function testEqualsCompareByBinary(): void
+    {
+        $a = IpAddress::fromString('1.2.3.4');
+        $b = IpAddress::fromString('::ffff:1.2.3.4');
+        $c = IpAddress::fromString('1.2.3.5');
+
+        $this->assertTrue($a->equals($b));
+        $this->assertFalse($a->equals($c));
+    }
+
+    /**
+     * @return iterable<string, array{0: string}>
+     */
+    public static function invalidProvider(): iterable
+    {
+        yield 'empty' => [''];
+        yield 'whitespace only' => [' '];
+        yield 'leading space' => [' 1.2.3.4'];
+        yield 'trailing space' => ['1.2.3.4 '];
+        yield 'inner whitespace' => ['1.2. 3.4'];
+        yield 'integer-as-string' => ['1234567890'];
+        yield 'three octets' => ['1.2.3'];
+        yield 'five octets' => ['1.2.3.4.5'];
+        yield 'octet > 255' => ['1.2.3.256'];
+        yield 'negative octet' => ['1.2.3.-1'];
+        yield 'leading zero v4' => ['010.0.0.1'];
+        yield 'leading zero in last octet' => ['10.0.0.01'];
+        yield 'empty octet' => ['10..0.1'];
+        yield 'hex octet' => ['0x10.0.0.1'];
+        yield 'alpha octet' => ['a.b.c.d'];
+        yield 'just dots' => ['...'];
+        yield 'incomplete v6' => ['2001:db8'];
+        yield 'too many groups v6' => ['1:2:3:4:5:6:7:8:9'];
+        yield 'invalid hex group v6' => ['ggggg::1'];
+        yield 'double :: v6' => ['1::2::3'];
+        yield 'bracketed v6' => ['[::1]'];
+        yield 'zone id v6' => ['fe80::1%eth0'];
+        yield 'random garbage' => ['hello world'];
+        yield 'sql injection-ish' => ["1.2.3.4'; DROP TABLE--"];
+        yield 'newline embedded' => ["1.2.3.4\n"];
+        yield 'tab embedded' => ["1.2.3.4\t"];
+        yield 'just slash' => ['/'];
+        yield 'with prefix' => ['1.2.3.4/24'];
+        yield 'just colon' => [':'];
+        yield 'just dot' => ['.'];
+        yield 'numeric overflow octet' => ['1.2.3.99999'];
+    }
+
+    #[DataProvider('invalidProvider')]
+    public function testRejectsInvalidInput(string $input): void
+    {
+        $this->expectException(InvalidIpException::class);
+
+        IpAddress::fromString($input);
+    }
+}