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.
This commit is contained in:
유창욱 2026-07-20 11:30:52 +09:00
parent 5d511b04f2
commit 3f2823a764
110 changed files with 271 additions and 271 deletions

View file

@ -3,7 +3,7 @@
Offline-installable bundle + systemd unit for the `move-detect` edge daemon. Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
The bundle is fully self-contained (all wheels + pose model + source): the Pi The bundle is fully self-contained (all wheels + pose model + source): the Pi
performs **zero network fetches** at install or run time. The only runtime performs **zero network fetches** at install or run time. The only runtime
network use is the cloud POST / config-poll / heartbeat, and even that tolerates network use is the server POST / config-poll / heartbeat, and even that tolerates
being offline (events buffer in the local sqlite queue and flush on reconnect). being offline (events buffer in the local sqlite queue and flush on reconnect).
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the > Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
@ -81,8 +81,8 @@ sudo systemctl enable --now move-detect-edge
journalctl -u move-detect-edge -f journalctl -u move-detect-edge -f
``` ```
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see `DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled `server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
model (`/opt/move-detect/models/movenet_lightning.onnx`). model (`/opt/move-detect/models/movenet_lightning.onnx`).
## 4. Verify the air gap (do this before trusting a build) ## 4. Verify the air gap (do this before trusting a build)
@ -93,7 +93,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`).
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 + - [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
numpy + requests all import from the venv). numpy + requests all import from the venv).
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows - [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
frames processed and (if the cloud is reachable) heartbeats. frames processed and (if the server is reachable) heartbeats.
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore - [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.) → the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
@ -125,14 +125,14 @@ validated). USB webcam stays available anytime via `CAMERA_BACKEND=uvc`.
**Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several **Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
the Hailo device (round-robin) and the offline queue, and cloud zones are routed to the the Hailo device (round-robin) and the offline queue, and server zones are routed to the
matching camera. Example — CSI + USB together: matching camera. Example — CSI + USB together:
```ini ```ini
CAMERAS=csi:0:cam-01,uvc:0:cam-02 CAMERAS=csi:0:cam-01,uvc:0:cam-02
``` ```
Each `camera_id` must match the `camera_id` on that camera's zones in the cloud. Each `camera_id` must match the `camera_id` on that camera's zones in the server.
> **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't > **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer > carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
@ -141,7 +141,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
## Not bundled (by design) ## Not bundled (by design)
- **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`. - **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled - **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above); HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
the default CPU MoveNet + USB path needs none of them. the default CPU MoveNet + USB path needs none of them.

View file

@ -136,7 +136,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
echo ">> [4/6] verify the edge package imports offline" echo ">> [4/6] verify the edge package imports offline"
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level # cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
# on the unconditional startup chain; the daemon's cloud clients import requests at startup). # on the unconditional startup chain; the daemon's server clients import requests at startup).
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')" PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime # onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may # ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
@ -226,6 +226,6 @@ systemctl daemon-reload
echo "" echo ""
echo "Installed. Next:" echo "Installed. Next:"
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)" echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
echo " 2. sudo systemctl enable --now move-detect-edge" echo " 2. sudo systemctl enable --now move-detect-edge"
echo " 3. journalctl -u move-detect-edge -f" echo " 3. journalctl -u move-detect-edge -f"

View file

@ -6,7 +6,7 @@
# sudo deploy/setup_online.sh # sudo deploy/setup_online.sh
# #
# Non-interactive: pass values as env vars, e.g. # Non-interactive: pass values as env vars, e.g.
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \ # sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh # HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
# The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF. # The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF.
# If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it: # If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it:
@ -89,7 +89,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the # cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND # unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
# can reach build_pose() without it. # can reach build_pose() without it.
# requests the daemon builds its cloud/alert clients with no injected HTTP fn, so # requests the daemon builds its server/alert clients with no injected HTTP fn, so
# `import requests` fires at startup regardless of backend. # `import requests` fires at startup regardless of backend.
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives # numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives
# transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless # transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
@ -233,7 +233,7 @@ else
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}" : "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}"
if [ -t 0 ]; then if [ -t 0 ]; then
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID [ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; } [ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from server): " DEVICE_SECRET; echo; }
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL [ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS [ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
fi fi
@ -254,7 +254,7 @@ TARGET_FPS=12
EOF EOF
chown root:"$SVC_USER" "$ETC/edge.env" chown root:"$SVC_USER" "$ETC/edge.env"
chmod 0640 "$ETC/edge.env" chmod 0640 "$ETC/edge.env"
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events." [ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events."
echo " -> wrote $ETC/edge.env" echo " -> wrote $ETC/edge.env"
fi fi

View file

@ -1,7 +1,7 @@
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망). # move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then # Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
# sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge # sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge
# The daemon tolerates the cloud being offline (events buffer in the local queue # The daemon tolerates the server being offline (events buffer in the local queue
# and flush on reconnect), so this unit only *wants* the network, not requires it. # and flush on reconnect), so this unit only *wants* the network, not requires it.
[Unit] [Unit]

View file

@ -14,13 +14,13 @@ Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low
A safety system that *looks* healthy while doing nothing is the worst failure. Make every A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD. misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/cloudclient.py`, `edge/alert.py` — `requests` sends the default - [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an `python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True` HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx. (`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`; DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_cloudclient.py. tests in test_alert.py / test_serverclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt, - [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/ docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates. missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
@ -35,7 +35,7 @@ misconfig/degradation fail LOUD.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera; DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit. test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where - [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the cloud cannot tell a dead device from a live one. `source.read()` returned nothing, so the server cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body. **Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors; DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag. test_daemon.py locks fps math + failing-camera flag.
@ -54,12 +54,12 @@ misconfig/degradation fail LOUD.
poison head unwedges the queue; test locks the drain-past-poison behavior. poison head unwedges the queue; test locks the drain-past-poison behavior.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out - [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; cloud dedups on it. client-generated `event_id` in the payload; server dedups on it.
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
creation, preserved through the offline queue + every retry). Cloud: `events.event_uuid` creation, preserved through the offline queue + every retry). Server: `events.event_uuid`
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key. already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (cloud). Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError - [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely. at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it. DONE — `frame_interval_from_env` validates; test_config.py locks it.
@ -70,7 +70,7 @@ misconfig/degradation fail LOUD.
## Phase 2 — Core safety correctness (fall / zone / tracking) ## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `cloud/public/assets/zone-draw.js:196` — zones are - [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides. zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
@ -125,13 +125,13 @@ misconfig/degradation fail LOUD.
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox. face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both. fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored - [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days` forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it. ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored - [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`. snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
@ -149,42 +149,42 @@ misconfig/degradation fail LOUD.
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
is the optional CPU fallback); flag for whoever needs CPU MoveNet. is the optional CPU fallback); flag for whoever needs CPU MoveNet.
## Phase 4 — Cloud hardening (shared hosting) ## Phase 4 — Server hardening (shared hosting)
- [x] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException - [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On). becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`; **Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`. ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"} DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse + error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified. backstop. Package build verified.
- [x] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate - [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error. error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood. (shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits - [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows. rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded, DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green. gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a - [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params. captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [x] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at - [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination. newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory), DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full `limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`. history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a - [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error. clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException) DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double. → `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `cloud/src/Ingest/IngestController.php:64` — snapshot write failure is - [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:** swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans. order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode, DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
@ -192,20 +192,20 @@ misconfig/degradation fail LOUD.
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent` is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(throwing repo double → asserts 201, event stored, no leftover *.jpg). (throwing repo double → asserts 201, event stored, no leftover *.jpg).
- [x] **P4-8 [L]** `cloud/src/Auth/AuthController.php:31` — login form emits a CSRF token that - [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that
`login` never checks. **Fix:** verify it (or drop it). `login` never checks. **Fix:** verify it (or drop it).
DONE — verified (completed the half-built defense, not dropped). login() checks DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the `Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`. `testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after - [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder. `session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
DONE — logout() rotates the id then wipes (was regenerate-after-destroy). DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
- [x] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an - [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator. lengths in the validator.
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest. DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
- [x] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` - [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize. (parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain ## Phase 5 — Install / bundle / supply-chain
@ -243,7 +243,7 @@ misconfig/degradation fail LOUD.
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera `/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware. is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera. DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs - [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude. (`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
--- ---
@ -253,7 +253,7 @@ misconfig/degradation fail LOUD.
(updated as each item lands — newest first) (updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done. - **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + cloud dedup on a UNIQUE P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration + list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3: provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
@ -272,16 +272,16 @@ misconfig/degradation fail LOUD.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning · (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.) knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (cloud error/auth hardening).** ZoneConfig ownership-mismatch - **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic (P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM). snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared - **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH (P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to cloud batch), P2-6..11 (mostly threshold correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6). decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet. Nothing committed yet.
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package. DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.

View file

@ -1,10 +1,10 @@
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드) # P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다. 이 문서는 노트북 웹캠 하나로 **엣지 → 서버 → 관리자 응답**의 최소 경로를 돌리는 절차다.
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6). 실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST. - 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림. - 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
--- ---
@ -25,23 +25,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
--- ---
## 2. 클라우드 로컬 기동 ## 2. 서버 로컬 기동
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은 로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만. [server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블). 1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입 2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
(`snapshot_dir`는 쓰기 가능 경로로). (`snapshot_dir`는 쓰기 가능 경로로).
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다. 3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용: CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"` - 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);` - `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"` - 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);` - `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
4. 기동: `php -S localhost:8000 -t cloud/public` 4. 기동: `php -S localhost:8000 -t server/public`
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개 - 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조. `localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
--- ---
@ -53,7 +53,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|-----|------|------| |-----|------|------|
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` | | `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` | | `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` | | `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` | | `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` | | `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` | | `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
@ -94,7 +94,7 @@ python -m edge # 또는 move-detect-edge
```bash ```bash
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip) pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings) cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings)
``` ```
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` — 모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —

View file

@ -1,4 +1,4 @@
# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan # 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. > **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.
@ -10,22 +10,22 @@
## Global Constraints ## Global Constraints
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`). - **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`).
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출. - **폐쇄망/공유호스팅:** 런타임 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`. - **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초). - **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지. - **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증. - **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증.
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer. - **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트. - **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지. - **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지.
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24). - **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로. - 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
## File Structure ## File Structure
``` ```
cloud/ server/
composer.json # PSR-4 autoload + phpunit(dev) composer.json # PSR-4 autoload + phpunit(dev)
phpunit.xml phpunit.xml
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore) config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
@ -64,7 +64,7 @@ cloud/
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit) ## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
**Files:** **Files:**
- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore` - Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -74,7 +74,7 @@ cloud/
```json ```json
{ {
"name": "move-detect/cloud", "name": "move-detect/server",
"type": "project", "type": "project",
"require": { "php": ">=8.2" }, "require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" }, "require-dev": { "phpunit/phpunit": "^11" },
@ -95,7 +95,7 @@ cloud/
</phpunit> </phpunit>
``` ```
- [ ] **Step 3: `.gitignore`** (cloud/) - [ ] **Step 3: `.gitignore`** (server/)
``` ```
/vendor/ /vendor/
@ -104,7 +104,7 @@ cloud/
!/storage/snapshots/.gitkeep !/storage/snapshots/.gitkeep
``` ```
- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php` - [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php`
```php ```php
<?php <?php
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
- [ ] **Step 5: 실패 확인** - [ ] **Step 5: 실패 확인**
Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php` Run: `cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
Expected: FAIL — `Class "App\Config" not found` Expected: FAIL — `Class "App\Config" not found`
- [ ] **Step 6: 구현** — `cloud/src/Config.php` - [ ] **Step 6: 구현** — `server/src/Config.php`
```php ```php
<?php <?php
@ -180,8 +180,8 @@ return [
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
```bash ```bash
git add cloud/composer.json cloud/phpunit.xml cloud/.gitignore cloud/config.example.php cloud/src/Config.php cloud/tests/ConfigTest.php 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(cloud): P1-B scaffold — composer autoload + Config" git commit -m "feat(server): P1-B scaffold — composer autoload + Config"
``` ```
--- ---
@ -189,13 +189,13 @@ git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치 ## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
**Files:** **Files:**
- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php` - Create: `server/src/Security/Hmac.php`, `server/tests/HmacTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - 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, 상수시간). - 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: 실패 테스트** — `cloud/tests/HmacTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/HmacTest.php`
```php ```php
<?php <?php
@ -229,7 +229,7 @@ final class HmacTest extends TestCase
Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found) Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found)
- [ ] **Step 3: 구현** — `cloud/src/Security/Hmac.php` - [ ] **Step 3: 구현** — `server/src/Security/Hmac.php`
```php ```php
<?php <?php
@ -256,8 +256,8 @@ final class Hmac
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
```bash ```bash
git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php git add server/src/Security/Hmac.php server/tests/HmacTest.php
git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract" git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
``` ```
--- ---
@ -265,7 +265,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
## Task 3: Repository 인터페이스 + InMemory 테스트더블 ## Task 3: Repository 인터페이스 + InMemory 테스트더블
**Files:** **Files:**
- Create: `cloud/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `cloud/tests/Support/InMemoryRepositories.php`, `cloud/tests/RepositoryContractTest.php` - Create: `server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `server/tests/Support/InMemoryRepositories.php`, `server/tests/RepositoryContractTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -282,7 +282,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의. - [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
`cloud/src/Repository/DeviceRepository.php`: `server/src/Repository/DeviceRepository.php`:
```php ```php
<?php <?php
@ -413,7 +413,7 @@ interface AuditRepository
} }
``` ```
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `cloud/tests/Support/InMemoryRepositories.php` (테스트 전용 구현) - [ ] **Step 2: InMemory 더블 + 실패 테스트** — `server/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
```php ```php
<?php <?php
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
} }
``` ```
그리고 `cloud/tests/RepositoryContractTest.php`: 그리고 `server/tests/RepositoryContractTest.php`:
```php ```php
<?php <?php
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
- [ ] **Step 4: 커밋** - [ ] **Step 4: 커밋**
```bash ```bash
git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php
git commit -m "feat(cloud): repository interfaces + in-memory test doubles" git commit -m "feat(server): repository interfaces + in-memory test doubles"
``` ```
--- ---
@ -532,13 +532,13 @@ git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증) ## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
**Files:** **Files:**
- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php` - Create: `server/src/Ingest/EventValidator.php`, `server/tests/EventValidatorTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - 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), 문자열 항목. - 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: 실패 테스트** — `cloud/tests/EventValidatorTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php`
```php ```php
<?php <?php
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php` - [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php`
```php ```php
<?php <?php
@ -627,8 +627,8 @@ final class EventValidator
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
git commit -m "feat(cloud): event payload validator with enum whitelist" git commit -m "feat(server): event payload validator with enum whitelist"
``` ```
--- ---
@ -636,13 +636,13 @@ git commit -m "feat(cloud): event payload validator with enum whitelist"
## Task 5: ImageProcessor (gd 재인코딩 + UUID) ## Task 5: ImageProcessor (gd 재인코딩 + UUID)
**Files:** **Files:**
- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php` - Create: `server/src/Ingest/ImageProcessor.php`, `server/tests/ImageProcessorTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - 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(재인코딩 바이트). - 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: 실패 테스트** — `cloud/tests/ImageProcessorTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php`
```php ```php
<?php <?php
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php` - [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php`
```php ```php
<?php <?php
@ -710,8 +710,8 @@ final class ImageProcessor
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)" git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
``` ```
--- ---
@ -719,7 +719,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
## Task 6: Http Request/Response ## Task 6: Http Request/Response
**Files:** **Files:**
- Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php` - Create: `server/src/Http/Request.php`, `server/src/Http/Response.php`, `server/tests/HttpTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -727,7 +727,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
- `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\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`. - `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: 실패 테스트** — `cloud/tests/HttpTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/HttpTest.php`
```php ```php
<?php <?php
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php` - [ ] **Step 3: 구현** — `server/src/Http/Request.php`
```php ```php
<?php <?php
@ -792,7 +792,7 @@ final class Request
} }
``` ```
`cloud/src/Http/Response.php`: `server/src/Http/Response.php`:
```php ```php
<?php <?php
@ -829,8 +829,8 @@ final class Response
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php
git commit -m "feat(cloud): Http Request/Response primitives" git commit -m "feat(server): Http Request/Response primitives"
``` ```
--- ---
@ -838,13 +838,13 @@ git commit -m "feat(cloud): Http Request/Response primitives"
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합 ## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
**Files:** **Files:**
- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php` - Create: `server/src/Ingest/IngestController.php`, `server/tests/IngestControllerTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1) - 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}`. - 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: 실패 테스트** — `cloud/tests/IngestControllerTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php`
```php ```php
<?php <?php
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php` - [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php`
```php ```php
<?php <?php
@ -991,8 +991,8 @@ final class IngestController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)" git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
``` ```
--- ---
@ -1000,7 +1000,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
## Task 8: Password (argon2id) + Csrf ## Task 8: Password (argon2id) + Csrf
**Files:** **Files:**
- Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php` - Create: `server/src/Security/Password.php`, `server/src/Security/Csrf.php`, `server/tests/PasswordCsrfTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - Consumes: (없음)
@ -1008,7 +1008,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
- `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`. - `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). - `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals).
- [ ] **Step 1: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/PasswordCsrfTest.php`
```php ```php
<?php <?php
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php` - [ ] **Step 3: 구현** — `server/src/Security/Password.php`
```php ```php
<?php <?php
@ -1054,7 +1054,7 @@ final class Password
} }
``` ```
`cloud/src/Security/Csrf.php`: `server/src/Security/Csrf.php`:
```php ```php
<?php <?php
@ -1073,8 +1073,8 @@ final class Csrf
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Security/Password.php cloud/src/Security/Csrf.php cloud/tests/PasswordCsrfTest.php git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php
git commit -m "feat(cloud): argon2id password + CSRF token helpers" git commit -m "feat(server): argon2id password + CSRF token helpers"
``` ```
--- ---
@ -1082,13 +1082,13 @@ git commit -m "feat(cloud): argon2id password + CSRF token helpers"
## Task 9: Rbac (역할→권한) ## Task 9: Rbac (역할→권한)
**Files:** **Files:**
- Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php` - Create: `server/src/Auth/Rbac.php`, `server/tests/RbacTest.php`
**Interfaces:** **Interfaces:**
- Consumes: (없음) - 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). - 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: 실패 테스트** — `cloud/tests/RbacTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php`
```php ```php
<?php <?php
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php` - [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php`
```php ```php
<?php <?php
@ -1146,8 +1146,8 @@ final class Rbac
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php git add server/src/Auth/Rbac.php server/tests/RbacTest.php
git commit -m "feat(cloud): RBAC permission matrix" git commit -m "feat(server): RBAC permission matrix"
``` ```
--- ---
@ -1155,7 +1155,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
## Task 10: Session + AuthController (로그인/brute-force 잠금) ## Task 10: Session + AuthController (로그인/brute-force 잠금)
**Files:** **Files:**
- Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php` - Create: `server/src/Auth/Session.php`, `server/src/Auth/AuthController.php`, `server/tests/AuthControllerTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3) - Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
@ -1163,7 +1163,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
- `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie). - `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. - `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: 실패 테스트** — `cloud/tests/AuthControllerTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/AuthControllerTest.php`
```php ```php
<?php <?php
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php` - [ ] **Step 3: 구현** — `server/src/Auth/Session.php`
```php ```php
<?php <?php
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
} }
``` ```
`cloud/src/Auth/AuthController.php`: `server/src/Auth/AuthController.php`:
```php ```php
<?php <?php
@ -1300,8 +1300,8 @@ final class AuthController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php
git commit -m "feat(cloud): session store + auth controller with lockout" git commit -m "feat(server): session store + auth controller with lockout"
``` ```
--- ---
@ -1309,13 +1309,13 @@ git commit -m "feat(cloud): session store + auth controller with lockout"
## Task 11: Router + 프론트컨트롤러 준비 ## Task 11: Router + 프론트컨트롤러 준비
**Files:** **Files:**
- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php` - Create: `server/src/Http/Router.php`, `server/tests/RouterTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6) - 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). - 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: 실패 테스트** — `cloud/tests/RouterTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php`
```php ```php
<?php <?php
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php` - [ ] **Step 3: 구현** — `server/src/Http/Router.php`
```php ```php
<?php <?php
@ -1379,8 +1379,8 @@ final class Router
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Http/Router.php cloud/tests/RouterTest.php git add server/src/Http/Router.php server/tests/RouterTest.php
git commit -m "feat(cloud): path-param router + dispatch" git commit -m "feat(server): path-param router + dispatch"
``` ```
--- ---
@ -1388,7 +1388,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시 ## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
**Files:** **Files:**
- Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php` - Create: `server/src/Dashboard/EventListController.php`, `server/src/Dashboard/EventDetailController.php`, `server/src/Dashboard/SnapshotProxy.php`, `server/tests/DashboardTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3) - Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
@ -1397,7 +1397,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
- `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`). - `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. - `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/DashboardTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/DashboardTest.php`
```php ```php
<?php <?php
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php` - [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php`
```php ```php
<?php <?php
@ -1476,7 +1476,7 @@ final class EventListController
} }
``` ```
`cloud/src/Dashboard/EventDetailController.php`: `server/src/Dashboard/EventDetailController.php`:
```php ```php
<?php <?php
@ -1516,7 +1516,7 @@ final class EventDetailController
} }
``` ```
`cloud/src/Dashboard/SnapshotProxy.php`: `server/src/Dashboard/SnapshotProxy.php`:
```php ```php
<?php <?php
@ -1545,8 +1545,8 @@ final class SnapshotProxy
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Dashboard/EventListController.php cloud/src/Dashboard/EventDetailController.php cloud/src/Dashboard/SnapshotProxy.php cloud/tests/DashboardTest.php 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(cloud): dashboard list/detail/ack + auth-gated snapshot proxy" git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy"
``` ```
--- ---
@ -1554,7 +1554,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
## Task 13: Polygon 검증 + ZoneConfigController ## Task 13: Polygon 검증 + ZoneConfigController
**Files:** **Files:**
- Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php` - Create: `server/src/Geometry/Polygon.php`, `server/src/Dashboard/ZoneConfigController.php`, `server/tests/ZoneConfigTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3) - Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
@ -1562,7 +1562,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
- `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자. - `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`. - `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: 실패 테스트** — `cloud/tests/ZoneConfigTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/ZoneConfigTest.php`
```php ```php
<?php <?php
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php` - [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php`
```php ```php
<?php <?php
@ -1631,7 +1631,7 @@ final class Polygon
} }
``` ```
`cloud/src/Dashboard/ZoneConfigController.php`: `server/src/Dashboard/ZoneConfigController.php`:
```php ```php
<?php <?php
@ -1698,8 +1698,8 @@ final class ZoneConfigController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php
git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)" git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
``` ```
--- ---
@ -1707,7 +1707,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
## Task 14: ConfigController + HeartbeatController (Pi 동기 API) ## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
**Files:** **Files:**
- Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php` - Create: `server/src/Api/ConfigController.php`, `server/src/Api/HeartbeatController.php`, `server/tests/ApiSyncTest.php`
**Interfaces:** **Interfaces:**
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1) - Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
@ -1715,7 +1715,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
- `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`. - `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. - `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200.
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ApiSyncTest.php` - [ ] **Step 1: 실패 테스트** — `server/tests/ApiSyncTest.php`
```php ```php
<?php <?php
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
- [ ] **Step 2: 실패 확인** → FAIL - [ ] **Step 2: 실패 확인** → FAIL
- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php` - [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php`
```php ```php
<?php <?php
@ -1802,7 +1802,7 @@ final class ConfigController
} }
``` ```
`cloud/src/Api/HeartbeatController.php`: `server/src/Api/HeartbeatController.php`:
```php ```php
<?php <?php
@ -1840,8 +1840,8 @@ final class HeartbeatController
- [ ] **Step 4: 통과 + 커밋** - [ ] **Step 4: 통과 + 커밋**
```bash ```bash
git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php
git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)" git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
``` ```
--- ---
@ -1849,11 +1849,11 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합) ## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
**Files:** **Files:**
- Create: `cloud/public/index.php`, `cloud/public/.htaccess`, `cloud/public/assets/{app.css,app.js,zone-draw.js}`, `cloud/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `cloud/tests/WiringSmokeTest.php` - 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:** **Interfaces:**
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만). - Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
- Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁). - Produces: `server/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
라우트 표: 라우트 표:
| method | path | handler | | method | path | handler |
@ -1872,7 +1872,7 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
| GET | /snap/{uuid} | SnapshotProxy::serve | | GET | /snap/{uuid} | SnapshotProxy::serve |
| GET | /, /zones, /login | View render | | GET | /, /zones, /login | View render |
- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php` - [ ] **Step 1: 라우터 스모크 테스트** — `server/tests/WiringSmokeTest.php`
```php ```php
<?php <?php
@ -1902,7 +1902,7 @@ final class WiringSmokeTest extends TestCase
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS. - [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
- [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php` - [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php`
```php ```php
<?php <?php
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
$r->dispatch(Request::fromGlobals())->send(); $r->dispatch(Request::fromGlobals())->send();
``` ```
- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess` - [ ] **Step 4: .htaccess** — `server/public/.htaccess`
```apache ```apache
RewriteEngine On RewriteEngine On
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
Run: `./vendor/bin/phpunit` (전체) — green. Run: `./vendor/bin/phpunit` (전체) — green.
```bash ```bash
git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php git add server/public server/src/View server/tests/WiringSmokeTest.php
git commit -m "feat(cloud): front controller wiring + views + zone-draw UI" git commit -m "feat(server): front controller wiring + views + zone-draw UI"
``` ```
--- ---
@ -2005,13 +2005,13 @@ git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차. > ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
**Files:** **Files:**
- Create: `cloud/src/Db/Database.php`, `cloud/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `cloud/schema.sql`, `cloud/docs/cloud-deploy.md`, `cloud/tests/PdoIntegrationTest.php` - 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:** **Interfaces:**
- Consumes: repository 인터페이스(T3), `Config`(T1) - Consumes: repository 인터페이스(T3), `Config`(T1)
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement). - Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
- [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php` - [ ] **Step 1: Database factory** — `server/src/Db/Database.php`
```php ```php
<?php <?php
@ -2035,7 +2035,7 @@ final class Database
- [ ] **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 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로 구현. 예 `cloud/src/Repository/Pdo/PdoNonceRepository.php`: - [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `server/src/Repository/Pdo/PdoNonceRepository.php`:
```php ```php
<?php <?php
@ -2069,7 +2069,7 @@ final class PdoNonceRepository implements NonceRepository
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc). - `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
- `PdoAuditRepository`: append(INSERT, before/after=json_encode). - `PdoAuditRepository`: append(INSERT, before/after=json_encode).
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`: - [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`:
```php ```php
protected function setUp(): void { protected function setUp(): void {
@ -2082,15 +2082,15 @@ protected function setUp(): void {
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지. 로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
- [ ] **Step 5: 배포 문서** — `cloud/docs/cloud-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 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: 전체 스위트 + 커밋** - [ ] **Step 6: 전체 스위트 + 커밋**
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip). Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
```bash ```bash
git add cloud/src/Db cloud/src/Repository/Pdo cloud/schema.sql cloud/docs/cloud-deploy.md cloud/tests/PdoIntegrationTest.php 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(cloud): PDO repositories + schema + Cafe24 deploy doc" git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
``` ```
--- ---
@ -2118,7 +2118,7 @@ git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
## Execution Handoff ## Execution Handoff
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md`.** **Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-server-ingest-dashboard.md`.**
실행 옵션: 실행 옵션:
1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`. 1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`.

View file

@ -2,18 +2,18 @@
> **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. > **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:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.) **Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(서버) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 서버 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트. **Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 서버 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) ServerClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 서버 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지. **Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 서버는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
## Global Constraints ## Global Constraints
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지. - Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 서버 스위트 **0 warnings/0 deprecations** 유지.
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`). - **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐. - **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용. - **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(서버 T14가 이미 검증). PostClient 서명 재사용.
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑. - **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음. - YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
@ -21,7 +21,7 @@
``` ```
edge/ edge/
cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST) serverclient.py # ServerClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
zones.py # zone rows(dict) -> Zone objects; thresholds zones.py # zone rows(dict) -> Zone objects; thresholds
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones() runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main() daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
@ -30,33 +30,33 @@ edge/
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
tools/ tools/
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/ fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
cloud/ server/
src/View/event_detail.php # (replace stub) HTML detail shell + script src/View/event_detail.php # (replace stub) HTML detail shell + script
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}> public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
public/index.php # (edit) add GET /events/{id}/view HTML route public/index.php # (edit) add GET /events/{id}/view HTML route
tests/ (edge) + cloud/tests/ tests/ (edge) + server/tests/
docs/pilot-runbook.md docs/pilot-runbook.md
``` ```
--- ---
## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명) ## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명)
**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py` **Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py`
**Interfaces:** **Interfaces:**
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`. - Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
- Produces: - Produces:
- `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)` - `ServerClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None. - `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx. - `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
- [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py` - [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py`
```python ```python
import json import json
from edge.cloudclient import CloudClient from edge.serverclient import ServerClient
from edge.alert import sign from edge.alert import sign
class _Resp: class _Resp:
@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses():
def fake_get(url, headers=None, timeout=None): def fake_get(url, headers=None, timeout=None):
captured["url"] = url; captured["headers"] = headers captured["url"] = url; captured["headers"] = headers
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}})) return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get) c = ServerClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
cfg = c.fetch_config() cfg = c.fetch_config()
assert cfg["version"] == 3 assert cfg["version"] == 3
assert "device_id=pi-1" in captured["url"] assert "device_id=pi-1" in captured["url"]
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "") assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
def test_fetch_config_none_on_error(): def test_fetch_config_none_on_error():
c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401)) c = ServerClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
assert c.fetch_config() is None assert c.fetch_config() is None
def test_send_heartbeat_signs_body(): def test_send_heartbeat_signs_body():
captured = {} captured = {}
def fake_post(url, data=None, headers=None, timeout=None): def fake_post(url, data=None, headers=None, timeout=None):
captured["data"] = data; captured["headers"] = headers; return _Resp(200) captured["data"] = data; captured["headers"] = headers; return _Resp(200)
c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post) c = ServerClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
assert c.send_heartbeat({"fps": 12.0}) is True assert c.send_heartbeat({"fps": 12.0}) is True
body = captured["data"] body = captured["data"]
assert json.loads(body)["device_id"] == "pi-1" assert json.loads(body)["device_id"] == "pi-1"
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body) assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
``` ```
- [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError) - [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError)
- [ ] **Step 3: 구현** — `edge/cloudclient.py` - [ ] **Step 3: 구현** — `edge/serverclient.py`
```python ```python
import json import json
@ -99,7 +99,7 @@ import json
from edge.alert import sign from edge.alert import sign
class CloudClient: class ServerClient:
def __init__(self, base_url: str, device_id: str, secret: str, def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
@ -146,16 +146,16 @@ class CloudClient:
return 200 <= resp.status_code < 300 return 200 <= resp.status_code < 300
``` ```
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS - [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS
```bash ```bash
git add edge/cloudclient.py tests/test_cloudclient.py git add edge/serverclient.py tests/test_serverclient.py
git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)" git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)"
``` ```
--- ---
## Task 2: 클라우드 zone 행 → Zone 객체 로딩 ## Task 2: 서버 zone 행 → Zone 객체 로딩
**Files:** Create `edge/zones.py`, `tests/test_zones.py` **Files:** Create `edge/zones.py`, `tests/test_zones.py`
@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict:
```bash ```bash
git add edge/zones.py tests/test_zones.py git add edge/zones.py tests/test_zones.py
git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects" git commit -m "feat(edge): parse server config zones/thresholds into Zone objects"
``` ```
--- ---
@ -341,7 +341,7 @@ git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zone
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml` **Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
**Interfaces:** **Interfaces:**
- Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py). - Consumes: `PipelineRuntime` (T3), `ServerClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
- Produces: - Produces:
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict. - `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리) - `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
@ -427,7 +427,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device) def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
import time import time
from edge.config import load_config from edge.config import load_config
from edge.cloudclient import CloudClient from edge.serverclient import ServerClient
from edge.alert import PostClient from edge.alert import PostClient
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime from edge.runtime import PipelineRuntime
@ -438,7 +438,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
now = time.time now = time.time
base = cfg.cafe24_url.rstrip("/") base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post) queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0"))) source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
@ -591,15 +591,15 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
--- ---
## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시 ## Task 6: 서버 사고 상세 뷰 — 스냅샷 표시
**Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php` **Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/tests/WiringSmokeTest.php`
**Interfaces:** **Interfaces:**
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image). - Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe). - Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
- [ ] **Step 1: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.) - [ ] **Step 1: 스모크 테스트 갱신** — `server/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd server && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
- [ ] **Step 2: event_detail.php (스텁 교체)** - [ ] **Step 2: event_detail.php (스텁 교체)**
@ -612,7 +612,7 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
<script src="/assets/detail.js" defer></script> <script src="/assets/detail.js" defer></script>
``` ```
- [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js` - [ ] **Step 3: detail.js** — `server/public/assets/detail.js`
```javascript ```javascript
(function () { (function () {
@ -653,18 +653,18 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail')); $r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
``` ```
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`. - [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`.
```bash ```bash
git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php
git commit -m "feat(cloud): incident detail view renders snapshot images" git commit -m "feat(server): incident detail view renders snapshot images"
``` ```
--- ---
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio ## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
**Files:** Modify `cloud/public/assets/app.js` **Files:** Modify `server/public/assets/app.js`
**Interfaces:** **Interfaces:**
- Consumes: `GET /api/events` (기존 폴링). - Consumes: `GET /api/events` (기존 폴링).
@ -702,11 +702,11 @@ function updateAlarm(events) {
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출. 그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
- [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경). - [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
```bash ```bash
git add cloud/public/assets/app.js git add server/public/assets/app.js
git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical" git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical"
``` ```
--- ---
@ -735,8 +735,8 @@ def test_real_movenet_runs():
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차: - [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`). 1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록. 2. **서버:** `php -S localhost:8000 -t server/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`. 3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
4. **실행:** `move-detect-edge` (또는 `python -m edge`). 4. **실행:** `move-detect-edge` (또는 `python -m edge`).
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush. 5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속. 6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
@ -753,7 +753,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
## Self-Review ## Self-Review
**1. 최소경로 커버리지 (기능검토 1~7):** **1. 최소경로 커버리지 (기능검토 1~7):**
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓ - 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→서버 흐름(3) → T4 데몬+기존 PostClient ✓
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓ - 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓ - config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지). - **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
@ -761,7 +761,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시). **2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
**3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관. **3. Type consistency:** `ServerClient.sign` = PostClient/서버 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
--- ---

View file

@ -8,7 +8,7 @@ from edge.events import Event
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent # Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC # with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
# and URL. Send an explicit UA on every request. CloudClient imports this too. # and URL. Send an explicit UA on every request. ServerClient imports this too.
USER_AGENT = "move-detect-edge/1.0" USER_AGENT = "move-detect-edge/1.0"
@ -65,7 +65,7 @@ class PostClient:
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout, resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
allow_redirects=False) allow_redirects=False)
except Exception: except Exception:
# Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us. # Connection refused / DNS / timeout — the server is unreachable, not rejecting us.
return SendResult.TRANSIENT return SendResult.TRANSIENT
code = resp.status_code code = resp.status_code
if 200 <= code < 300: if 200 <= code < 300:

View file

@ -49,7 +49,7 @@ def queue_db_path(env: dict) -> str:
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real # Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
# provisioned value. A unit left at any of these uploads nothing while looking healthy. # provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx") _PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the cloud are HMAC-derived (long hex); anything shorter is a # Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a
# stub left in the template, not a real key. # stub left in the template, not a real key.
_MIN_SECRET_LEN = 16 _MIN_SECRET_LEN = 16
@ -82,13 +82,13 @@ def require_upload_config(cfg: Config) -> None:
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)") problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN: elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the " problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the cloud, not a stub") "per-device secret issued by the server, not a stub")
if problems: if problems:
raise ValueError( raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run " "FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. " "but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the cloud." "https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
) )

View file

@ -63,7 +63,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
counters["syncs"] += 1 counters["syncs"] += 1
if heartbeat_every and i % heartbeat_every == 0: if heartbeat_every and i % heartbeat_every == 0:
# Measured fps over the window since the last beat, and per-camera health, so # Measured fps over the window since the last beat, and per-camera health, so
# the cloud can tell a live device from a degraded/dead one (was hardcoded 0.0 # the server can tell a live device from a degraded/dead one (was hardcoded 0.0
# + a frames counter that ticked even when no frame was read). # + a frames counter that ticked even when no frame was read).
t0, f0 = beat_anchor t0, f0 = beat_anchor
elapsed = now - t0 elapsed = now - t0
@ -133,7 +133,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
from dataclasses import replace from dataclasses import replace
from edge.config import (frame_interval_from_env, load_config, queue_db_path, from edge.config import (frame_interval_from_env, load_config, queue_db_path,
require_upload_config) require_upload_config)
from edge.cloudclient import CloudClient from edge.serverclient import ServerClient
from edge.alert import PostClient from edge.alert import PostClient
from edge.queue import OfflineQueue from edge.queue import OfflineQueue
from edge.runtime import PipelineRuntime from edge.runtime import PipelineRuntime
@ -161,7 +161,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
nonce = lambda: os.urandom(8).hex() nonce = lambda: os.urandom(8).hex()
now = time.time now = time.time
base = cfg.cafe24_url.rstrip("/") base = cfg.cafe24_url.rstrip("/")
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now) client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now) post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
queue = OfflineQueue(queue_db_path(dict(os.environ)), post) queue = OfflineQueue(queue_db_path(dict(os.environ)), post)

View file

@ -52,7 +52,7 @@ class Event:
spatial: tuple | None # (x, y) 유령사고 등 공간 기준 spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the # Stable idempotency key: generated ONCE at event creation and carried unchanged through the
# offline queue and every retry, so a POST that succeeded server-side but timed out # offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice. # client-side is deduplicated by the server (see server IngestController) instead of stored twice.
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex) event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict: def to_payload(self, device_id: str) -> dict:

View file

@ -27,7 +27,7 @@ class OfflineQueue:
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]: def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the # Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
# cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they # server dedups it. Rows queued before this field existed simply get a fresh uuid (they
# predate dedup, so a first-and-only delivery is correct for them). # predate dedup, so a first-and-only delivery is correct for them).
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {} uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]), e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
@ -70,7 +70,7 @@ class OfflineQueue:
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh # A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise # one just now. Persist it so every retry of this row reuses the SAME key — otherwise
# each flush sends a different uuid and a timeout-retried delivery duplicates on the # each flush sends a different uuid and a timeout-retried delivery duplicates on the
# cloud (the P1-9 bug, still open for transitional rows). # server (the P1-9 bug, still open for transitional rows).
if not payload.get("event_uuid"): if not payload.get("event_uuid"):
payload["event_uuid"] = event.event_uuid payload["event_uuid"] = event.event_uuid
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?", self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",

View file

@ -3,7 +3,7 @@ import json
from edge.alert import USER_AGENT, sign from edge.alert import USER_AGENT, sign
class CloudClient: class ServerClient:
def __init__(self, base_url: str, device_id: str, secret: str, def __init__(self, base_url: str, device_id: str, secret: str,
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0): nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")

View file

@ -18,7 +18,7 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]: def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
"""Parse cloud config zone rows into Zone objects. When camera_id is given, only """Parse server config zone rows into Zone objects. When camera_id is given, only
rows for that camera are kept (multi-camera routing); None keeps all rows.""" rows for that camera are kept (multi-camera routing); None keeps all rows."""
out: list[Zone] = [] out: list[Zone] = []
rows = config.get("zones", []) or [] rows = config.get("zones", []) or []

View file

@ -1,5 +1,5 @@
{ {
"name": "move-detect/cloud", "name": "move-detect/server",
"type": "project", "type": "project",
"require": { "php": ">=8.2" }, "require": { "php": ">=8.2" },
"require-dev": { "phpunit/phpunit": "^11" }, "require-dev": { "phpunit/phpunit": "^11" },

View file

@ -5,11 +5,11 @@
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a # THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
# folder. The hosting account executes nothing — no shell, no composer, no installer: the # folder. The hosting account executes nothing — no shell, no composer, no installer: the
# operator uploads files over FTP and edits config.php. That is also why this lives under # operator uploads files over FTP and edits config.php. That is also why this lives under
# cloud/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as # server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
# root on the target. # root on the target.
# #
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host): # Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# cloud/deploy/build_cafe24_package.sh # server/deploy/build_cafe24_package.sh
# #
# Output: dist/cafe24/ and dist/move-detect-cafe24.zip # Output: dist/cafe24/ and dist/move-detect-cafe24.zip
# #
@ -42,7 +42,7 @@ APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}" PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLOUD="$REPO_ROOT/cloud" SERVER="$REPO_ROOT/server"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}" OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/cafe24" STAGE="$OUT_DIR/cafe24"
ZIP="$OUT_DIR/move-detect-cafe24.zip" ZIP="$OUT_DIR/move-detect-cafe24.zip"
@ -75,15 +75,15 @@ echo ">> [2/6] generating the no-dev autoloader"
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's # would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
# `new PhpSessionStore()` — the 4th statement of every request. # `new PhpSessionStore()` — the 4th statement of every request.
# #
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites cloud/vendor/ # --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
# in place, so restore the dev autoloader on exit or local phpunit breaks. # in place, so restore the dev autoloader on exit or local phpunit breaks.
restore_dev_autoloader() { restore_dev_autoloader() {
echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)" echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true ( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
} }
trap restore_dev_autoloader EXIT trap restore_dev_autoloader EXIT
( cd "$CLOUD" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction ) ( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
# Precondition for everything below: this package stages ONLY composer's generated # Precondition for everything below: this package stages ONLY composer's generated
# autoloader, which is correct exactly while the app has no runtime packages. The day # autoloader, which is correct exactly while the app has no runtime packages. The day
@ -91,7 +91,7 @@ trap restore_dev_autoloader EXIT
# the host fatals on a missing class. Fail here, on the commit that adds the dependency, # the host fatals on a missing class. Fail here, on the commit that adds the dependency,
# rather than on the hosting account. # rather than on the hosting account.
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
"$CLOUD/composer.lock" || { "$SERVER/composer.lock" || {
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2 echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
echo " autoloader, so their code would be missing from the package. Teach" >&2 echo " autoloader, so their code would be missing from the package. Teach" >&2
echo " step [3/6] to stage the real vendor tree before shipping again." >&2 echo " step [3/6] to stage the real vendor tree before shipping again." >&2
@ -100,14 +100,14 @@ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ?
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)" echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
mkdir -p "$PRIV/vendor/composer" mkdir -p "$PRIV/vendor/composer"
cp "$CLOUD/vendor/autoload.php" "$PRIV/vendor/autoload.php" cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in # Copy only composer's own generated files, by name. A glob over vendor/* would drag in
# phpunit/, sebastian/, myclabs/ and friends. # phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \ for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f" cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
done done
cp "$CLOUD/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not # installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
# regenerate them — they are the package inventory written by the last `composer install`, # regenerate them — they are the package inventory written by the last `composer install`,
@ -141,18 +141,18 @@ if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -
fi fi
echo ">> [4/6] staging app source" echo ">> [4/6] staging app source"
cp -r "$CLOUD/src/." "$PRIV/src/" cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$CLOUD/public/." "$WEB/" cp -r "$SERVER/public/." "$WEB/"
cp "$CLOUD/schema.sql" "$PRIV/schema.sql" cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one. # The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess" cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php" cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php" cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). # .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini" cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# `cp -r` above copies whatever is in cloud/src|public — defensively strip dev/tooling caches # `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served # so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed. # package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \ find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \

View file

@ -1,13 +1,13 @@
# Cafe24 Cloud Deploy Guide (P1-B) # Cafe24 Server Deploy Guide (P1-B)
This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a This document covers deploying the `server/` PHP application (ingest API + dashboard) to a
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
prepared statements — there is nothing to `composer install` on the production host. prepared statements — there is nothing to `composer install` on the production host.
## 1. Directory layout on the server ## 1. Directory layout on the server
Only `cloud/public/` may be reachable from the web. Everything else must sit **outside** Only `server/public/` may be reachable from the web. Everything else must sit **outside**
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is), the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
or be blocked by hosting-level access rules if your plan cannot place files outside the or be blocked by hosting-level access rules if your plan cannot place files outside the
web root at all. web root at all.
@ -27,7 +27,7 @@ web root at all.
└── assets/ sit at the FTP root — see below. └── assets/ sit at the FTP root — see below.
``` ```
`cloud/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from `server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
subdirectory of one — in the latter case the app root is two levels up, not one, because subdirectory of one — in the latter case the app root is two levels up, not one, because
@ -39,7 +39,7 @@ views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without t
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
resolve against the co-hosted site. resolve against the co-hosted site.
`cloud/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=` `server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
(empty) to mount the app directly in `www/`. (empty) to mount the app directly in `www/`.
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional **`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
@ -49,7 +49,7 @@ every request, including §10's own smoke check. There are no runtime Composer *
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine: autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
```bash ```bash
cd cloud && composer dump-autoload --no-dev --classmap-authoritative --no-interaction cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
``` ```
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
@ -63,7 +63,7 @@ deliberately excludes.
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`. PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
`cloud/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the `server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
tree below automatically — prefer it over assembling the transfer by hand. tree below automatically — prefer it over assembling the transfer by hand.
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
@ -81,7 +81,7 @@ web-readable.
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
SSH if available) — do not assume, since PHP builds vary by hosting plan: SSH if available) — do not assume, since PHP builds vary by hosting plan:
- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written - [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
version Cafe24 provisions for your plan). version Cafe24 provisions for your plan).
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include - [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
@ -112,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan:
## 3. `config.php` ## 3. `config.php`
Copy the example and fill in real values — **never commit `config.php`** (already covered Copy the example and fill in real values — **never commit `config.php`** (already covered
by `cloud/.gitignore`): by `server/.gitignore`):
```bash ```bash
cp config.example.php config.php cp config.example.php config.php
@ -139,7 +139,7 @@ or version control.
## 4. Import the schema ## 4. Import the schema
`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`, `server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`). `events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this 1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this

View file

@ -32,7 +32,7 @@ final class EventValidator
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends // Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
// it on retry so the cloud can dedup. Enforce the exact shape (matches CHAR(32) + the // it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
// snapshot-uuid convention); a malformed key is a bug, not a silent pass. // snapshot-uuid convention); a malformed key is a bug, not a silent pass.
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) { if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
$errors[] = 'bad:event_uuid'; $errors[] = 'bad:event_uuid';

View file

@ -31,7 +31,7 @@ final class PdoDeviceRepository implements DeviceRepository
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController) // A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
// only reaches this after secretFor() already succeeded for the same // only reaches this after secretFor() already succeeded for the same
// device_id, so the row is guaranteed to exist. Device rows are provisioned // device_id, so the row is guaranteed to exist. Device rows are provisioned
// out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created // out-of-band by an admin (see server/docs/server-deploy.md), never created
// implicitly from an inbound heartbeat. // implicitly from an inbound heartbeat.
$st = $this->pdo->prepare( $st = $this->pdo->prepare(
'UPDATE move_devices 'UPDATE move_devices

View file

@ -125,7 +125,7 @@ final class IngestControllerTest extends TestCase
public function testDeduplicatesRepeatedEventUuid(): void { public function testDeduplicatesRepeatedEventUuid(): void {
// A POST that succeeded server-side but timed out client-side is re-sent with the SAME // A POST that succeeded server-side but timed out client-side is re-sent with the SAME
// event_uuid (fresh nonce). The cloud must return the already-stored event, not duplicate. // event_uuid (fresh nonce). The server must return the already-stored event, not duplicate.
[$c, $events] = $this->make(); [$c, $events] = $this->make();
$uuid = str_repeat('a', 32); $uuid = str_repeat('a', 32);
$p = $this->payload(); $p['event_uuid'] = $uuid; $p = $this->payload(); $p['event_uuid'] = $uuid;

Some files were not shown because too many files have changed in this diff Show more