fuzzyworld

word-waltz / files / src / firebase / gameService.ts

gameService.ts

3.0 KB · history · blame

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 *
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 *
16 * None of these functions know about encryption — that is strictly the
17 * responsibility of the layer above (main.ts / route handlers).
18 */
19
20import { doc, getDoc, setDoc, updateDoc, deleteDoc, onSnapshot } from 'firebase/firestore';
21import { db } from './config.js';
22import type { Encrypted, EncryptedEnvelope, PendingNotification, PlayerId } from '../types.js';
23
24/** Create a new game document. */
25export async function createGame(gameId: string, encrypted: Encrypted): Promise<void> {
26 const envelope: EncryptedEnvelope = { ...encrypted, lastPlayed: Date.now() };
27 await setDoc(doc(db, 'games', gameId), envelope);
28}
29
30/** Read a game document once. Returns null if it does not exist. */
31export async function getGame(gameId: string): Promise<EncryptedEnvelope | null> {
32 const snap = await getDoc(doc(db, 'games', gameId));
33 if (!snap.exists()) return null;
34 return snap.data() as EncryptedEnvelope;
35}
36
37/**
38 * Overwrite the game document with new encrypted state.
39 * Always updates lastPlayed to the current time.
40 * Optionally writes a pendingNotification for the Cloud Function to consume.
41 */
42export 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.
58 */
59export 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 });
67}
68
69/** Subscribe to real-time updates for a game document. */
70export function subscribeToGame(
71 gameId: string,
72 onUpdate: (envelope: EncryptedEnvelope) => void,
73): () => void {
74 return onSnapshot(doc(db, 'games', gameId), (snap) => {
75 if (snap.exists()) onUpdate(snap.data() as EncryptedEnvelope);
76 });
77}
78
79/** Permanently delete a game document from Firestore. */
80export async function deleteGame(gameId: string): Promise<void> {
81 await deleteDoc(doc(db, 'games', gameId));
82}
83