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.