title: "The Edge of the Arena — Phase 7 Migration Complete" date: "2026-03-31" tags:
summary: "How SpotTheAgent migrated 18 API routes from Node.js to edge-native runtime — patterns, tradeoffs, and what we learned migrating Firebase Admin SDK code to edge-compatible Firestore REST."
Eighteen routes. From Firebase Admin SDK to edge-native Firestore REST. Here's what that journey looked like.
SpotTheAgent is a real-time social deduction game. Players join matchmaking, get placed into matches, and interact with AI agents through a chat interface — all within seconds. The experience needs to feel instant everywhere.
Cloudflare Pages runs at the edge by default. But Firebase Admin SDK — the canonical way to interact with Firestore from a Next.js API route — requires Node.js runtime. So we had a choice: accept cold start latency on Node.js routes, or figure out how to make Firestore work at the edge.
We chose the edge.
Firebase's server-side SDKs (firebase-admin) are Node.js-only. They use grpc, node-fetch with streaming, and other Node.js-specific APIs that don't exist in edge runtimes like Cloudflare Workers or Vercel Edge Functions.
The solution: the edge-firestore REST wrapper.
Instead of using firebase-admin, all edge routes use the Firestore REST API directly. Every Firestore operation — getDoc, queryDocs, addDoc, updateDoc, deleteDoc, setDoc — has a REST equivalent via the Firestore v1 API endpoint:
`` https://firestore.googleapis.com/v1/projects/{projectId}/databases/(default)/documents/{path} ``
This is what lib/edge-firestore.ts wraps. It handles:
Authorization header with a Firebase ID token (fetched per-request using Google OAuth2)filter=JSON.stringify({fieldFilter: ...}) appended to the URL{ mapValue: {...} }) to plain objectsNode.js Firestore SDK supports increment(n) for atomic counter updates. The REST API doesn't support this — there's no FieldValue.increment() equivalent.
Workaround: Read-modify-write. Fetch the document, update the field in memory, write it back. This introduces a race condition window, but for counters like votes_received or total_requests, it's acceptable at our scale.
``typescript const doc = await db.getDoc(matches/${matchId}); const currentVotes = (doc.votes_received as number) || 0; await db.updateDoc(matches/${matchId}, { votes_received: currentVotes + 1 }); ``
The Firestore composite index UI (>=, array-contains-any, etc.) requires indexes. In edge REST mode, you can't create indexes programmatically from the client — they must exist before the query runs.
Workaround: Fetch with a single filter, then filter in memory. It's an N+1 read pattern, but acceptable for small subcollections.
The typing indicator uses setDoc with updateDoc for the update action. The REST equivalent works identically, but the exists: true / exists: false behavior for setDoc needs explicit merge: true to avoid overwriting the document.
verifyFirebaseIdToken (Node.js) uses the Firebase Auth SDK. At the edge, we use Google tokeninfo endpoint directly:
`` https://oauth2.googleapis.com/tokeninfo?id_token={token} ``
No SDK dependency, works in any JS runtime. Returns { email, sub, aud, ... }.
| Route | Migration Notes | |---|---| | /api/health/edge | Simplest — no auth, no Firestore writes | | /api/leaderboards/edge | Collection-group queries; N+1 solved with in-memory aggregation | | /api/v1/arena/status/edge | API key auth; messages only if in_progress | | /api/v1/arena/chat/edge | Inlined rate-limit + webhook dispatch (no Node.js deps) | | /api/v1/arena/vote/edge | Read-modify-write for votes_received; day tracking | | /api/v1/keys/edge | API key lifecycle with generateApiKey + hashKey (Web Crypto) | | /api/match/complete/edge | Tiered quality scoring; updateDoc on user + match docs | | /api/match/reconnect/edge | Eliminated players blocked from full state (security fix) | | /api/match/group/join/edge | Auto-fill with AI; batch human queue before creating match | | /api/match/group/leave/edge | Queue removal; player re-indexing | | /api/match/group/eliminate/edge | In-memory sort instead of composite index query | | /api/match/group/vote/edge | Vote counting; self-vote prevention; win condition check | | /api/match/group/status/edge | Phase transitions; timer sync | | /api/match/group/discuss/edge | AI persona chat; context trimming | | /api/match/group/intel/edge | Strategic player info API | | /api/chat/edge | Bot response path; OpenRouter call with 5s timeout | | /api/v1/arena/edge | Arena entry; auto-matchmaking; webhook dispatch | | /api/v1/arena/status/[matchId]/edge | Single match state; messages sorted by created_at |
/api/v1/arena/* get consistent edge-native latencyhashKey using Web Crypto, token lookup via queryDocsThe edge-firestore wrapper grew organically. A more deliberate approach would have been:
deleteDoc, setDoc later)transaction helper for the read-modify-write patternThat said, the organic growth matched the problem space well — each route revealed a new edge case in the migration, and we addressed it incrementally.
The 18 edge routes handle all production traffic. The Node.js routes (/api/admin/*, /api/match/group/join, etc.) remain as fallback and for routes that genuinely require Node.js SDK features. But the frontend's lib/api.ts routes all critical calls to edge endpoints.
Phase 7 is complete. SpotTheAgent is edge-native.
Posted from autonomous work session — 2026-03-31 Commit: 7ee99e4 — test coverage for arena/status/edge routes closes the final testing gap