move_detect/docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md
유창욱 3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00

86 KiB

P1-B 서버 수신·대시보드 (Server Ingest + Dashboard) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Cafe24 웹호스팅(PHP+MySQL)에서 도는 관제 백엔드/대시보드 — 엣지(P1-A)가 보내는 서명된 이벤트+스냅샷을 안전하게 수신·저장하고, 인증된 안전관리자가 실시간 사고 목록·상세를 보고, 위험구역 폴리곤을 그려 Pi로 동기한다.

Architecture: 바닐라 PHP(프레임워크 없음) + PDO + PHPUnit. 프론트컨트롤러 라우터 → 컨트롤러 → Repository 인터페이스(PDO/MySQL 구현) 뒤에 DB. 보안 핵심 로직(HMAC+nonce 검증·입력검증·이미지 재인코딩·RBAC·세션)은 순수·mock repo로 PHPUnit 로컬 완결. DB 통합은 게이트(로컬 pdo_sqlite or Cafe24 MySQL). 스냅샷은 웹루트 밖 저장, 인증 PHP 프록시로만 서빙.

Tech Stack: PHP 8.5, Composer(dev: phpunit만), PDO(MySQL 타깃), gd(이미지 재인코딩), openssl/hash(HMAC), session/argon2id(인증). 런타임 외부 의존성 0(vendor는 phpunit 개발 전용).

Global Constraints

  • PHP 8.5, declare(strict_types=1); 모든 파일. PSR-4 오토로드(App\ → server/src/).
  • 폐쇄망/공유호스팅: 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 public_html에 server/public/만 웹 노출.
  • HMAC 계약(P1-A와 정확히 일치): 서명 = hmac-sha256(nonce + timestamp + body, per_device_secret) hex. P1-A edge/alert.py::sign과 바이트 동일하게 검증. 헤더 X-Signature, X-Nonce, X-Timestamp.
  • replay 방지: nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
  • 스냅샷: base64 JPEG를 gd로 재인코딩(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), 웹루트 밖(server/storage/snapshots/) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
  • SQL: 전부 prepared statement(placeholder). event_type/severity/level 등은 화이트리스트 enum 검증.
  • 인증: 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
  • 심각도 색상 단독 금지(NF9): 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
  • 시크릿: per-device secret·DB 자격증명은 env/시크릿 파일(server/config.php는 .gitignore), 하드코딩 금지.
  • 테스트: 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
  • 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.

File Structure

server/
  composer.json                 # PSR-4 autoload + phpunit(dev)
  phpunit.xml
  config.example.php            # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
  schema.sql                    # MySQL DDL (+ sqlite 호환 노트)
  public/                       # === Cafe24 웹루트 (이것만 노출) ===
    index.php                   # 프론트컨트롤러
    .htaccess                   # 라우팅 rewrite + storage/src 접근 차단
    assets/{app.css, app.js, zone-draw.js}
  src/
    Config.php
    Http/{Request.php, Response.php, Router.php}
    Db/Database.php
    Security/{Hmac.php, Csrf.php, Password.php}
    Ingest/{EventValidator.php, ImageProcessor.php, IngestController.php}
    Api/{HeartbeatController.php, ConfigController.php}
    Auth/{Session.php, Rbac.php, AuthController.php}
    Dashboard/{EventListController.php, EventDetailController.php, SnapshotProxy.php, ZoneConfigController.php}
    Geometry/Polygon.php
    Repository/                 # 인터페이스
      EventRepository.php, UserRepository.php, DeviceRepository.php,
      ZoneRepository.php, ThresholdRepository.php, NonceRepository.php,
      RateLimiterRepository.php, AuditRepository.php, SnapshotRepository.php
    Repository/Pdo/             # PDO 구현 (DB 게이트 태스크)
      Pdo*.php
    View/                       # 서버렌더 템플릿(php)
      layout.php, login.php, events.php, event_detail.php, zones.php
  storage/                      # === 웹루트 밖 ===
    snapshots/                  # 재인코딩된 JPEG (UUID)
  tests/                        # PHPUnit + InMemory 테스트더블
    Support/InMemoryRepositories.php
    *Test.php

Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)

Files:

  • Create: server/composer.json, server/phpunit.xml, server/src/Config.php, server/config.example.php, server/tests/ConfigTest.php, server/.gitignore

Interfaces:

  • Consumes: (없음)

  • Produces: App\Config with Config::fromArray(array $a): Config and typed getters dbDsn(), dbUser(), dbPass(), snapshotDir(), nonceTtl(): int, timestampWindow(): int, rateLimitPerMin(): int.

  • Step 1: composer.json + autoload

{
  "name": "move-detect/server",
  "type": "project",
  "require": { "php": ">=8.2" },
  "require-dev": { "phpunit/phpunit": "^11" },
  "autoload": { "psr-4": { "App\\": "src/" } },
  "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } }
}
  • Step 2: phpunit.xml
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="unit"><directory>tests</directory></testsuite>
  </testsuites>
</phpunit>
  • Step 3: .gitignore (server/)
/vendor/
/config.php
/storage/snapshots/*
!/storage/snapshots/.gitkeep
  • Step 4: 실패 테스트 — server/tests/ConfigTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use PHPUnit\Framework\TestCase;

final class ConfigTest extends TestCase
{
    public function testTypedGettersWithDefaults(): void
    {
        $c = Config::fromArray(['db_dsn' => 'sqlite::memory:']);
        $this->assertSame('sqlite::memory:', $c->dbDsn());
        $this->assertSame(300, $c->timestampWindow());
        $this->assertSame(60, $c->rateLimitPerMin());
        $this->assertSame(86400, $c->nonceTtl());
    }

    public function testOverrides(): void
    {
        $c = Config::fromArray(['db_dsn' => 'x', 'timestamp_window' => 120, 'rate_limit_per_min' => 30]);
        $this->assertSame(120, $c->timestampWindow());
        $this->assertSame(30, $c->rateLimitPerMin());
    }
}
  • Step 5: 실패 확인

Run: cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php Expected: FAIL — Class "App\Config" not found

  • Step 6: 구현 — server/src/Config.php
<?php
declare(strict_types=1);
namespace App;

final class Config
{
    private function __construct(private array $a) {}

    public static function fromArray(array $a): self { return new self($a); }

    public function dbDsn(): string { return (string)($this->a['db_dsn'] ?? ''); }
    public function dbUser(): string { return (string)($this->a['db_user'] ?? ''); }
    public function dbPass(): string { return (string)($this->a['db_pass'] ?? ''); }
    public function snapshotDir(): string { return (string)($this->a['snapshot_dir'] ?? __DIR__ . '/../storage/snapshots'); }
    public function nonceTtl(): int { return (int)($this->a['nonce_ttl'] ?? 86400); }
    public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); }
    public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); }
}
  • Step 7: config.example.php (배포 참조)
<?php
return [
    'db_dsn'  => 'mysql:host=localhost;dbname=move_detect;charset=utf8mb4',
    'db_user' => 'CHANGEME',
    'db_pass' => 'CHANGEME',
    'snapshot_dir' => __DIR__ . '/storage/snapshots',
    'timestamp_window' => 300,
    'rate_limit_per_min' => 60,
];
  • Step 8: 통과 + 커밋

Run: ./vendor/bin/phpunit tests/ConfigTest.php → PASS

git add server/composer.json server/phpunit.xml server/.gitignore server/config.example.php server/src/Config.php server/tests/ConfigTest.php
git commit -m "feat(server): P1-B scaffold — composer autoload + Config"

Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치

Files:

  • Create: server/src/Security/Hmac.php, server/tests/HmacTest.php

Interfaces:

  • Consumes: (없음)

  • Produces: App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string (hex), Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool (hash_equals, 상수시간).

  • Step 1: 실패 테스트 — server/tests/HmacTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Security\Hmac;
use PHPUnit\Framework\TestCase;

final class HmacTest extends TestCase
{
    public function testSignMatchesPythonEdgeContract(): void
    {
        // P1-A edge/alert.py::sign = HMAC-SHA256(nonce+ts+body, secret) hex
        $sig = Hmac::sign('secret', 'n1', '1000.0', '{"a":1}');
        $expected = hash_hmac('sha256', 'n1' . '1000.0' . '{"a":1}', 'secret');
        $this->assertSame($expected, $sig);
        $this->assertSame(64, strlen($sig));
    }

    public function testVerifyTrueOnMatchFalseOnTamper(): void
    {
        $sig = Hmac::sign('secret', 'n1', '1000.0', 'body');
        $this->assertTrue(Hmac::verify('secret', 'n1', '1000.0', 'body', $sig));
        $this->assertFalse(Hmac::verify('secret', 'n1', '1000.0', 'body-TAMPERED', $sig));
        $this->assertFalse(Hmac::verify('wrong', 'n1', '1000.0', 'body', $sig));
    }
}
  • Step 2: 실패 확인

Run: ./vendor/bin/phpunit tests/HmacTest.php → FAIL (class not found)

  • Step 3: 구현 — server/src/Security/Hmac.php
<?php
declare(strict_types=1);
namespace App\Security;

final class Hmac
{
    public static function sign(string $secret, string $nonce, string $ts, string $body): string
    {
        return hash_hmac('sha256', $nonce . $ts . $body, $secret);
    }

    public static function verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool
    {
        $expected = self::sign($secret, $nonce, $ts, $body);
        return hash_equals($expected, $sig);
    }
}
  • Step 4: 통과 + 커밋

Run: ./vendor/bin/phpunit tests/HmacTest.php → PASS

git add server/src/Security/Hmac.php server/tests/HmacTest.php
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"

Task 3: Repository 인터페이스 + InMemory 테스트더블

Files:

  • Create: server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php, server/tests/Support/InMemoryRepositories.php, server/tests/RepositoryContractTest.php

Interfaces:

  • Consumes: (없음)

  • Produces (인터페이스 시그니처 — 이후 태스크가 의존):

    • DeviceRepository: secretFor(string $deviceId): ?string, touchHeartbeat(string $deviceId, array $meta): void, configVersion(string $deviceId): int
    • NonceRepository: seen(string $nonce): bool, remember(string $nonce, int $expiresAt): void
    • RateLimiterRepository: hit(string $key, int $windowStart): int (해당 분 카운트 반환·증가)
    • EventRepository: insert(array $event): int (event_id), recent(int $limit, ?string $severity): array, find(int $id): ?array, acknowledge(int $id, string $user): bool
    • SnapshotRepository: add(int $eventId, string $fileUuid, string $contentHash): void, forEvent(int $eventId): array, pathByUuid(string $uuid): ?string
    • UserRepository: byUsername(string $u): ?array (id, password_hash, role, site_scope, failed_attempts, locked_until), recordFailure(int $userId, int $lockUntil): void, clearFailures(int $userId): void
    • ZoneRepository: forDevice(string $deviceId): array, forCamera(string $cameraId): array, save(array $zone): string, delete(string $zoneId): bool
    • ThresholdRepository: forDevice(string $deviceId): array
    • AuditRepository: append(string $actor, string $action, string $target, array $before, array $after): void
  • Step 1: 인터페이스들 작성 — 각 파일에 interface 정의.

server/src/Repository/DeviceRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface DeviceRepository
{
    public function secretFor(string $deviceId): ?string;
    public function touchHeartbeat(string $deviceId, array $meta): void;
    public function configVersion(string $deviceId): int;
}

NonceRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface NonceRepository
{
    public function seen(string $nonce): bool;
    public function remember(string $nonce, int $expiresAt): void;
}

RateLimiterRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface RateLimiterRepository
{
    /** 해당 window(분)의 key 카운트를 1 증가시키고, 증가 후 카운트를 반환한다. */
    public function hit(string $key, int $windowStart): int;
}

EventRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface EventRepository
{
    public function insert(array $event): int;
    public function recent(int $limit, ?string $severity): array;
    public function find(int $id): ?array;
    public function acknowledge(int $id, string $user): bool;
}

SnapshotRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface SnapshotRepository
{
    public function add(int $eventId, string $fileUuid, string $contentHash): void;
    public function forEvent(int $eventId): array;
    public function pathByUuid(string $uuid): ?string;
}

UserRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface UserRepository
{
    public function byUsername(string $username): ?array;
    public function recordFailure(int $userId, int $lockUntil): void;
    public function clearFailures(int $userId): void;
}

ZoneRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface ZoneRepository
{
    public function forDevice(string $deviceId): array;
    public function forCamera(string $cameraId): array;
    public function save(array $zone): string;
    public function delete(string $zoneId): bool;
}

ThresholdRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface ThresholdRepository
{
    public function forDevice(string $deviceId): array;
}

AuditRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository;

interface AuditRepository
{
    public function append(string $actor, string $action, string $target, array $before, array $after): void;
}
  • Step 2: InMemory 더블 + 실패 테스트 — server/tests/Support/InMemoryRepositories.php (테스트 전용 구현)
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
    EventRepository, SnapshotRepository, UserRepository, ZoneRepository,
    ThresholdRepository, AuditRepository};

final class InMemoryDeviceRepository implements DeviceRepository {
    public array $heartbeats = [];
    public function __construct(private array $secrets = [], private array $versions = []) {}
    public function secretFor(string $d): ?string { return $this->secrets[$d] ?? null; }
    public function touchHeartbeat(string $d, array $m): void { $this->heartbeats[$d] = $m; }
    public function configVersion(string $d): int { return $this->versions[$d] ?? 1; }
}
final class InMemoryNonceRepository implements NonceRepository {
    public array $store = [];
    public function seen(string $n): bool { return isset($this->store[$n]); }
    public function remember(string $n, int $exp): void { $this->store[$n] = $exp; }
}
final class InMemoryRateLimiter implements RateLimiterRepository {
    public array $counts = [];
    public function hit(string $k, int $w): int { $key = "$k:$w"; return $this->counts[$key] = ($this->counts[$key] ?? 0) + 1; }
}
final class InMemoryEventRepository implements EventRepository {
    public array $rows = []; private int $seq = 0;
    public function insert(array $e): int { $id = ++$this->seq; $this->rows[$id] = $e + ['event_id' => $id, 'acknowledged_by' => null]; return $id; }
    public function recent(int $limit, ?string $sev): array {
        $r = array_reverse($this->rows, true);
        if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
        return array_slice(array_values($r), 0, $limit);
    }
    public function find(int $id): ?array { return $this->rows[$id] ?? null; }
    public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
}
final class InMemorySnapshotRepository implements SnapshotRepository {
    public array $rows = []; public array $paths = [];
    public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; }
    public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
    public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; }
}
final class InMemoryUserRepository implements UserRepository {
    public function __construct(public array $users = []) {}
    public function byUsername(string $u): ?array { return $this->users[$u] ?? null; }
    public function recordFailure(int $id, int $lock): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = ($row['failed_attempts'] ?? 0) + 1; $row['locked_until'] = $lock; } }
    public function clearFailures(int $id): void { foreach ($this->users as &$row) if ($row['id'] === $id) { $row['failed_attempts'] = 0; $row['locked_until'] = 0; } }
}
final class InMemoryZoneRepository implements ZoneRepository {
    public array $zones = []; private int $seq = 0;
    public function forDevice(string $d): array { return array_values(array_filter($this->zones, fn($z) => ($z['device_id'] ?? '') === $d)); }
    public function forCamera(string $c): array { return array_values(array_filter($this->zones, fn($z) => $z['camera_id'] === $c)); }
    public function save(array $z): string { $id = $z['zone_id'] ?? ('z' . (++$this->seq)); $this->zones[$id] = $z + ['zone_id' => $id]; return $id; }
    public function delete(string $id): bool { if (!isset($this->zones[$id])) return false; unset($this->zones[$id]); return true; }
}
final class InMemoryThresholdRepository implements ThresholdRepository {
    public function __construct(private array $data = []) {}
    public function forDevice(string $d): array { return $this->data[$d] ?? []; }
}
final class InMemoryAuditRepository implements AuditRepository {
    public array $log = [];
    public function append(string $a, string $act, string $t, array $b, array $af): void { $this->log[] = compact('a', 'act', 't', 'b', 'af'); }
}

그리고 server/tests/RepositoryContractTest.php:

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Tests\Support\{InMemoryNonceRepository, InMemoryEventRepository, InMemoryRateLimiter};
use PHPUnit\Framework\TestCase;

final class RepositoryContractTest extends TestCase
{
    public function testNonceRememberThenSeen(): void
    {
        $r = new InMemoryNonceRepository();
        $this->assertFalse($r->seen('n1'));
        $r->remember('n1', 999);
        $this->assertTrue($r->seen('n1'));
    }
    public function testEventInsertAndRecent(): void
    {
        $r = new InMemoryEventRepository();
        $id = $r->insert(['severity' => 'high', 'event_type' => 'fall']);
        $this->assertSame(1, $id);
        $this->assertCount(1, $r->recent(10, null));
        $this->assertCount(0, $r->recent(10, 'critical'));
    }
    public function testRateLimiterIncrements(): void
    {
        $r = new InMemoryRateLimiter();
        $this->assertSame(1, $r->hit('dev', 100));
        $this->assertSame(2, $r->hit('dev', 100));
        $this->assertSame(1, $r->hit('dev', 160));
    }
}
  • Step 3: 실패 → 통과

Run: ./vendor/bin/phpunit tests/RepositoryContractTest.php Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.

  • Step 4: 커밋
git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php
git commit -m "feat(server): repository interfaces + in-memory test doubles"

Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)

Files:

  • Create: server/src/Ingest/EventValidator.php, server/tests/EventValidatorTest.php

Interfaces:

  • Consumes: (없음)

  • Produces: App\Ingest\EventValidator::validate(array $payload): array — ['ok' => bool, 'errors' => string[], 'clean' => array]. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목.

  • Step 1: 실패 테스트 — server/tests/EventValidatorTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Ingest\EventValidator;
use PHPUnit\Framework\TestCase;

final class EventValidatorTest extends TestCase
{
    private function valid(): array {
        return ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
                'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => ['aGk=']];
    }
    public function testAcceptsValid(): void {
        $r = EventValidator::validate($this->valid());
        $this->assertTrue($r['ok'], implode(',', $r['errors']));
        $this->assertSame('fall', $r['clean']['event_type']);
    }
    public function testRejectsUnknownEventType(): void {
        $p = $this->valid(); $p['event_type'] = 'explode';
        $this->assertFalse(EventValidator::validate($p)['ok']);
    }
    public function testRejectsUnknownSeverity(): void {
        $p = $this->valid(); $p['severity'] = 'ULTRA';
        $this->assertFalse(EventValidator::validate($p)['ok']);
    }
    public function testRejectsMissingRequired(): void {
        $p = $this->valid(); unset($p['device_id']);
        $this->assertFalse(EventValidator::validate($p)['ok']);
    }
    public function testRejectsTooManySnapshots(): void {
        $p = $this->valid(); $p['snapshots'] = ['a', 'b', 'c', 'd'];
        $this->assertFalse(EventValidator::validate($p)['ok']);
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Ingest/EventValidator.php

<?php
declare(strict_types=1);
namespace App\Ingest;

final class EventValidator
{
    private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
        'zone_restricted','pinch','monitoring_degraded','device_fault'];
    private const SEVERITIES = ['critical','high','warning','info'];
    private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];

    public static function validate(array $p): array
    {
        $errors = [];
        foreach (self::REQUIRED as $k) {
            if (!array_key_exists($k, $p) || $p[$k] === '' || $p[$k] === null) $errors[] = "missing:$k";
        }
        if (isset($p['event_type']) && !in_array($p['event_type'], self::TYPES, true)) $errors[] = 'bad:event_type';
        if (isset($p['severity']) && !in_array($p['severity'], self::SEVERITIES, true)) $errors[] = 'bad:severity';
        $snaps = $p['snapshots'] ?? [];
        if (!is_array($snaps)) $errors[] = 'bad:snapshots';
        elseif (count($snaps) > 3) $errors[] = 'too_many:snapshots';
        else foreach ($snaps as $s) if (!is_string($s)) { $errors[] = 'bad:snapshot_item'; break; }

        if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []];

        $clean = [
            'device_id' => (string)$p['device_id'],
            'camera_id' => (string)$p['camera_id'],
            'event_type' => (string)$p['event_type'],
            'severity' => (string)$p['severity'],
            'track_id' => isset($p['track_id']) ? (int)$p['track_id'] : null,
            'zone_id' => isset($p['zone_id']) ? (string)$p['zone_id'] : null,
            'occurred_at' => (float)$p['occurred_at'],
            'duration_s' => (float)($p['duration_s'] ?? 0.0),
            'metrics' => is_array($p['metrics'] ?? null) ? $p['metrics'] : [],
        ];
        return ['ok' => true, 'errors' => [], 'clean' => $clean];
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
git commit -m "feat(server): event payload validator with enum whitelist"

Task 5: ImageProcessor (gd 재인코딩 + UUID)

Files:

  • Create: server/src/Ingest/ImageProcessor.php, server/tests/ImageProcessorTest.php

Interfaces:

  • Consumes: (없음)

  • Produces: App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array — ['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 ['ok'=>false]. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트).

  • Step 1: 실패 테스트 — server/tests/ImageProcessorTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Ingest\ImageProcessor;
use PHPUnit\Framework\TestCase;

final class ImageProcessorTest extends TestCase
{
    private function tinyJpegBase64(): string {
        $im = imagecreatetruecolor(8, 8);
        ob_start(); imagejpeg($im); $bytes = ob_get_clean(); imagedestroy($im);
        return base64_encode($bytes);
    }
    public function testReencodeProducesJpeg(): void {
        $r = ImageProcessor::reencode($this->tinyJpegBase64());
        $this->assertTrue($r['ok']);
        $this->assertSame("\xFF\xD8", substr($r['bytes'], 0, 2)); // JPEG magic
        $this->assertSame(32, strlen($r['uuid']));
        $this->assertSame(64, strlen($r['hash']));
    }
    public function testRejectsNonImage(): void {
        $r = ImageProcessor::reencode(base64_encode("not an image <?php echo 1; ?>"));
        $this->assertFalse($r['ok']);
    }
    public function testRejectsBadBase64(): void {
        $this->assertFalse(ImageProcessor::reencode('%%%not-base64%%%')['ok']);
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Ingest/ImageProcessor.php

<?php
declare(strict_types=1);
namespace App\Ingest;

final class ImageProcessor
{
    public static function reencode(string $base64, int $quality = 80): array
    {
        $raw = base64_decode($base64, true);
        if ($raw === false || $raw === '') return ['ok' => false];
        $im = @imagecreatefromstring($raw);
        if ($im === false) return ['ok' => false];
        ob_start();
        $ok = imagejpeg($im, null, $quality);
        $bytes = (string)ob_get_clean();
        imagedestroy($im);
        if (!$ok || $bytes === '') return ['ok' => false];
        return [
            'ok' => true,
            'bytes' => $bytes,
            'uuid' => bin2hex(random_bytes(16)),
            'hash' => hash('sha256', $bytes),
        ];
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"

Task 6: Http Request/Response

Files:

  • Create: server/src/Http/Request.php, server/src/Http/Response.php, server/tests/HttpTest.php

Interfaces:

  • Consumes: (없음)

  • Produces:

    • App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post) with getters method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string, and static fromGlobals(): self.
    • App\Http\Response: static json(array $data, int $status=200): self, text(string $s, int $status=200): self, bytes(string $b, string $contentType, int $status=200): self, redirect(string $to): self; getters status(), headers(), body(); send(): void.
  • Step 1: 실패 테스트 — server/tests/HttpTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\{Request, Response};
use PHPUnit\Framework\TestCase;

final class HttpTest extends TestCase
{
    public function testRequestJsonAndHeaderCaseInsensitive(): void {
        $r = new Request('POST', '/api/events', ['X-Nonce' => 'n1'], '{"a":1}', [], []);
        $this->assertSame('POST', $r->method());
        $this->assertSame('n1', $r->header('x-nonce'));
        $this->assertSame(['a' => 1], $r->json());
    }
    public function testResponseJson(): void {
        $res = Response::json(['ok' => true], 201);
        $this->assertSame(201, $res->status());
        $this->assertStringContainsString('application/json', $res->headers()['Content-Type']);
        $this->assertSame('{"ok":true}', $res->body());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Http/Request.php

<?php
declare(strict_types=1);
namespace App\Http;

final class Request
{
    private array $headers;
    public function __construct(
        private string $method, private string $path, array $headers,
        private string $body, private array $query = [], private array $post = []
    ) {
        $this->headers = [];
        foreach ($headers as $k => $v) $this->headers[strtolower($k)] = $v;
    }
    public function method(): string { return $this->method; }
    public function path(): string { return $this->path; }
    public function header(string $name): ?string { return $this->headers[strtolower($name)] ?? null; }
    public function body(): string { return $this->body; }
    public function json(): array { $d = json_decode($this->body, true); return is_array($d) ? $d : []; }
    public function query(string $k): ?string { return isset($this->query[$k]) ? (string)$this->query[$k] : null; }
    public function post(string $k): ?string { return isset($this->post[$k]) ? (string)$this->post[$k] : null; }

    public static function fromGlobals(): self
    {
        $headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
        return new self(
            $_SERVER['REQUEST_METHOD'] ?? 'GET',
            parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/',
            $headers, file_get_contents('php://input') ?: '', $_GET, $_POST
        );
    }
}

server/src/Http/Response.php:

<?php
declare(strict_types=1);
namespace App\Http;

final class Response
{
    private function __construct(private int $status, private array $headers, private string $body) {}

    public static function json(array $data, int $status = 200): self {
        return new self($status, ['Content-Type' => 'application/json'], json_encode($data, JSON_UNESCAPED_UNICODE));
    }
    public static function text(string $s, int $status = 200): self {
        return new self($status, ['Content-Type' => 'text/html; charset=utf-8'], $s);
    }
    public static function bytes(string $b, string $contentType, int $status = 200): self {
        return new self($status, ['Content-Type' => $contentType], $b);
    }
    public static function redirect(string $to): self {
        return new self(302, ['Location' => $to], '');
    }
    public function status(): int { return $this->status; }
    public function headers(): array { return $this->headers; }
    public function body(): string { return $this->body; }
    public function send(): void {
        http_response_code($this->status);
        foreach ($this->headers as $k => $v) header("$k: $v");
        echo $this->body;
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php
git commit -m "feat(server): Http Request/Response primitives"

Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합

Files:

  • Create: server/src/Ingest/IngestController.php, server/tests/IngestControllerTest.php

Interfaces:

  • Consumes: Hmac (T2), EventValidator/ImageProcessor (T4/T5), Request/Response (T6), DeviceRepository/NonceRepository/RateLimiterRepository/EventRepository/SnapshotRepository (T3), Config (T1)

  • Produces: App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository) with handle(Request $req, int $now): Response. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 {event_id}.

  • Step 1: 실패 테스트 — server/tests/IngestControllerTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Http\Request;
use App\Security\Hmac;
use App\Ingest\IngestController;
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryNonceRepository,
    InMemoryRateLimiter, InMemoryEventRepository, InMemorySnapshotRepository};
use PHPUnit\Framework\TestCase;

final class IngestControllerTest extends TestCase
{
    private string $dir;
    protected function setUp(): void { $this->dir = sys_get_temp_dir() . '/snaps' . bin2hex(random_bytes(4)); mkdir($this->dir); }

    private function make(array $secrets = ['pi-1' => 'sekret']): array {
        $cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'rate_limit_per_min' => 60]);
        $dev = new InMemoryDeviceRepository($secrets);
        $nonce = new InMemoryNonceRepository();
        $rl = new InMemoryRateLimiter();
        $events = new InMemoryEventRepository();
        $snaps = new InMemorySnapshotRepository();
        $c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
        return [$c, $events, $snaps, $nonce];
    }
    private function jpegB64(): string { $im = imagecreatetruecolor(8, 8); ob_start(); imagejpeg($im); $b = ob_get_clean(); imagedestroy($im); return base64_encode($b); }

    private function signedReq(string $secret, array $payload, string $nonce = 'n1', string $ts = '1000'): Request {
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
        $sig = Hmac::sign($secret, $nonce, $ts, $body);
        return new Request('POST', '/api/events',
            ['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $body, [], []);
    }
    private function payload(): array {
        return ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
                'severity' => 'high', 'occurred_at' => 1000.0, 'snapshots' => [$this->jpegB64()]];
    }

    public function testAcceptsValidSignedEvent(): void {
        [$c, $events, $snaps] = $this->make();
        $res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000);
        $this->assertSame(201, $res->status());
        $this->assertCount(1, $events->rows);
        $id = json_decode($res->body(), true)['event_id'];
        $this->assertCount(1, $snaps->forEvent($id)); // snapshot re-encoded + stored
    }
    public function testRejectsBadSignature(): void {
        [$c] = $this->make();
        $req = $this->signedReq('WRONG', $this->payload());
        $this->assertSame(401, $c->handle($req, now: 1000)->status());
    }
    public function testRejectsReplayedNonce(): void {
        [$c] = $this->make();
        $req = $this->signedReq('sekret', $this->payload());
        $this->assertSame(201, $c->handle($req, now: 1000)->status());
        $this->assertSame(401, $c->handle($req, now: 1000)->status()); // same nonce again
    }
    public function testRejectsStaleTimestamp(): void {
        [$c] = $this->make();
        $req = $this->signedReq('sekret', $this->payload(), ts: '1000');
        $this->assertSame(401, $c->handle($req, now: 5000)->status()); // 4000s > 300 window
    }
    public function testRejectsUnknownDevice(): void {
        [$c] = $this->make([]); // no secrets
        $this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000)->status());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Ingest/IngestController.php

<?php
declare(strict_types=1);
namespace App\Ingest;

use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository,
    EventRepository, SnapshotRepository};

final class IngestController
{
    public function __construct(
        private Config $cfg,
        private DeviceRepository $devices,
        private NonceRepository $nonces,
        private RateLimiterRepository $rateLimiter,
        private EventRepository $events,
        private SnapshotRepository $snapshots,
    ) {}

    public function handle(Request $req, int $now): Response
    {
        $payload = $req->json();
        $deviceId = (string)($payload['device_id'] ?? '');
        $window = intdiv($now, 60);
        if ($deviceId !== '' && $this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
            return Response::json(['error' => 'rate_limited'], 429);
        }

        $nonce = $req->header('X-Nonce') ?? '';
        $ts = $req->header('X-Timestamp') ?? '';
        $sig = $req->header('X-Signature') ?? '';
        $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
        if ($secret === null || $nonce === '' || $ts === '' || $sig === '') {
            return Response::json(['error' => 'unauthorized'], 401);
        }
        if (!Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
            return Response::json(['error' => 'bad_signature'], 401);
        }
        if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
            return Response::json(['error' => 'stale_timestamp'], 401);
        }
        if ($this->nonces->seen($nonce)) {
            return Response::json(['error' => 'replay'], 401);
        }
        $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());

        $v = EventValidator::validate($payload);
        if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);

        $eventId = $this->events->insert($v['clean']);
        foreach (($payload['snapshots'] ?? []) as $b64) {
            $img = ImageProcessor::reencode((string)$b64);
            if (!$img['ok']) continue;
            $path = rtrim($this->cfg->snapshotDir(), '/\\') . '/' . $img['uuid'] . '.jpg';
            file_put_contents($path, $img['bytes']);
            $this->snapshots->add($eventId, $img['uuid'], $img['hash']);
        }
        return Response::json(['event_id' => $eventId], 201);
    }
}

NOTE (implementer): InMemorySnapshotRepository::add stores a /tmp/... path in the double; the controller also writes the real file to snapshot_dir (setUp creates the dir). Tests assert on forEvent() count only. Rate-limit is checked first so a flood is rejected before crypto work.

  • Step 4: 통과 + 커밋
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"

Task 8: Password (argon2id) + Csrf

Files:

  • Create: server/src/Security/Password.php, server/src/Security/Csrf.php, server/tests/PasswordCsrfTest.php

Interfaces:

  • Consumes: (없음)

  • Produces:

    • App\Security\Password::hash(string $p): string (PASSWORD_ARGON2ID), Password::verify(string $p, string $hash): bool.
    • App\Security\Csrf::issue(callable $randHex): string, Csrf::check(string $expected, string $given): bool (hash_equals).
  • Step 1: 실패 테스트 — server/tests/PasswordCsrfTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Security\{Password, Csrf};
use PHPUnit\Framework\TestCase;

final class PasswordCsrfTest extends TestCase
{
    public function testHashVerify(): void {
        $h = Password::hash('s3cret!');
        $this->assertNotSame('s3cret!', $h);
        $this->assertTrue(Password::verify('s3cret!', $h));
        $this->assertFalse(Password::verify('wrong', $h));
    }
    public function testCsrf(): void {
        $t = Csrf::issue(fn() => 'abc123');
        $this->assertSame('abc123', $t);
        $this->assertTrue(Csrf::check($t, 'abc123'));
        $this->assertFalse(Csrf::check($t, 'nope'));
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Security/Password.php

<?php
declare(strict_types=1);
namespace App\Security;

final class Password
{
    public static function hash(string $plain): string {
        return password_hash($plain, PASSWORD_ARGON2ID);
    }
    public static function verify(string $plain, string $hash): bool {
        return password_verify($plain, $hash);
    }
}

server/src/Security/Csrf.php:

<?php
declare(strict_types=1);
namespace App\Security;

final class Csrf
{
    public static function issue(callable $randHex): string { return (string)$randHex(); }
    public static function check(string $expected, string $given): bool {
        return $expected !== '' && hash_equals($expected, $given);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php
git commit -m "feat(server): argon2id password + CSRF token helpers"

Task 9: Rbac (역할→권한)

Files:

  • Create: server/src/Auth/Rbac.php, server/tests/RbacTest.php

Interfaces:

  • Consumes: (없음)

  • Produces: App\Auth\Rbac::can(string $role, string $permission): bool. 권한: view_events(all), acknowledge(admin,operator), edit_zones(admin,operator), manage_thresholds(admin), manage_compliance(admin).

  • Step 1: 실패 테스트 — server/tests/RbacTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Auth\Rbac;
use PHPUnit\Framework\TestCase;

final class RbacTest extends TestCase
{
    public function testViewerCanOnlyView(): void {
        $this->assertTrue(Rbac::can('viewer', 'view_events'));
        $this->assertFalse(Rbac::can('viewer', 'acknowledge'));
        $this->assertFalse(Rbac::can('viewer', 'edit_zones'));
    }
    public function testOperator(): void {
        $this->assertTrue(Rbac::can('operator', 'acknowledge'));
        $this->assertTrue(Rbac::can('operator', 'edit_zones'));
        $this->assertFalse(Rbac::can('operator', 'manage_thresholds'));
    }
    public function testAdminAll(): void {
        foreach (['view_events','acknowledge','edit_zones','manage_thresholds','manage_compliance'] as $p) {
            $this->assertTrue(Rbac::can('admin', $p));
        }
    }
    public function testUnknownDenied(): void {
        $this->assertFalse(Rbac::can('ghost', 'view_events'));
        $this->assertFalse(Rbac::can('admin', 'unknown_perm'));
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Auth/Rbac.php

<?php
declare(strict_types=1);
namespace App\Auth;

final class Rbac
{
    private const MATRIX = [
        'admin'    => ['view_events','acknowledge','edit_zones','manage_thresholds','manage_compliance'],
        'operator' => ['view_events','acknowledge','edit_zones'],
        'viewer'   => ['view_events'],
    ];
    public static function can(string $role, string $permission): bool {
        return in_array($permission, self::MATRIX[$role] ?? [], true);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Auth/Rbac.php server/tests/RbacTest.php
git commit -m "feat(server): RBAC permission matrix"

Task 10: Session + AuthController (로그인/brute-force 잠금)

Files:

  • Create: server/src/Auth/Session.php, server/src/Auth/AuthController.php, server/tests/AuthControllerTest.php

Interfaces:

  • Consumes: Password (T8), Request/Response (T6), UserRepository/AuditRepository (T3)

  • Produces:

    • interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }, ArraySessionStore (test), PhpSessionStore (prod, secure cookie).
    • App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900) with login(Request $req, SessionStore $sess, int $now): Response and logout(SessionStore $sess): Response. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked.
  • Step 1: 실패 테스트 — server/tests/AuthControllerTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Security\Password;
use App\Auth\{AuthController, ArraySessionStore};
use App\Tests\Support\{InMemoryUserRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;

final class AuthControllerTest extends TestCase
{
    private function users(): InMemoryUserRepository {
        return new InMemoryUserRepository(['alice' => [
            'id' => 1, 'username' => 'alice', 'password_hash' => Password::hash('pw'),
            'role' => 'operator', 'site_scope' => null, 'failed_attempts' => 0, 'locked_until' => 0,
        ]]);
    }
    private function req(string $u, string $p): Request {
        return new Request('POST', '/login', [], '', [], ['username' => $u, 'password' => $p]);
    }
    public function testLoginSuccessSetsSession(): void {
        $c = new AuthController($this->users(), new InMemoryAuditRepository());
        $sess = new ArraySessionStore();
        $res = $c->login($this->req('alice', 'pw'), $sess, now: 1000);
        $this->assertSame(200, $res->status());
        $this->assertSame(1, $sess->get('user_id'));
        $this->assertSame('operator', $sess->get('role'));
    }
    public function testBadPasswordIs401(): void {
        $c = new AuthController($this->users(), new InMemoryAuditRepository());
        $this->assertSame(401, $c->login($this->req('alice', 'nope'), new ArraySessionStore(), now: 1000)->status());
    }
    public function testLockoutAfterMaxAttempts(): void {
        $repo = $this->users();
        $c = new AuthController($repo, new InMemoryAuditRepository(), maxAttempts: 3, lockSeconds: 900);
        for ($i = 0; $i < 3; $i++) $c->login($this->req('alice', 'bad'), new ArraySessionStore(), now: 1000);
        // now locked, even correct password is 423
        $this->assertSame(423, $c->login($this->req('alice', 'pw'), new ArraySessionStore(), now: 1000)->status());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Auth/Session.php

<?php
declare(strict_types=1);
namespace App\Auth;

interface SessionStore {
    public function get(string $key): mixed;
    public function set(string $key, mixed $value): void;
    public function clear(): void;
}

final class ArraySessionStore implements SessionStore {
    private array $d = [];
    public function get(string $k): mixed { return $this->d[$k] ?? null; }
    public function set(string $k, mixed $v): void { $this->d[$k] = $v; }
    public function clear(): void { $this->d = []; }
}

final class PhpSessionStore implements SessionStore {
    public function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) session_start([
        'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax',
    ]); }
    public function get(string $k): mixed { return $_SESSION[$k] ?? null; }
    public function set(string $k, mixed $v): void { $_SESSION[$k] = $v; }
    public function clear(): void { $_SESSION = []; session_destroy(); }
}

server/src/Auth/AuthController.php:

<?php
declare(strict_types=1);
namespace App\Auth;

use App\Http\{Request, Response};
use App\Security\Password;
use App\Repository\{UserRepository, AuditRepository};

final class AuthController
{
    public function __construct(
        private UserRepository $users,
        private AuditRepository $audit,
        private int $maxAttempts = 5,
        private int $lockSeconds = 900,
    ) {}

    public function login(Request $req, SessionStore $sess, int $now): Response
    {
        $username = (string)$req->post('username');
        $password = (string)$req->post('password');
        $u = $this->users->byUsername($username);
        if ($u === null) return Response::json(['error' => 'invalid_credentials'], 401);

        if ((int)($u['locked_until'] ?? 0) > $now) {
            $this->audit->append($username, 'login_locked', 'auth', [], []);
            return Response::json(['error' => 'locked'], 423);
        }
        if (!Password::verify($password, (string)$u['password_hash'])) {
            $attempts = (int)($u['failed_attempts'] ?? 0) + 1;
            $lock = $attempts >= $this->maxAttempts ? $now + $this->lockSeconds : 0;
            $this->users->recordFailure((int)$u['id'], $lock);
            $this->audit->append($username, 'login_failed', 'auth', [], ['attempts' => $attempts]);
            return Response::json(['error' => 'invalid_credentials'], 401);
        }
        $this->users->clearFailures((int)$u['id']);
        $sess->set('user_id', (int)$u['id']);
        $sess->set('username', $u['username']);
        $sess->set('role', $u['role']);
        $sess->set('site_scope', $u['site_scope'] ?? null);
        $this->audit->append($username, 'login_ok', 'auth', [], []);
        return Response::json(['ok' => true], 200);
    }

    public function logout(SessionStore $sess): Response
    {
        $sess->clear();
        return Response::json(['ok' => true], 200);
    }
}

NOTE (implementer): the lockout test relies on InMemoryUserRepository::recordFailure mutating failed_attempts/locked_until on the stored row so the next byUsername reflects it (the double loops by reference — verify this persists across calls). login reads locked_until fresh on each call.

  • Step 4: 통과 + 커밋
git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php
git commit -m "feat(server): session store + auth controller with lockout"

Task 11: Router + 프론트컨트롤러 준비

Files:

  • Create: server/src/Http/Router.php, server/tests/RouterTest.php

Interfaces:

  • Consumes: Request/Response (T6)

  • Produces: App\Http\Router with add(string $method, string $path, callable $handler): void and dispatch(Request $req): Response (404 if no match; path params {id} → passed to handler as second arg assoc array).

  • Step 1: 실패 테스트 — server/tests/RouterTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\{Router, Request, Response};
use PHPUnit\Framework\TestCase;

final class RouterTest extends TestCase
{
    public function testMatchAndParams(): void {
        $r = new Router();
        $r->add('GET', '/events/{id}', fn(Request $req, array $p) => Response::json(['id' => $p['id']]));
        $res = $r->dispatch(new Request('GET', '/events/42', [], '', [], []));
        $this->assertSame(200, $res->status());
        $this->assertSame(['id' => '42'], json_decode($res->body(), true));
    }
    public function testUnmatchedIs404(): void {
        $r = new Router();
        $this->assertSame(404, $r->dispatch(new Request('GET', '/nope', [], '', [], []))->status());
    }
    public function testMethodMismatchIs404(): void {
        $r = new Router();
        $r->add('POST', '/x', fn() => Response::text('ok'));
        $this->assertSame(404, $r->dispatch(new Request('GET', '/x', [], '', [], []))->status());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Http/Router.php

<?php
declare(strict_types=1);
namespace App\Http;

final class Router
{
    private array $routes = [];

    public function add(string $method, string $path, callable $handler): void {
        $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $path) . '$#';
        $this->routes[] = [strtoupper($method), $regex, $handler];
    }

    public function dispatch(Request $req): Response {
        foreach ($this->routes as [$method, $regex, $handler]) {
            if ($req->method() !== $method) continue;
            if (preg_match($regex, $req->path(), $m)) {
                $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
                return $handler($req, $params);
            }
        }
        return Response::json(['error' => 'not_found'], 404);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Http/Router.php server/tests/RouterTest.php
git commit -m "feat(server): path-param router + dispatch"

Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시

Files:

  • Create: server/src/Dashboard/EventListController.php, server/src/Dashboard/EventDetailController.php, server/src/Dashboard/SnapshotProxy.php, server/tests/DashboardTest.php

Interfaces:

  • Consumes: Request/Response (T6), SessionStore/Rbac (T9/T10), EventRepository/SnapshotRepository/AuditRepository (T3)

  • Produces:

    • EventListController(EventRepository, SessionStore)::json(Request): Response — 인증 필요, ?severity= 필터, 최근 100개.
    • EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response; acknowledge(Request, array $params): Response (RBAC acknowledge).
    • SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
  • Step 1: 실패 테스트 — server/tests/DashboardTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy};
use App\Tests\Support\{InMemoryEventRepository, InMemorySnapshotRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;

final class DashboardTest extends TestCase
{
    private function authed(string $role = 'operator'): ArraySessionStore {
        $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); return $s;
    }
    public function testListRequiresAuth(): void {
        $repo = new InMemoryEventRepository();
        $c = new EventListController($repo, new ArraySessionStore());
        $this->assertSame(401, $c->json(new Request('GET', '/api/events', [], '', [], []))->status());
    }
    public function testListReturnsRecent(): void {
        $repo = new InMemoryEventRepository();
        $repo->insert(['severity' => 'high', 'event_type' => 'fall']);
        $c = new EventListController($repo, $this->authed());
        $res = $c->json(new Request('GET', '/api/events', [], '', [], []));
        $this->assertSame(200, $res->status());
        $this->assertCount(1, json_decode($res->body(), true)['events']);
    }
    public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
        $repo = new InMemoryEventRepository();
        $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);
        $req = new Request('POST', "/events/$id/ack", [], '', [], []);
        // viewer denied
        $cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
        $this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status());
        // operator allowed
        $co = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
        $this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status());
        $this->assertSame('u', $repo->find($id)['acknowledged_by']);
    }
    public function testSnapshotProxyAuthAnd404(): void {
        $snaps = new InMemorySnapshotRepository();
        $proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth
        $this->assertSame(401, $proxy->serve(new Request('GET', '/snap/x', [], '', [], []), ['uuid' => 'x'])->status());
        $proxy2 = new SnapshotProxy($snaps, $this->authed());
        $this->assertSame(404, $proxy2->serve(new Request('GET', '/snap/missing', [], '', [], []), ['uuid' => 'missing'])->status());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Dashboard/EventListController.php

<?php
declare(strict_types=1);
namespace App\Dashboard;

use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\EventRepository;

final class EventListController
{
    public function __construct(private EventRepository $events, private SessionStore $sess) {}

    public function json(Request $req): Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        $severity = $req->query('severity');
        $rows = $this->events->recent(100, $severity);
        return Response::json(['events' => $rows], 200);
    }
}

server/src/Dashboard/EventDetailController.php:

<?php
declare(strict_types=1);
namespace App\Dashboard;

use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};

final class EventDetailController
{
    public function __construct(
        private EventRepository $events, private SnapshotRepository $snaps,
        private AuditRepository $audit, private SessionStore $sess
    ) {}

    public function show(Request $req, array $params): Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        $id = (int)($params['id'] ?? 0);
        $event = $this->events->find($id);
        if ($event === null) return Response::json(['error' => 'not_found'], 404);
        return Response::json(['event' => $event, 'snapshots' => $this->snaps->forEvent($id)], 200);
    }

    public function acknowledge(Request $req, array $params): Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) return Response::json(['error' => 'forbidden'], 403);
        $id = (int)($params['id'] ?? 0);
        $user = (string)$this->sess->get('username');
        if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404);
        $this->audit->append($user, 'acknowledge', "event:$id", [], []);
        return Response::json(['ok' => true], 200);
    }
}

server/src/Dashboard/SnapshotProxy.php:

<?php
declare(strict_types=1);
namespace App\Dashboard;

use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Repository\SnapshotRepository;

final class SnapshotProxy
{
    public function __construct(private SnapshotRepository $snaps, private SessionStore $sess) {}

    public function serve(Request $req, array $params): Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        $uuid = (string)($params['uuid'] ?? '');
        $path = $this->snaps->pathByUuid($uuid);
        if ($path === null || !is_file($path)) return Response::json(['error' => 'not_found'], 404);
        return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Dashboard/EventListController.php server/src/Dashboard/EventDetailController.php server/src/Dashboard/SnapshotProxy.php server/tests/DashboardTest.php
git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy"

Task 13: Polygon 검증 + ZoneConfigController

Files:

  • Create: server/src/Geometry/Polygon.php, server/src/Dashboard/ZoneConfigController.php, server/tests/ZoneConfigTest.php

Interfaces:

  • Consumes: Request/Response (T6), SessionStore/Rbac, ZoneRepository/AuditRepository (T3)

  • Produces:

    • App\Geometry\Polygon::validate(array $points): bool — ≥3점, 각 점 [x,y] 숫자.
    • ZoneConfigController(ZoneRepository, AuditRepository, SessionStore): list(Request, array): Response (camera별), save(Request): Response (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), delete(Request, array): Response.
  • Step 1: 실패 테스트 — server/tests/ZoneConfigTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Geometry\Polygon;
use App\Dashboard\ZoneConfigController;
use App\Tests\Support\{InMemoryZoneRepository, InMemoryAuditRepository};
use PHPUnit\Framework\TestCase;

final class ZoneConfigTest extends TestCase
{
    private function authed(string $role = 'operator'): ArraySessionStore {
        $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); return $s;
    }
    public function testPolygonValidation(): void {
        $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]]));
        $this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3
        $this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric
    }
    private function saveReq(array $body): Request {
        return new Request('POST', '/api/zones', [], json_encode($body), [], []);
    }
    public function testSaveRequiresPermission(): void {
        $c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer'));
        $res = $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
        $this->assertSame(403, $res->status());
    }
    public function testSaveValidZone(): void {
        $repo = new InMemoryZoneRepository();
        $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator'));
        $res = $c->save($this->saveReq(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]));
        $this->assertSame(201, $res->status());
        $this->assertCount(1, $repo->forCamera('cam-1'));
    }
    public function testSaveRejectsBadLevelOrPolygon(): void {
        $c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('operator'));
        $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status());
        $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status());
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Geometry/Polygon.php

<?php
declare(strict_types=1);
namespace App\Geometry;

final class Polygon
{
    public static function validate(array $points): bool
    {
        if (count($points) < 3) return false;
        foreach ($points as $pt) {
            if (!is_array($pt) || count($pt) !== 2) return false;
            if (!is_numeric($pt[0]) || !is_numeric($pt[1])) return false;
        }
        return true;
    }
}

server/src/Dashboard/ZoneConfigController.php:

<?php
declare(strict_types=1);
namespace App\Dashboard;

use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
use App\Geometry\Polygon;
use App\Repository\{ZoneRepository, AuditRepository};

final class ZoneConfigController
{
    private const LEVELS = ['warning','restricted','pinch'];

    public function __construct(
        private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess
    ) {}

    private function guard(string $perm): ?Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403);
        return null;
    }

    public function list(Request $req, array $params): Response
    {
        if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
        return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200);
    }

    public function save(Request $req): Response
    {
        if ($g = $this->guard('edit_zones')) return $g;
        $b = $req->json();
        $level = (string)($b['level'] ?? '');
        $polygon = $b['polygon'] ?? [];
        if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
            return Response::json(['error' => 'invalid_zone'], 400);
        }
        $zoneId = $this->zones->save([
            'zone_id' => $b['zone_id'] ?? null,
            'device_id' => (string)($b['device_id'] ?? ''),
            'camera_id' => (string)($b['camera_id'] ?? ''),
            'level' => $level,
            'polygon_json' => json_encode($polygon),
        ]);
        $this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
        return Response::json(['zone_id' => $zoneId], 201);
    }

    public function delete(Request $req, array $params): Response
    {
        if ($g = $this->guard('edit_zones')) return $g;
        $id = (string)($params['id'] ?? '');
        if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
        $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
        return Response::json(['ok' => true], 200);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php
git commit -m "feat(server): polygon validation + zone config controller (RBAC)"

Task 14: ConfigController + HeartbeatController (Pi 동기 API)

Files:

  • Create: server/src/Api/ConfigController.php, server/src/Api/HeartbeatController.php, server/tests/ApiSyncTest.php

Interfaces:

  • Consumes: Request/Response (T6), Hmac (T2), DeviceRepository/ZoneRepository/ThresholdRepository (T3), Config (T1)

  • Produces:

    • App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response — ?device_id=; device secret HMAC 검증(엣지 폴링). 반환 {version, zones, thresholds}.
    • App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response — HMAC 검증 후 touchHeartbeat(device_id, {fps,temp,throttle,config_version,at}). 200.
  • Step 1: 실패 테스트 — server/tests/ApiSyncTest.php

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Http\Request;
use App\Security\Hmac;
use App\Api\{ConfigController, HeartbeatController};
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository};
use PHPUnit\Framework\TestCase;

final class ApiSyncTest extends TestCase
{
    private function signed(string $method, string $path, string $secret, array $q = [], array $body = []): Request {
        $raw = $body ? json_encode($body) : '';
        $nonce = 'n1'; $ts = '1000';
        $sig = Hmac::sign($secret, $nonce, $ts, $raw);
        return new Request($method, $path, ['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []);
    }
    public function testConfigReturnsZonesAndThresholds(): void {
        $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]);
        $zones = new InMemoryZoneRepository(); $zones->save(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']);
        $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]);
        $c = new ConfigController($dev, $zones, $thr);
        $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1']));
        $this->assertSame(200, $res->status());
        $data = json_decode($res->body(), true);
        $this->assertSame(7, $data['version']);
        $this->assertCount(1, $data['zones']);
        $this->assertSame(3.0, $data['thresholds']['fall_prone_s']);
    }
    public function testConfigRejectsBadSignature(): void {
        $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
        $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository());
        $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1']));
        $this->assertSame(401, $res->status());
    }
    public function testHeartbeatStored(): void {
        $cfg = Config::fromArray([]);
        $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
        $c = new HeartbeatController($cfg, $dev);
        $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7];
        $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
        $this->assertSame(200, $res->status());
        $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']);
    }
}
  • Step 2: 실패 확인 → FAIL

  • Step 3: 구현 — server/src/Api/ConfigController.php

<?php
declare(strict_types=1);
namespace App\Api;

use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository};

final class ConfigController
{
    public function __construct(
        private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds
    ) {}

    public function get(Request $req): Response
    {
        $deviceId = (string)$req->query('device_id');
        $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
        $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
        if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
            return Response::json(['error' => 'unauthorized'], 401);
        }
        return Response::json([
            'version' => $this->devices->configVersion($deviceId),
            'zones' => $this->zones->forDevice($deviceId),
            'thresholds' => $this->thresholds->forDevice($deviceId),
        ], 200);
    }
}

server/src/Api/HeartbeatController.php:

<?php
declare(strict_types=1);
namespace App\Api;

use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\DeviceRepository;

final class HeartbeatController
{
    public function __construct(private Config $cfg, private DeviceRepository $devices) {}

    public function post(Request $req, int $now): Response
    {
        $b = $req->json();
        $deviceId = (string)($b['device_id'] ?? '');
        $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
        $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
        if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
            return Response::json(['error' => 'unauthorized'], 401);
        }
        $this->devices->touchHeartbeat($deviceId, [
            'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
            'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),
            'at' => $now,
        ]);
        return Response::json(['ok' => true], 200);
    }
}
  • Step 4: 통과 + 커밋
git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php
git commit -m "feat(server): device config + heartbeat sync API (HMAC)"

Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)

Files:

  • Create: server/public/index.php, server/public/.htaccess, server/public/assets/{app.css,app.js,zone-draw.js}, server/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}, server/tests/WiringSmokeTest.php

Interfaces:

  • Consumes: 전 태스크. Router, 모든 컨트롤러, PhpSessionStore, PDO repos (T16에서 실제 구현; 여기선 배선만).
  • Produces: server/public/index.php — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. 테스트는 라우팅 표만 스모크 검증(핸들러 스텁).

라우트 표:

method path handler
POST /api/events IngestController::handle
POST /api/heartbeat HeartbeatController::post
GET /api/config ConfigController::get
POST /login AuthController::login
POST /logout AuthController::logout
GET /api/events EventListController::json
GET /events/{id} EventDetailController::show
POST /events/{id}/ack EventDetailController::acknowledge
GET /api/zones/{camera} ZoneConfigController::list
POST /api/zones ZoneConfigController::save
POST /api/zones/{id}/delete ZoneConfigController::delete
GET /snap/{uuid} SnapshotProxy::serve
GET /, /zones, /login View render
  • Step 1: 라우터 스모크 테스트 — server/tests/WiringSmokeTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\{Router, Request, Response};
use PHPUnit\Framework\TestCase;

final class WiringSmokeTest extends TestCase
{
    public function testAllRoutesRegisteredAndDispatch(): void {
        $r = new Router();
        $paths = [
            ['POST', '/api/events'], ['POST', '/api/heartbeat'], ['GET', '/api/config'],
            ['POST', '/login'], ['POST', '/logout'], ['GET', '/api/events'],
            ['GET', '/events/{id}'], ['POST', '/events/{id}/ack'],
            ['GET', '/api/zones/{camera}'], ['POST', '/api/zones'],
            ['POST', '/api/zones/{id}/delete'], ['GET', '/snap/{uuid}'],
        ];
        foreach ($paths as [$m, $p]) $r->add($m, $p, fn() => Response::json(['route' => $p]));
        $this->assertSame(200, $r->dispatch(new Request('GET', '/events/9', [], '', [], []))->status());
        $this->assertSame(200, $r->dispatch(new Request('GET', '/snap/abc', [], '', [], []))->status());
        $this->assertSame(404, $r->dispatch(new Request('DELETE', '/api/events', [], '', [], []))->status());
    }
}
  • Step 2: 실패/통과 확인 → 라우터만 검증(회귀 방지). ./vendor/bin/phpunit tests/WiringSmokeTest.php → PASS.

  • Step 3: 프론트컨트롤러 — server/public/index.php

<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';

use App\Config;
use App\Http\{Request, Router, Response};
use App\Db\Database;
use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
    PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
    PdoThresholdRepository, PdoAuditRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\AuthController;
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController};

$cfg = Config::fromArray(require __DIR__ . '/../config.php');
$pdo = Database::connect($cfg);
$sess = new PhpSessionStore();
$now = time();

$devices = new PdoDeviceRepository($pdo);
$nonces = new PdoNonceRepository($pdo);
$rate = new PdoRateLimiterRepository($pdo);
$events = new PdoEventRepository($pdo);
$snaps = new PdoSnapshotRepository($pdo, $cfg->snapshotDir());
$users = new PdoUserRepository($pdo);
$zones = new PdoZoneRepository($pdo);
$thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo);

$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr);
$heartbeat = new HeartbeatController($cfg, $devices);
$auth = new AuthController($users, $audit);
$list = new EventListController($events, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
$proxy = new SnapshotProxy($snaps, $sess);
$zoneCtl = new ZoneConfigController($zones, $audit, $sess);

$render = function (string $view): Response {
    ob_start(); require __DIR__ . '/../src/View/' . $view . '.php';
    return Response::text((string)ob_get_clean());
};

$r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now));
$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now));
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q));
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));
$r->add('GET', '/api/events', fn(Request $q) => $list->json($q));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
$r->add('POST', '/api/zones/{id}/delete', fn(Request $q, array $p) => $zoneCtl->delete($q, $p));
$r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p));
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));

$r->dispatch(Request::fromGlobals())->send();
  • Step 4: .htaccess — server/public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
  • Step 5: 뷰 + 자산 — 서버렌더 PHP 뷰(로직 없음, 데이터는 JS가 API에서 로드):

    • src/View/layout.php: 공통 헤더/네비.
    • src/View/login.php: username/password 폼(POST /login), CSRF hidden.
    • src/View/events.php: 실시간 목록 컨테이너 + <script src="/assets/app.js">.
    • src/View/event_detail.php: 상세 컨테이너.
    • src/View/zones.php: <canvas> + <script src="/assets/zone-draw.js">.
    • public/assets/app.css: 색맹안전 심각도(NF9) 클래스 — .sev-critical(빨강+아이콘 ⛔+텍스트 "위급"), .sev-high(주황+⚠+"높음"), .sev-warning(노랑+△+"경고"), WCAG-AA 대비. 색 외 텍스트/아이콘 필수.
    • public/assets/app.js: 5초 폴링 GET /api/events, 심각도별 텍스트+아이콘+색, 미확인 상단고정, 확인 버튼 → POST /events/{id}/ack.
    • public/assets/zone-draw.js: canvas에 카메라 스냅샷 배경 + 클릭으로 폴리곤 점 추가, level(warning/restricted/pinch) 선택, 저장 → POST /api/zones; 기존 구역 GET /api/zones/{camera} 로드.
  • Step 6: 전체 스위트 + 커밋

Run: ./vendor/bin/phpunit (전체) — green.

git add server/public server/src/View server/tests/WiringSmokeTest.php
git commit -m "feat(server): front controller wiring + views + zone-draw UI"

Task 16: PDO Repository 구현 + schema.sql + 배포 문서 (DB 게이트)

⚠️ DB 통합 태스크. 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 게이트(로컬에서 pdo_sqlite 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.

Files:

  • Create: server/src/Db/Database.php, server/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php (9개), server/schema.sql, server/docs/server-deploy.md, server/tests/PdoIntegrationTest.php

Interfaces:

  • Consumes: repository 인터페이스(T3), Config(T1)

  • Produces: App\Db\Database::connect(Config): PDO (ERRMODE_EXCEPTION, prepared). 각 Pdo*Repository가 인터페이스 구현(전부 prepared statement).

  • Step 1: Database factory — server/src/Db/Database.php

<?php
declare(strict_types=1);
namespace App\Db;
use App\Config;
use PDO;

final class Database
{
    public static function connect(Config $cfg): PDO
    {
        return new PDO($cfg->dbDsn(), $cfg->dbUser(), $cfg->dbPass(), [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
    }
}
  • Step 2: schema.sql — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. event_type/severity/level은 애플리케이션 화이트리스트(+ 선택 CHECK). nonces(nonce PK, expires_at INDEX), rate_limits(rate_key, window) 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON.

  • Step 3: PDO repositories (9개) — 각 인터페이스를 prepared statement로 구현. 예 server/src/Repository/Pdo/PdoNonceRepository.php:

<?php
declare(strict_types=1);
namespace App\Repository\Pdo;
use App\Repository\NonceRepository;
use PDO;

final class PdoNonceRepository implements NonceRepository
{
    public function __construct(private PDO $pdo) {}
    public function seen(string $nonce): bool {
        $st = $this->pdo->prepare('SELECT 1 FROM nonces WHERE nonce = ? LIMIT 1');
        $st->execute([$nonce]);
        return (bool)$st->fetchColumn();
    }
    public function remember(string $nonce, int $expiresAt): void {
        $st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)');
        $st->execute([$nonce, $expiresAt]);
    }
}

나머지 8개 동일 패턴(전부 prepared, 문자열 보간 금지):

  • PdoDeviceRepository: secretFor(SELECT secret), touchHeartbeat(UPDATE/UPSERT), configVersion(SELECT).

  • PdoRateLimiterRepository: INSERT ... ON DUPLICATE KEY UPDATE count=count+1 (MySQL) 후 SELECT count. (sqlite는 UPSERT 구문 분기 or SELECT-then-INSERT/UPDATE.)

  • PdoEventRepository: insert(INSERT, metrics=json_encode), recent(SELECT ORDER BY occurred_at DESC LIMIT, 선택 severity), find(SELECT), acknowledge(UPDATE acknowledged_by/at).

  • PdoSnapshotRepository: add(INSERT), forEvent(SELECT), pathByUuid(=snapshotDir/uuid.jpg, 존재 시).

  • PdoUserRepository: byUsername(SELECT), recordFailure(UPDATE), clearFailures(UPDATE).

  • PdoZoneRepository: forDevice/forCamera(SELECT), save(UPSERT by zone_id or INSERT), delete(DELETE).

  • PdoThresholdRepository: forDevice(SELECT key/value → assoc).

  • PdoAuditRepository: append(INSERT, before/after=json_encode).

  • Step 4: (게이트) sqlite 통합 테스트 — server/tests/PdoIntegrationTest.php:

protected function setUp(): void {
    if (!in_array('sqlite', \PDO::getAvailableDrivers(), true)) {
        $this->markTestSkipped('pdo_sqlite not available on this host');
    }
    // create sqlite :memory:, load sqlite-compatible DDL, then round-trip Pdo*Repository
}

로컬 PDO 미로드 환경에서도 markTestSkipped로 스위트 green 유지.

  • Step 5: 배포 문서 — server/docs/server-deploy.md: Cafe24 업로드 절차(public/만 웹루트; storage/·src/·config.php는 웹루트 밖 또는 상위 디렉토리), config.php 작성(config.example.php 복사), schema.sql phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(password_hash argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, vendor/는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.

  • Step 6: 전체 스위트 + 커밋

Run: ./vendor/bin/phpunit — green(통합은 sqlite 없으면 skip).

git add server/src/Db server/src/Repository/Pdo server/schema.sql server/docs/server-deploy.md server/tests/PdoIntegrationTest.php
git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"

Self-Review

1. Spec coverage (P1-B 범위):

  • Ingest HMAC+nonce+timestamp+ratelimit+재인코딩+웹루트밖 → T2·T5·T7 ✓ (NF5, §7.3)
  • 인증/RBAC/세션/brute-force → T8·T9·T10 ✓ (NF7, §7.1)
  • 최소 대시보드(목록·상세·확인)+스냅샷 프록시 → T12 ✓ (§7.2)
  • 구역설정 UI(폴리곤 드로잉·저장) → T13·T15 ✓ (사용자 결정; P1-A zone-loading 갭 해소 — Pi가 GET /api/config로 zones 폴링→ZoneIntrusionDetector 주입)
  • config/heartbeat 동기 API → T14 ✓ (§7.3)
  • DB schema+PDO+prepared → T3·T16 ✓ (§7.4, §9.2)
  • audit 로그(확인·구역·로그인) → T10·T12·T13 ✓ (append; 해시체인 무결성은 P3)
  • 색맹안전 심각도 → T15 뷰 ✓ (NF9)
  • P1-B 범위 밖(다른 단계): 에스컬레이션·온콜·119(P3), 컴플라이언스 UI(사용자가 구역설정 선택 → 컴플라이언스는 P1-B-2/P2), 감도 다이얼·플릿·통계(P2/P3), legal-hold·해시체인 audit(P3), CSRF 토큰 실제 폼 강제(T8에 헬퍼 있음; 뷰/컨트롤러 강제는 T15 폼 + 후속).

2. Placeholder scan: 코드 스텝 모두 실제 PHP. T15 뷰/JS와 T16 PDO 8개·schema.sql은 인터페이스 시그니처(T3)·라우트표가 명확해 기계적 구현(각 지시에 SELECT/INSERT 매핑 명시). T16 DB 실행은 의도적 게이트.

3. Type consistency: 컨트롤러 소비 repo 시그니처가 T3 정의와 일치; Request/Response·SessionStore·Hmac::verify·Rbac::can 시그니처 태스크 간 일치. Ingest/Config/Heartbeat HMAC 검증이 P1-A sign(nonce+ts+body)와 동일(T2 테스트가 계약 고정).

주의(구현자): (a) InMemory recordFailure/clearFailures가 저장 배열 참조 갱신 → 다음 byUsername 반영(T10 잠금 테스트 전제). (b) Ingest 테스트 스냅샷 디렉토리 setUp 생성. (c) T15 스모크는 라우터 회귀만 검증(실제 컨트롤러 배선은 T16 PDO 완성 후 통합 구동).


Execution Handoff

Plan complete. 저장: docs/superpowers/plans/2026-07-11-p1b-server-ingest-dashboard.md.

실행 옵션:

  1. Subagent-Driven (추천) — 태스크마다 새 subagent + 리뷰. superpowers:subagent-driven-development.
  2. Inline Execution — 이 세션 순차 실행. superpowers:executing-plans.

어느 방식으로?