mnswpr/docs/leaderboard-env-migration.md
Ayo 9e1114be78 feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1)
Extract the leaderboard into a generic, backend-agnostic package and add
rolling Today/Week/Month/All-Time windows, a web component, and local-first
development against the Firestore emulator.

Package (leaderboard/):
- LeaderBoardService: backend-agnostic core via a storage-adapter seam, with
  Firebase and Supabase adapters (Supabase client injected, no added dep)
- Rolling time windows (last 24h / 7d / 30d) with hover tooltips; top-N by score
- <cozy-leaderboard> web component built on web-component-base: compose the UI
  in HTML, configure the backend once in JS
- Every user-facing string is configurable (labels, tooltips, empty/loading/
  error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION

App (mnswpr.com):
- Compose the board declaratively in index.html via <cozy-leaderboard>
- Nickname + randomized greeting bar; score submission through the element
- Legends: the current all-time leaders frozen into a static /legends page
- Firebase config and leaderboard namespace via Vite env vars; emulator-first
  local dev (VITE_FIRESTORE_EMULATOR)

Firebase schema-as-code:
- firebase.json, .firebaserc, firestore.rules (public reads, create-only scores,
  no client updates/deletes, namespace-generalized), empty indexes (rolling
  windows need none)
- prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace
- emulator config, seed script, and docs (firebase-leaderboards.md,
  leaderboard-env-migration.md, AYO.md)

Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage.

Reviewed-on: git.ayo.run:3000/
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 11:41:14 +00:00

6.1 KiB

Migration: env-var-driven prod/test separation

Why

Today the leaderboard has no separation between production and test data. Every environment — a developer running pnpm dev, a Netlify preview, and the live site — reads and writes the same collections (mw-scores, mw-all, mw-config, legacy mw-leaders) in the single Firebase project secure-moment-188701. version/import.meta.env.MODE only affects UI text (lib/mnswpr.js:96); it never changes collection names. So local play pollutes the production leaderboard.

We keep one Firebase project (there is no separate prod project — see docs/firebase-leaderboards.md) and separate data by collection namespace, chosen at build time via an env var:

Environment VITE_LB_NAMESPACE Collections
Production (Netlify) mw mw-scores, mw-all, mw-config
Local dev / previews mw-test mw-test-scores, mw-test-all, mw-test-config

The Firebase web config (VITE_FIREBASE_*) is identical in both — same project — so the only thing distinguishing prod from test is the namespace. Existing production data under mw-* is untouched; mw-test-* starts empty.

Current vs target

Piece Now After
Namespace hardcoded 'mw' in app/main.js import.meta.env.VITE_LB_NAMESPACE
Dev writes into prod mw-* into mw-test-*
Rules only mw-* matches any mw / mw-test namespace
Indexes games collection group unchanged (already covers all namespaces)
.firebaserc REPLACE_WITH_PROD_PROJECT_ID placeholder secure-moment-188701

Steps

1. Add the namespace env var

  • app/.env.development: add VITE_LB_NAMESPACE=mw-test
  • app/.env.example: document VITE_LB_NAMESPACE
  • Netlify (production build): set VITE_LB_NAMESPACE=mw and the same VITE_FIREBASE_* values as dev (same project).

2. Read it in the app

In app/main.js, replace the hardcoded namespace. Default to the test namespace so a missing/misconfigured var can never write to production:

new FirebaseAdapter({
  firebaseConfig,
  namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test'
})

Production must set VITE_LB_NAMESPACE=mw explicitly. (Fail-safe: the worst case of a missing var is an empty test board, never prod pollution.)

3. Generalize the security rules

Rewrite firestore.rules so a rule matches by the namespace suffix instead of a literal mw- prefix. Firestore ORs all matching rules, so one set of generic blocks covers mw, mw-test, and any future namespace. Note: {ns}-scores/{cat}/games/{id}, {ns}-all/{id}/games/{s} and {ns}-leaders/{cat}/games/{id} share the same 4-segment shape, so each block is guarded by a regex on the captured collection name.

rules_version = '2'
service cloud.firestore {
  match /databases/{database}/documents {

    // Ranked, windowed scores — public read, validated create-only.
    match /{scores}/{category}/games/{game} {
      allow read:   if scores.matches('mw(-[a-z]+)?-scores');
      allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
      allow update, delete: if false;
    }

    // Legacy all-time (now frozen into Legends) — read-only.
    match /{leaders}/{category}/games/{game} {
      allow read:  if leaders.matches('mw(-[a-z]+)?-leaders');
      allow write: if false;
    }

    // Per-browser personal archive — permissive, as before.
    match /{all}/{browserId}/games/{session} {
      allow read, write: if all.matches('mw(-[a-z]+)?-all');
    }

    // Server config — public read, no client write.
    match /{config}/{document} {
      allow read:  if config.matches('mw(-[a-z]+)?-config');
      allow write: if false;
    }

    function isValidScore(category) {
      let d = request.resource.data;
      return d.score is number
        && d.score >= 0
        && d.category == category
        && d.name is string
        && d.name.size() <= 24
        && d.day is string
        && d.week is string
        && d.month is string;
    }
  }
}

The regex mw(-[a-z]+)?-scores matches mw-scores and mw-test-scores (and e.g. mw-preview-scores).

4. Indexes — none needed

Time windows are rolling (time_stamp >=) and all-time sorts by score, so the queries use Firestore's automatic single-field indexes. firestore.indexes.json is empty — nothing to add for any namespace.

5. Seed the test config doc

Windowed qualification reads {ns}-config/configuration. Create mw-test-config/configuration once (copy passingStatus and message from the prod mw-config/configuration) via the Console, or a tiny one-off script. If it is absent the default qualifier simply allows all entries — acceptable for test.

6. Point .firebaserc at the real project

Both aliases target the one project we actually have:

{ "projects": { "default": "secure-moment-188701", "prod": "secure-moment-188701", "dev": "secure-moment-188701" } }

(If a separate prod Firebase project is ever created, split these then.)

7. Deploy & verify

  1. npx firebase deploy --only firestore:rules,firestore:indexes --project prod
  2. Set the Netlify env vars (VITE_LB_NAMESPACE=mw + VITE_FIREBASE_*).
  3. pnpm dev, win a game → confirm the write lands in mw-test-scores, and the prod mw-scores is untouched (Firestore console).
  4. Confirm reads on both mw-scores and mw-test-scores succeed under the new rules, and a malformed write is rejected.

Not affected

  • Legends — static, already exported from legacy mw-leaders; no runtime DB.
  • Data migration — none. Prod keeps mw-*; test starts fresh in mw-test-*.
  • The @cozy-games/leaderboard package — already namespace-parameterized; only the app's wiring changes.

Rollback

Set VITE_LB_NAMESPACE=mw everywhere (or revert app/main.js to the hardcoded 'mw') to return to shared collections. The generalized rules are a superset of the old ones, so they stay valid either way.