SCREECHVR V19: WORKER SECURITY + FIVE-MINUTE HEARTBEAT Date: 2026-10-30 Scope: Read-only review of local Worker/Unity source, V08 draft and official Cloudflare/PlayFab documentation. No code or production changes. V08 is draft, not verified as deployed. SUMMARY There is no implemented Worker heartbeat route and no Unity heartbeat caller. V08 CloudScript `Heartbeat` is not a cryptographic challenge protocol and has no D1 integration. Current /updateplayers ignores all input. /redeem validates a PlayFab session ticket but has non-atomic KV rate limiting and multi-step link/grant behavior. A proposed heartbeat can fit Cloudflare Free at 100 CCU in isolation (28,800 beats/day), but Workers Free has a shared 100,000 request/day limit and D1 Free 100,000 rows written/day; existing traffic consumes headroom. Heartbeat only indicates recent protocol activity, not an unmodified client. 1. CURRENT SOURCE FINDINGS Worker: `! Screech VR/discord-bot/worker.js` Route/auth map (router at lines 617-629): - `POST /photonwh/*` -> `handlePhotonHook` (175-206): requires PHOTON_KEY in `X-Secret` or `?key=` (177-180), parses JSON, optionally checks AppId (182). Uses Durable Object CCU counter when bound; on failure falls back to KV read-modify-write (193-205). Query key can leak via logs/history. This is Photon webhook authentication, not player custom authentication. - `GET /photonwh/state` -> `handlePhotonState` (209-219): requires `?key=`. Returns count and game state. Query-string secret is exposed to URL logging. - `POST /` -> Discord interactions (434-455): verifies Discord Ed25519 signature and timestamp within 300 seconds (246-257). Commands use interaction user ID; owner checks compare OWNER_ID (263-264); /link may check ROLE_ID (270-275). - `POST /updateplayers` -> `handleUpdatePlayers` (457-459): returns `{ok:true, ignored:true}` without parsing/authenticating input. Unity submits player count, but Worker does not use it. - `POST /redeem` -> `handleRedeem` (461-521): IP-only KV limiter 10/min via read/increment/write (463-468), reads code and PlayFab ticket, calls `/Server/AuthenticateSessionTicket` and rejects expired/invalid tickets (469-480). Then reads/writes link keys, deletes code/pending keys, grants hat and writes UserData (483-512). Multi-step KV/PlayFab operations are not atomic; result of UserData update is not checked and hat failure still returns success with warning (503-517). Rate limit can race; no per-account limiter or request idempotency. Some auth error responses default to HTTP 200 (478). - `GET /motd` -> `handleMotd` (524-530): unauthenticated, reads title data and returns MOTD/lockdown/maintenance. Public read route; verify that returned fields are intended to be public and avoid leaking internal title data. - `GET /export` (533-554) and `GET /setup` (556-610): key in query parameter, compared to EXPORT_KEY; export returns linked-account PII CSV. Protect with non-URL auth if retained, rotate/manage secret, restrict route and audit use. `/setup` calls Discord API; source lacks required User-Agent noted in project operational guidance, so guild route may fail. - `/health` (628): unauthenticated `{ok:true,service:"screech-link"}`; low sensitivity but method is not constrained. Apply method restriction if desired. - Unknown routes return 404. No general CORS or method constraints beyond route checks; no heartbeat endpoint. Secrets: defaults at worker.js:1-16/57-83 include blank env-backed credentials; runtime uses DISCORD_TOKEN, PLAYFAB_SECRET, PHOTON_KEY, EXPORT_KEY and other settings. No actual secret value is included here. Keep all secrets in Worker secret bindings, not source/client. Remove query-string secrets; do not log tickets, link codes, full PlayFab responses, or auth headers. `handleRedeem` currently logs truncated auth response and bad code details (477, 492); minimize and redact those logs. Unity callers: - `! Screech VR/Screech VR/Assets/PlayerCountReporter.cs`: starts after 5 sec, periodic interval default 30 sec, and reports join/leave/player changes (49-66, 77-95). Calls are not heartbeat/security signals. - `Assets/ScreechApi.cs`: `PostPlayers` sends `{"players":count}` to `/updateplayers` with 10 sec timeout (56-65, 115-123); no PlayFab ticket/auth header. Failures only warn. SessionTicket property (26-38) is used by `/redeem` JSON body (67-78), not the player-count route. - No Unity `/heartbeat` caller found in Assets C#. - V08 `Heartbeat` exists only in `revisions/SCREECHVR_PLAYFAB_REVISION_V08_2026-10-29.js` (447-506); no Unity caller found. It stores sequence/time in PlayFab InternalData, requires increasing sequence, checks optional client timestamp, rate limits at four minutes; `challengeResponse` is unused. Read-check-write is non-atomic; types/ranges weak; no Worker route, D1 state, session start/end, or authoritative session expiration. It does not itself prove integrity or implement an automatic ban. 2. PROPOSED AUTHENTICATED HEARTBEAT Purpose and threat limits: - Use heartbeat for session liveness/operational telemetry only. A modified client can continue sending valid beats, so it is not proof of integrity, legitimate movement, or fair play. Never auto-ban or kick for missed beats, Worker/D1 outage, or weak anomaly signals. - Obtain a PlayFab ClientSessionTicket from normal login. Start session by POSTing ticket to Worker over TLS. Worker calls PlayFab Server AuthenticateSessionTicket using server secret; derive PlayFabId from response, never trust client-supplied PlayFabId. Check expiration and optional ban state. No title secret goes to Unity. - Worker generates random session capability (e.g. 256-bit opaque token) and random one-use challenge; returns session token, challenge, server expiry and interval. Store only token hash, session ID/player ID, last sequence/beat, current challenge hash, created/expiry, status. Session token is a bearer credential, not an anti-cheat secret; TLS, redaction and rotation still matter. - Every 5 minutes, client POSTs session capability, current challenge, and strictly increasing integer sequence. Worker atomically condition-checks session active/unexpired, expected sequence/challenge, then updates last_seen, sequence and fresh challenge. Compare via one conditional D1 UPDATE/UPSERT with `RETURNING`; exactly one concurrent/replayed request can advance state. Repeated request gets stale/replayed response; optionally return current state only for a narrowly bounded retry mechanism without accepting an old nonce. - Server-issued nonce prevents replay of a previously accepted beat. It does not prevent a malicious client from requesting/answering fresh challenges. HMAC using a key stored in a modifiable client would not improve this trust model. - Session expires after a grace window (recommend 10-15 minutes after last accepted beat) and absolute lifetime (for example, bounded by PlayFab ticket expiration or periodic ticket revalidation). Stop marking active; do not ban. Player reconnects with fresh PlayFab ticket/session, receiving new session ID and nonce. Revoke/close old session on re-auth where feasible. Failure behavior: - PlayFab auth unavailable: do not create a trusted heartbeat session; return retryable service error. Do not accept a client-provided ID as fallback. - Worker/D1 timeout or 5xx: client retries with jitter/backoff, preserves current challenge/sequence until response outcome resolved; if ambiguous, reconnect or status-check with idempotent request ID. Mark telemetry stale/unknown, never punish the player. - D1 daily limits reached: degrade heartbeat only, alert operators; core login, rooms, /link, MOTD and CCU must not be coupled to heartbeat storage. - PlayFab ticket expired/revoked: require ordinary reauthentication; close or expire session. Use explicit response codes and avoid leaking session data. 3. D1 SCHEMA / ATOMICITY / RETENTION Minimal table (illustrative, not implemented): `heartbeat_sessions(session_id TEXT PRIMARY KEY, player_id TEXT NOT NULL, token_hash BLOB NOT NULL, seq INTEGER NOT NULL, challenge_hash BLOB NOT NULL, last_seen INTEGER NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, status TEXT NOT NULL)`. - One active row per session; optionally enforce one active session per player with a separate unique active-player key/transaction. Avoid needless indexes: Cloudflare counts an additional indexed-row write when indexed columns change. - Heartbeat uses one prepared SQL statement with conditional match on session, token hash, nonce hash, expected next sequence, active status and expiry; atomically sets new sequence, challenge hash, last_seen. Use RETURNING to determine success without read-before-write. Test exact `rows_read` and `rows_written` from D1 result metadata because SQLite query accounting and indexes affect billable counts. - Challenge generation can happen before conditional update; on lost response client must not assume accepted. Provide safe recovery protocol that does not let the old challenge be accepted twice. One option: server endpoint returns session status/current challenge only after bearer token auth, with throttling. - Cleanup: one scheduled bounded DELETE of expired sessions, e.g. daily or every few hours; no per-heartbeat evidence insert. For <=100 active rows, a periodic scan/delete is cheap. Retain only latest session state for 24-72 hours after expiry if operational diagnostics need it, then delete; aggregate anonymous counts if longer trends are needed. - Evidence: heartbeat records only last-seen/sequence/status; store flags only for independently useful security events with timestamp, rule, evidence reference and retention limit. Do not retain raw tickets, tokens, challenge values or high-frequency movement from heartbeat. Restrict access; redact logs. 4. CAPACITY AND COST ESTIMATE (100 CCU TARGET) One accepted heartbeat every 5 minutes = 12/player/hour = 288/player/day. Assumptions: steady active player count, one Worker request and one D1 conditional row update per beat; no per-beat KV write, no per-beat evidence row. The real D1 read count must be measured from query metadata before rollout. Active players | beats/Worker requests per day | D1 write rows/day* | storage 20 | 5,760 | ~5,760 | ~10-30 KB 50 | 14,400 | ~14,400 | ~25-70 KB 100 | 28,800 | ~28,800 | ~50-140 KB *Base estimate: one updated table row per heartbeat and no secondary indexes on changing columns. D1 documentation says each indexed-column update can add an index row write; counters/table/session lifecycle and cleanup add extra writes. If an indexed session lookup is required, session_id primary key is already indexed; avoid an additional index on last_seen. D1 reads are expected to be bounded to the matching session row (rough planning upper bound: one row read per beat: 5,760 / 14,400 / 28,800 per day). Conditional UPDATE may report different accounting; capture actual `meta.rows_read`/`rows_written` in staging. Monthly approximate beats: 172,800 / 432,000 / 864,000. These are below D1 Free 5M rows read/day, 100K rows written/day and 5GB total storage, if query accounting is near the stated one-row estimates. Workers Free allows 100K requests/day: at 100 players heartbeat alone uses 28.8% of this shared daily allowance; at 50 it uses 14.4%, at 20 5.76%. Existing Discord, redeem, webhook and other Worker traffic also consumes requests, so monitor total and retain headroom. Worker subrequests include D1 operations; Free limit is 50 per invocation, far above a single-query beat. D1 usage exceeding Free daily limits returns errors until reset, so heartbeat must fail gracefully. Cost target: €0 additional recurring is feasible if current account is on Workers Free, D1 free usage stays within shared quotas, and no new paid service is needed. Cloudflare pricing lists included Free usage; do not guarantee €0 until checking the actual account plan and aggregate usage. Existing Worker functionality may already consume capacity. No D1 has been provisioned or measured here. 5. DEPENDENCIES / MIGRATION / TESTS Dependencies and rollout: - Add D1 binding/database and migration; choose session/token and expiry policy. - Add Worker start/beat/reconnect/status routes with POST-only, strict JSON size/ schema, TLS, origin-independent bearer auth, per-session/IP rate limits, constant error detail, and no secrets in URLs/logs. Do not use CF IP alone as identity. Avoid KV writes for beats. - Validate PlayFab session ticket at start with Worker secret; consider periodic revalidation aligned with ticket lifetime. Determine if Worker is appropriate for game traffic latency/availability. - Add Unity client caller at a 5-minute interval with bounded jitter and retry; send only required session/nonce/seq. Avoid simultaneous duplicate coroutines. - Keep `/updateplayers` behavior as-is or remove after checking consumers; it currently does not update CCU and should not be presented as a security route. Preserve /redeem, /motd, Photon webhooks and Discord interactions when changing router/auth middleware. - Review PHOTON_KEY/EXPORT_KEY query parameter exposure and rotate only through separately approved operational work. Source scan is not evidence any secret value leaked. Regression tests (staging only): 1. Valid PlayFab session starts session; wrong/expired ticket and forged player ID rejected; no secret/ticket appears in logs. 2. Valid beat advances sequence and rotates nonce; old nonce, duplicate seq, future/negative seq, wrong session/token, malformed body all rejected. 3. Two concurrent requests using same challenge: at most one succeeds. Lost response/retry recovery must neither silently lose session nor double-accept. 4. Expired session, reauth/reconnect and ticket expiry behave as documented; no duplicate active sessions beyond policy. 5. Worker/D1/PlayFab timeouts, 429, 5xx and daily quota exhaustion do not ban or block core gameplay; retry backoff does not create request storms. 6. Load test 20/50/100 clients at five-minute cadence plus synchronized jitter; measure Worker requests, D1 rows_read/written, latency, errors, and total account quota headroom. Verify no KV operation on each beat. 7. Route regression: /redeem, /motd, /health, /export, /setup, Discord interactions and Photon webhook auth retain intended behavior; verify sensitive routes reject missing/bad credentials and URL secrets are removed in a future approved change. 8. Expiry cleanup and retention delete rows as specified; evidence logs contain only minimal non-secret metadata. 6. OFFICIAL DOCUMENTATION - Cloudflare D1 pricing and row accounting (indexed columns add writes; Free 5M read/day, 100K write/day, 5GB total storage; inspect query meta): https://developers.cloudflare.com/d1/platform/pricing/ - Workers Free limits (100K requests/day, 50 subrequests/request): https://developers.cloudflare.com/workers/platform/limits/ - D1 Worker API / prepared statements, batch transactions, session consistency: https://developers.cloudflare.com/d1/worker-api/d1-database/ - PlayFab AuthenticateSessionTicket (server-authenticated ticket validation): https://learn.microsoft.com/en-us/rest/api/playfab/server/authentication/authenticate-session-ticket UNRESOLVED - Actual Cloudflare account plan, aggregate daily Workers/D1 use, bindings, limits, current secret rotation and deployed Worker version. - Whether any V08 CloudScript handler is deployed/callable; V08 heartbeat has no Unity caller in inspected source. - Which Unity scenes run PlayerCountReporter and whether any external client uses `/updateplayers` despite the current no-op. - PlayFab ticket lifetime/revocation policy and acceptable session revalidation. - Privacy/retention requirement for player-linked liveness metadata; legal review and operator alerting threshold. No implementation, deployment, dashboard, or production changes. END OF REPORT