You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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)
Auth + Deployment Roadmap for LiveCode
Context
LiveCode is currently a real-time collaborative Markdown editor with zero authentication and zero deployment infrastructure. Investigation confirmed:
/backend(Rust/Axum + Postgres via sqlx, only adocumentstable 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, hardcodedhttp://localhost:3000/ws://localhost:1234).frontend/src/lib/presence.ts+frontend/src/hooks/useYjsEditor.tsauto-assign a random color and a "User N" label per WebSocket connection via Yjs Awareness. Nothing is persisted, nothing links to a real identity..envfiles, no Dockerfile/docker-compose, no deploy CI (onlynpm auditruns in CI, and it skips the Rust backend). CORS inbackend/src/main.rsis wide open (Any/Any/Any).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
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 theirCache-Controlheader, and validate the RS256 signature plusiss(https://securetoken.google.com/<project-id>),aud(<project-id>), andexp. The Nodeysocketrelay can instead use the officialfirebase-adminnpm package'sverifyIdToken(), which is simpler and worth using there even though the backend can't.Original comparison tables (for reference)
Compute hosting (Rust/Axum backend + Node
ysocketrelay)Database hosting (Postgres)
Auth provider / strategy
oauth2+argon2/bcrypt+jsonwebtokencrates)firebase-adminNode packageFrontend hosting (static Vite build)
Phase 1 — Backend data model + auth foundations
Add a
userstable migration (newmigrations/0002_create_users.sql, following the pattern of0001_create_documents.sql) keyed onfirebase_uid TEXT UNIQUE(Firebase's stable per-user identifier — not a UUID we generate), plusemail,display_name,photo_urlcolumns synced from ID token claims on first sign-in. Add a nullabledocuments.owner_idreferencingusers.id(backward-compatible with existing anonymous docs).Add Firebase verification config to
AppState(currently just{ db: Db }inbackend/src/main.rs):firebase_project_idplus a cached JWKS client (fetch + cache Google's public certs, respecting theirCache-Controlmax-age, since there's no official Rust Admin SDK to do this for us). Add anAuthUserextractor (impl FromRequestParts) following the extractor style already used inbackend/src/controllers/documents.rsthat validates theAuthorization: Bearer <firebase-id-token>header — checks RS256 signature,iss,aud,exp— and upserts/looks up the correspondingusersrow byfirebase_uid. Decide whichdocumentsroutes stay public vs become owner-gated. No UI yet — this just makes the backend capable of recognizing an authenticated request.Validation
cargo testpasses; migration applies cleanly on a fresh DB and is idempotent on re-rundocuments.owner_idis nullable and pre-existing anonymous documents still load unmodifiedfirebase_uidhas a realUNIQUEconstraint (attempt a duplicate insert and confirm it's rejected at the DB level, not just app logic)aud/iss(e.g. a token from a throwaway second Firebase project) is rejected — confirms the extractor checks claims, not just signature validityPhase 2 — Frontend auth UI + auth state
Add the
firebasenpm package and a Firebase app/auth client init (using theVITE_FIREBASE_*env vars from Phase 0). Add login/signup routes alongsidefrontend/src/routes/index.tsx/doc.$id.tsxusingcreateUserWithEmailAndPassword/signInWithEmailAndPassword. Add app-wide auth/session state via Firebase'sonAuthStateChangedlistener, and havefrontend/src/lib/api.tsand the WS connection callgetIdToken()to attach a fresh token to requests. Email/password first pass only — no social login yet.Validation
VITE_FIREBASE_*values are confirmed public-safe (Firebase web config is meant to be client-visible; double-check no server-only secret ever gets aVITE_prefix by mistake)Authorization: Bearerheader, never as a query param or in request logs, for regular HTTP callsPhase 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
signInWithPopupwithGoogleAuthProvider/GithubAuthProvider— the redirect/callback flow itself is fully delegated to Firebase, so app-side scope stays small.Validation
usersrow correctlyusersrow correctlyusersrows for one person)Phase 4 — Real identity in rooms/presence + document ownership
Replace
assignUserName/randomColorinfrontend/src/lib/presence.tswith the authenticated user's realdisplayName/photoURLseeded into Yjs Awareness. Thread the Firebase ID token through the WS upgrade inysocket/server.js(passed as a query param, since raw upgrades can't carry custom headers) and verify it in the existingserver.on('upgrade', ...)handler using the officialfirebase-adminpackage'sverifyIdToken()(simpler than the Rust side since an official SDK exists here). Start enforcingdocuments.owner_idon relevant backend routes, and decide whether anonymous/unauthenticated viewing remains allowed.Validation
ysocketrejects the WS upgrade (before any Yjs sync data is exchanged) when the token query param is missing, malformed, or expiredysocketaccess logs or any reverse proxy / Fly.io log in a way that persists it in plaintext (check log output, redact if needed)ysocketWS upgrade — not just one of the twoPhase 5 — Containerization + env config plumbing
Add a
Dockerfilefor the Rust backend and one forysocket, both Fly.io-compatible (small image, single exposed port, respectsPORT/fly.tomlconventions). 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 currentAny/Any/Any),ysocket/server.js(FIREBASE_PROJECT_ID),frontend/src/lib/api.ts(VITE_BACKEND_URLinstead of hardcodedhttp://localhost:3000),frontend/src/routes/doc.$id.tsx(VITE_WS_URLinstead of hardcodedws://localhost:1234), plus theVITE_FIREBASE_*client config vars from Phase 0. Commit.env.examplefiles. No infra provisioning yet — just making everything a deployable artifact.Validation
docker buildsucceeds for bothbackendandysocketdocker compose upbrings up all services locally talking to each other purely via env vars — no hardcodedlocalhostfallback left anywhere in source.envis gitignored;.env.examplecontains only placeholder values, never a real secretdocker history <image>(or equivalent layer inspection) shows no secret baked into an image layerAny) if the env var is unsetPhase 6 — Infra provisioning
fly launchforbackendandysocket(separate apps, each with its ownfly.toml), set secrets viafly secrets set.DATABASE_URL.VITE_FIREBASE_*.frontend/as root, Vite build output dir), setVITE_*build env vars in the Pages dashboard.Wire real secrets/URLs into each service's runtime config.
Validation
DATABASE_URL, etc.) are set viafly secrets set, never committed tofly.tomlor the reposslmode=require(or Neon's default TLS) — confirm the app actually connects over TLS, not silently falling back to plaintextPhase 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 deployforbackend/ysocket(using aFLY_API_TOKENrepo secret), and either Cloudflare Pages' native git integration (auto-deploy on push, no CI step needed) or an explicitwrangler pages deploystep if build-time secrets need to come from GitHub Actions instead of the Pages dashboard.Validation
FLY_API_TOKENand any other deploy credentials live in GitHub Actions secrets, never in workflow YAMLcargo auditandnpm auditare wired to actually fail the build on high/critical findings, not just print a reportmain, not on pull_request events from forks (which would otherwise expose secrets to untrusted PR code)permissions:are scoped down explicitly rather than left at the broad defaultGITHUB_TOKENpermissionsmaintriggers 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://towss:///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'srevokeRefreshTokens+ checkauth_timeagainst it server-side if that level of enforcement is needed. Add standard security headers.Validation
curlwith a forgedOriginheader from a non-allow-listed domain is rejected by CORShttp:///ws://requests against the production hosts are rejected or redirected —wss:///https://enforced end-to-endX-Content-Type-Options: nosniff,X-Frame-Options/frame-ancestors,Referrer-PolicyrevokeRefreshTokensfor 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 theoreticalcargo audit/npm auditclean with no unresolved high/critical at time of hardening sign-offgrepthe built JS for anything that looks like a connection string or private key)