1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
|
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
|