word-waltz / files / src / firebase / gameService.ts
gameService.ts
f755bca6 2026-03-23 Pavel Safronov | 1 | /** |
| 2 | * firebase/gameService.ts — Firestore game document CRUD | |
| 3 | * | |
| 4 | * All ciphertext/iv data written to Firestore must be encrypted by the caller | |
| 5 | * before calling these functions. All data read from Firestore must be | |
| 6 | * decrypted by the caller after these functions return. | |
| 7 | * | |
| 8 | * Every write also stores `lastPlayed: Date.now()` as an unencrypted field. | |
| 9 | * This is the ONLY plaintext data in the document and is used solely for | |
| 10 | * automated cleanup of stale games. It reveals no game content. | |
| 11 | * | |
51cb47e7 2026-03-26 Pavel Safronov | 12 | * Push notification fields (pushSubs, pendingNotification) are also plaintext |
| 13 | * and are described in types.ts. They reveal no game content — only that a | |
| 14 | * player is subscribed to notifications and that it is someone's turn. | |
| 15 | * | |
f755bca6 2026-03-23 Pavel Safronov | 16 | * None of these functions know about encryption — that is strictly the |
| 17 | * responsibility of the layer above (main.ts / route handlers). | |
| 18 | */ | |
| 19 | ||
560597e5 2026-03-24 Pavel Safronov | 20 | import { doc, getDoc, setDoc, updateDoc, deleteDoc, onSnapshot } from 'firebase/firestore'; |
f755bca6 2026-03-23 Pavel Safronov | 21 | import { db } from './config.js'; |
51cb47e7 2026-03-26 Pavel Safronov | 22 | import type { Encrypted, EncryptedEnvelope, PendingNotification, PlayerId } from '../types.js'; |
f755bca6 2026-03-23 Pavel Safronov | 23 | |
| 24 | /** Create a new game document. */ | |
| 25 | export async function createGame(gameId: string, encrypted: Encrypted): Promise<void> { | |
1c548168 2026-03-24 Pavel Safronov | 26 | const envelope: EncryptedEnvelope = { ...encrypted, lastPlayed: Date.now() }; |
| 27 | await setDoc(doc(db, 'games', gameId), envelope); | |
f755bca6 2026-03-23 Pavel Safronov | 28 | } |
| 29 | ||
1c548168 2026-03-24 Pavel Safronov | 30 | /** Read a game document once. Returns null if it does not exist. */ |
f755bca6 2026-03-23 Pavel Safronov | 31 | export async function getGame(gameId: string): Promise<EncryptedEnvelope | null> { |
1c548168 2026-03-24 Pavel Safronov | 32 | const snap = await getDoc(doc(db, 'games', gameId)); |
| 33 | if (!snap.exists()) return null; | |
| 34 | return snap.data() as EncryptedEnvelope; | |
f755bca6 2026-03-23 Pavel Safronov | 35 | } |
| 36 | ||
| 37 | /** | |
| 38 | * Overwrite the game document with new encrypted state. | |
| 39 | * Always updates lastPlayed to the current time. | |
51cb47e7 2026-03-26 Pavel Safronov | 40 | * Optionally writes a pendingNotification for the Cloud Function to consume. |
| 41 | */ | |
| 42 | export async function updateGame( | |
| 43 | gameId: string, | |
| 44 | encrypted: Encrypted, | |
| 45 | pendingNotification?: PendingNotification | null, | |
| 46 | ): Promise<void> { | |
| 47 | const update: Partial<EncryptedEnvelope> = { | |
| 48 | ...encrypted, | |
| 49 | lastPlayed: Date.now(), | |
| 50 | pendingNotification: pendingNotification ?? null, | |
| 51 | }; | |
| 52 | await updateDoc(doc(db, 'games', gameId), update); | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Store or clear this player's push subscription JSON in the game document. | |
| 57 | * This is a partial update — it does not touch the encrypted game state. | |
f755bca6 2026-03-23 Pavel Safronov | 58 | */ |
51cb47e7 2026-03-26 Pavel Safronov | 59 | export async function storePushSubscription( |
| 60 | gameId: string, | |
| 61 | player: PlayerId, | |
| 62 | subscriptionJson: string | null, | |
| 63 | ): Promise<void> { | |
| 64 | await updateDoc(doc(db, 'games', gameId), { | |
| 65 | [`pushSubs.${player}`]: subscriptionJson, | |
| 66 | }); | |
f755bca6 2026-03-23 Pavel Safronov | 67 | } |
| 68 | ||
| 69 | /** Subscribe to real-time updates for a game document. */ | |
| 70 | export function subscribeToGame( | |
| 71 | gameId: string, | |
| 72 | onUpdate: (envelope: EncryptedEnvelope) => void, | |
| 73 | ): () => void { | |
1c548168 2026-03-24 Pavel Safronov | 74 | return onSnapshot(doc(db, 'games', gameId), (snap) => { |
| 75 | if (snap.exists()) onUpdate(snap.data() as EncryptedEnvelope); | |
| 76 | }); | |
f755bca6 2026-03-23 Pavel Safronov | 77 | } |
1c548168 2026-03-24 Pavel Safronov | 78 | |
560597e5 2026-03-24 Pavel Safronov | 79 | /** Permanently delete a game document from Firestore. */ |
| 80 | export async function deleteGame(gameId: string): Promise<void> { | |
| 81 | await deleteDoc(doc(db, 'games', gameId)); | |
| 82 | } | |
| 83 |