Two approved data-model changes.
P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid 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 (catches the insert-race PDOException and returns the
winner).
P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.
Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.
Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The event list was hard-capped at the newest 100 with no way to reach
older events. Adds keyset pagination:
- EventRepository::page(limit, severity, beforeTs, beforeId) on both the
PDO and in-memory implementations, ordered (occurred_at DESC,
event_id DESC). The cursor is the last row's (occurred_at, event_id),
so pages stay stable as new events arrive at the head — no overlap, no
skips. occurred_at is bound as a string in SQL to keep DECIMAL
precision.
- EventListController reads `limit` (default 50, capped 200) and `cursor`
("occurred_at:event_id", malformed input ignored) and returns
`next_cursor` only on a full page. recent() is retained for existing
callers; the response gains next_cursor additively.
The live-tail dashboard keeps consuming page 1; a history-browse UI can
build on the same cursor. Test: testListPaginatesWithCursor.
phpunit: 89 passing (4 skipped: PDO-backed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Only SameSite=Lax protected the acknowledge and zone save/delete endpoints,
even though a Csrf helper and mintable token already existed. Add
GET /api/csrf (CsrfController::token) to mint/return a per-session token,
check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge
and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch
the token on load and send it as X-Csrf-Token on every POST.