Skip to content

Auth + Deployment Roadmap #2

Description

@NathanTheDev

Auth + Deployment Roadmap for LiveCode

Context

LiveCode is currently a real-time collaborative Markdown editor with zero authentication and zero deployment infrastructure. Investigation confirmed:

  • Three independently-run services: /backend (Rust/Axum + Postgres via sqlx, only a documents table exists), /ysocket (Node.js WebSocket relay for Yjs CRDT sync, no auth on WS upgrade at all), /frontend (React 19 + TanStack + Vite, no env-var usage, hardcoded http://localhost:3000 / ws://localhost:1234).
  • "Users" today are 100% anonymous — frontend/src/lib/presence.ts + frontend/src/hooks/useYjsEditor.ts auto-assign a random color and a "User N" label per WebSocket connection via Yjs Awareness. Nothing is persisted, nothing links to a real identity.
  • No .env files, no Dockerfile/docker-compose, no deploy CI (only npm audit runs in CI, and it skips the Rust backend). CORS in backend/src/main.rs is wide open (Any/Any/Any).
  • Git history shows a prior Firebase-based auth flow that was fully deleted during the pivot to the current Rust backend — reintroducing Firebase Auth below is a deliberate re-adoption, not a resume of that old code.

The goal is to plan the next phase of work: real accounts (email/password + social login) and getting the app deployable.


Phase 0 — External service decisions ✅ DECIDED

Category Choice
Compute hosting Fly.io
Database hosting Neon (Postgres)
Auth provider Firebase Auth
Frontend hosting Cloudflare Pages

Config surface: DATABASE_URL (Neon), FIREBASE_PROJECT_ID (backend + ysocket token verification), VITE_FIREBASE_API_KEY / VITE_FIREBASE_AUTH_DOMAIN / VITE_FIREBASE_PROJECT_ID / VITE_FIREBASE_APP_ID (frontend Firebase SDK config — public, not secret), VITE_BACKEND_URL, VITE_WS_URL.

Note on Firebase Auth specifically: there's no official Firebase Admin SDK for Rust, so the Axum backend verifies ID tokens by hand — fetch Google's public signing certs (https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com), cache per their Cache-Control header, and validate the RS256 signature plus iss (https://securetoken.google.com/<project-id>), aud (<project-id>), and exp. The Node ysocket relay can instead use the official firebase-admin npm package's verifyIdToken(), which is simpler and worth using there even though the backend can't.

Original comparison tables (for reference)

Compute hosting (Rust/Axum backend + Node ysocket relay)

Option Cost Ease Notes
Fly.io (chosen) Small always-on VMs, cheap but not strictly free once always-on High — Docker-native, git/CLI deploy, managed TLS Avoids the "sleeps after idle" problem that free Render/Railway tiers have, which matters for a WS relay holding open connections
Render Free tier exists, but free web services spin down after 15 min idle High Idle spin-down is a real problem for the WS relay specifically
Self-hosted VPS (Hetzner / Oracle Always Free / DigitalOcean) $0 (Oracle) to ~$5/mo Low — you own OS, TLS, process supervision, firewall Cheapest long-run, most ops burden; both services fit easily on one box
AWS (ECS/Fargate/EC2/App Runner) Free tier doesn't really cover this well; complex billing Low Overkill for a personal project at this scale

Database hosting (Postgres)

Option Cost Ease Notes
Neon (chosen) Free tier, scale-to-zero (cold-start blip, but wakes automatically) Very high — hosted, simple connection string Better fit than Supabase for low-traffic apps since it doesn't require manually unpausing
Supabase Postgres Free tier, but pauses after ~1 week of inactivity and requires manual unpause via dashboard Very high Real availability risk for a low-traffic personal project
Self-hosted on the same VPS $0 marginal Low Couples DB uptime to app server uptime — no separation of concerns

Auth provider / strategy

Option Cost Ease Notes
Supabase Auth Free tier, 50k MAU High — hosted signup/login + Google/GitHub OAuth pre-wired Smallest, most auditable integration surface of the hosted options
Roll-your-own (Axum + oauth2 + argon2/bcrypt + jsonwebtoken crates) $0 Low — real work: password hashing, JWT issuance/refresh, OAuth callback + CSRF/state handling Full control and learning value, but security-sensitive code with real footguns for a solo dev already juggling 3 services
Firebase Auth (chosen) Free tier, generous High — hosted signup/login, client SDKs, Google/GitHub OAuth pre-wired No official Rust Admin SDK, so the backend hand-verifies ID tokens against Google's JWKS instead of using a library; ysocket can use the official firebase-admin Node package
Clerk Free tier, 10k MAU High Viable, but skews toward JS/Next.js-first DX

Frontend hosting (static Vite build)

Option Cost Ease Notes
Vercel Free tier Very high Best-in-class DX, preview deploys
Cloudflare Pages (chosen) Free, unlimited bandwidth High Slightly better long-term cost profile (no usage caps)
Netlify Free tier Very high Comparable to Vercel

Phase 1 — Backend data model + auth foundations

Add a users table migration (new migrations/0002_create_users.sql, following the pattern of 0001_create_documents.sql) keyed on firebase_uid TEXT UNIQUE (Firebase's stable per-user identifier — not a UUID we generate), plus email, display_name, photo_url columns synced from ID token claims on first sign-in. Add a nullable documents.owner_id referencing users.id (backward-compatible with existing anonymous docs).

Add Firebase verification config to AppState (currently just { db: Db } in backend/src/main.rs): firebase_project_id plus a cached JWKS client (fetch + cache Google's public certs, respecting their Cache-Control max-age, since there's no official Rust Admin SDK to do this for us). Add an AuthUser extractor (impl FromRequestParts) following the extractor style already used in backend/src/controllers/documents.rs that validates the Authorization: Bearer <firebase-id-token> header — checks RS256 signature, iss, aud, exp — and upserts/looks up the corresponding users row by firebase_uid. Decide which documents routes stay public vs become owner-gated. No UI yet — this just makes the backend capable of recognizing an authenticated request.

Validation

  • cargo test passes; migration applies cleanly on a fresh DB and is idempotent on re-run
  • documents.owner_id is nullable and pre-existing anonymous documents still load unmodified
  • firebase_uid has a real UNIQUE constraint (attempt a duplicate insert and confirm it's rejected at the DB level, not just app logic)
  • Manual curl with a valid Firebase ID token succeeds against a protected route
  • Manual curl with no token is rejected on owner-gated routes, still allowed on intentionally-public ones
  • Manual curl with a tampered signature (flip a character in the token) is rejected
  • Manual curl with a token carrying the wrong aud/iss (e.g. a token from a throwaway second Firebase project) is rejected — confirms the extractor checks claims, not just signature validity
  • Manual curl with an expired token is rejected
  • JWKS fetch failure (simulate by blocking the Google metadata endpoint) fails closed — requests are rejected, not silently treated as unauthenticated-but-allowed
  • Every route's public-vs-owner-gated status is intentional and documented, not left at its default

Phase 2 — Frontend auth UI + auth state

Add the firebase npm package and a Firebase app/auth client init (using the VITE_FIREBASE_* env vars from Phase 0). Add login/signup routes alongside frontend/src/routes/index.tsx / doc.$id.tsx using createUserWithEmailAndPassword / signInWithEmailAndPassword. Add app-wide auth/session state via Firebase's onAuthStateChanged listener, and have frontend/src/lib/api.ts and the WS connection call getIdToken() to attach a fresh token to requests. Email/password first pass only — no social login yet.

Validation

  • Sign up, sign out, sign back in works end-to-end in the browser
  • VITE_FIREBASE_* values are confirmed public-safe (Firebase web config is meant to be client-visible; double-check no server-only secret ever gets a VITE_ prefix by mistake)
  • ID token is sent via Authorization: Bearer header, never as a query param or in request logs, for regular HTTP calls
  • Auth error messages don't reveal whether a given email is already registered (check Firebase's default error codes aren't re-exposed verbatim in a way that enables account enumeration)
  • Signing out actually clears local session state — a page reload post-logout does not silently restore an authenticated view
  • Weak-password / malformed-email cases are handled client-side with a clear error, not a raw Firebase exception dumped to the UI

Phase 3 — Social login (Google/GitHub)

In the Firebase console, enable the Google and GitHub sign-in providers (GitHub requires registering a GitHub OAuth App and pasting its client ID/secret into the Firebase console; Google is auto-configured). On the frontend, add "Continue with Google/GitHub" buttons using signInWithPopup with GoogleAuthProvider / GithubAuthProvider — the redirect/callback flow itself is fully delegated to Firebase, so app-side scope stays small.

Validation

  • Google sign-in completes end-to-end and creates/links a users row correctly
  • GitHub sign-in completes end-to-end and creates/links a users row correctly
  • Firebase's Authorized domains list contains only real frontend domains (no wildcard, nothing stale) — this is what prevents the OAuth flow from being abusable via an attacker-controlled redirect
  • GitHub OAuth App's callback URL is scoped to the actual Firebase auth domain, not a placeholder
  • Signing up with email/password, then signing in with Google/GitHub using the same email, behaves intentionally (account linking or a clear rejection — not two silent duplicate users rows for one person)
  • Revoking the app's access from the Google/GitHub account settings page correctly breaks future sign-in attempts

Phase 4 — Real identity in rooms/presence + document ownership

Replace assignUserName/randomColor in frontend/src/lib/presence.ts with the authenticated user's real displayName/photoURL seeded into Yjs Awareness. Thread the Firebase ID token through the WS upgrade in ysocket/server.js (passed as a query param, since raw upgrades can't carry custom headers) and verify it in the existing server.on('upgrade', ...) handler using the official firebase-admin package's verifyIdToken() (simpler than the Rust side since an official SDK exists here). Start enforcing documents.owner_id on relevant backend routes, and decide whether anonymous/unauthenticated viewing remains allowed.

Validation

  • Two browser profiles signed in as different users see each other's real name/avatar in presence, not "User N"
  • ysocket rejects the WS upgrade (before any Yjs sync data is exchanged) when the token query param is missing, malformed, or expired
  • The WS token-in-query-param isn't captured in ysocket access logs or any reverse proxy / Fly.io log in a way that persists it in plaintext (check log output, redact if needed)
  • A token that's valid at connection time but expires mid-session is handled deliberately (reject on next reconnect at minimum; document if long-lived sessions are accepted as a tradeoff)
  • Owner-gated actions (rename/delete) return a consistent, intentionally-chosen status for non-owners (403 vs 404 — pick one and confirm it's applied consistently, since 404 avoids confirming a document's existence to non-owners)
  • The anonymous-viewing decision is enforced identically on both the backend routes and the ysocket WS upgrade — not just one of the two

Phase 5 — Containerization + env config plumbing

Add a Dockerfile for the Rust backend and one for ysocket, both Fly.io-compatible (small image, single exposed port, respects PORT/fly.toml conventions). Replace hardcoded config across all four codebases with env vars: backend/src/main.rs (FIREBASE_PROJECT_ID, DATABASE_URL, CORS allow-list instead of the current Any/Any/Any), ysocket/server.js (FIREBASE_PROJECT_ID), frontend/src/lib/api.ts (VITE_BACKEND_URL instead of hardcoded http://localhost:3000), frontend/src/routes/doc.$id.tsx (VITE_WS_URL instead of hardcoded ws://localhost:1234), plus the VITE_FIREBASE_* client config vars from Phase 0. Commit .env.example files. No infra provisioning yet — just making everything a deployable artifact.

Validation

  • docker build succeeds for both backend and ysocket
  • docker compose up brings up all services locally talking to each other purely via env vars — no hardcoded localhost fallback left anywhere in source
  • .env is gitignored; .env.example contains only placeholder values, never a real secret
  • docker history <image> (or equivalent layer inspection) shows no secret baked into an image layer
  • Both Dockerfiles run the app as a non-root user
  • CORS config is env-driven and defaults to something safe (not Any) if the env var is unset

Phase 6 — Infra provisioning

  • Fly.io: fly launch for backend and ysocket (separate apps, each with its own fly.toml), set secrets via fly secrets set.
  • Neon: create a project, get the pooled connection string for DATABASE_URL.
  • Firebase: create a project, enable Email/Password + Google + GitHub sign-in providers, register the GitHub OAuth App from Phase 3, grab the web app config for VITE_FIREBASE_*.
  • Cloudflare Pages: create a project connected to the frontend build (frontend/ as root, Vite build output dir), set VITE_* build env vars in the Pages dashboard.

Wire real secrets/URLs into each service's runtime config.

Validation

  • All secrets (DATABASE_URL, etc.) are set via fly secrets set, never committed to fly.toml or the repo
  • Neon connection string enforces sslmode=require (or Neon's default TLS) — confirm the app actually connects over TLS, not silently falling back to plaintext
  • Firebase project has only Email/Password, Google, and GitHub providers enabled — no other unused providers left on as extra attack surface
  • Neon database role used by the app is scoped to what it needs (not the project owner/superuser role, if Neon's plan allows creating a scoped role)
  • Cloudflare Pages preview deploys and production deploys use separate env var sets, so a preview build can't leak production secrets
  • Each of the four services is independently reachable at its expected URL (backend health check, ysocket WS handshake, frontend loads, DB accepts a connection)

Phase 7 — CI/CD

Extend .github/workflows/npm-audit.yml (currently frontend+ysocket only, audit-only, doesn't touch Rust, doesn't build or deploy) into a real pipeline: add a Rust job (build/test/cargo audit), build steps for all services, and deploy steps — flyctl deploy for backend/ysocket (using a FLY_API_TOKEN repo secret), and either Cloudflare Pages' native git integration (auto-deploy on push, no CI step needed) or an explicit wrangler pages deploy step if build-time secrets need to come from GitHub Actions instead of the Pages dashboard.

Validation

  • FLY_API_TOKEN and any other deploy credentials live in GitHub Actions secrets, never in workflow YAML
  • cargo audit and npm audit are wired to actually fail the build on high/critical findings, not just print a report
  • Deploy jobs are gated to run only on push to main, not on pull_request events from forks (which would otherwise expose secrets to untrusted PR code)
  • Workflow permissions: are scoped down explicitly rather than left at the broad default GITHUB_TOKEN permissions
  • A push to main triggers a real deploy and the deployed services reflect the change (not just "workflow went green")

Phase 8 — Production hardening

Lock down CORS to the real Cloudflare Pages origin (custom domain if one is added), add rate limiting on auth-adjacent endpoints and document writes, move ws:///http:// to wss:///https:// end-to-end (TLS terminates at Fly.io for the backend/ysocket and at Cloudflare for the frontend — both handle this natively, no cert management needed). Firebase ID tokens are short-lived (1hr) and refreshed automatically by the client SDK; for revocation (e.g. a compromised account) use Firebase's revokeRefreshTokens + check auth_time against it server-side if that level of enforcement is needed. Add standard security headers.

Validation

  • curl with a forged Origin header from a non-allow-listed domain is rejected by CORS
  • Plain http:///ws:// requests against the production hosts are rejected or redirected — wss:///https:// enforced end-to-end
  • Rate limiting confirmed by scripting a burst of requests against an auth endpoint and a document-write endpoint; excess requests are throttled/rejected, not silently accepted
  • Standard security headers present on responses: CSP, X-Content-Type-Options: nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy
  • After calling revokeRefreshTokens for a test user, their existing ID token (until natural expiry) plus any new token acquisition is actually rejected end-to-end — confirms revocation isn't purely theoretical
  • cargo audit / npm audit clean with no unresolved high/critical at time of hardening sign-off
  • A quick pass confirms no secret (Firebase server keys — n/a since none exist, DB URL, Fly tokens) is present in frontend bundle output (grep the built JS for anything that looks like a connection string or private key)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions