utils/firebase
Krzysztof Rudnicki 02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00
..
database.rules.json crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam 2026-08-03 18:14:25 +02:00
README.md crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam 2026-08-03 18:14:25 +02:00

firebase/

Deployable configuration for the Firebase project backing crdt_sync / crdt_sync_dart sync. Language-neutral, so it lives here rather than under either library.

This repository is public. Nothing here names a concrete project, database URL, account or uid. Those live in a local, 0600 config file — see Local configuration.

Why Realtime Database, over REST

  • No SDK. Official FlutterFire has no Linux desktop support, and one caller (wake_alarm's PC side) is a dependency-free systemd job. The REST endpoints are plain HTTPS and behave identically on Linux, Android and headless.
  • No per-operation quota on the Spark (no-cost) plan. RTDB bills only storage and bandwidth, so a misbehaving sync loop cannot exhaust a daily budget and silently stop working mid-day. Firestore's 20k-writes/day cap would be a second way to hit a wall, for querying this workload never does.
  • Path model matches the existing layout — <prefix>/<deviceId>/<file> — so no data remodelling.

Deliberately not used: Cloud Storage (requires a linked billing account since 2026‑02‑03) and Cloud Functions. Avoiding both keeps the project structurally incapable of generating a bill.

database.rules.json

Scopes the whole database to a single Firebase Auth uid.

auth != null alone would not be safe: email/password sign-up is open through the identitytoolkit REST API to anyone holding the Web API key, and that key ships inside the Android APKs. Pinning the uid means a stranger who registers an account still reads and writes nothing.

To deploy, substitute the real uid (Firebase console → Authentication → Users → User UID) and paste into Realtime Database → Rules → Publish.

Verify the rules are live — an unauthenticated request must be refused:

curl -s "$DATABASE_URL/.json"
# {"error" : "Permission denied"}   <- expected

Local configuration

Everything environment-specific lives in ~/.config/crdt-sync/, mode 0600, outside version control:

File Contents
firebase.json apiKey, databaseUrl, projectId, uid, email
password the sync account's password, no trailing newline
database.rules.json the deployed rules, with the real uid filled in

The Web API key is not a secret — it is a public project identifier that ships inside client apps. The password and refresh tokens are the real secrets.

Credentials at runtime

No service-account key is used anywhere: it would bypass the security rules entirely and is trivially extractable from an installed APK. Devices sign in as the single user and hold a refresh token:

  • Android — flutter_secure_storage
  • Linux — ~/.config/<app>/firebase.json, mode 0600

ID tokens last about an hour, so the refresh exchange is mandatory rather than optional — diet_guard's PC timer alone runs 96 times a day.