···11+# Copy to .dev.vars for local development. Never commit .dev.vars.
22+33+# Google OAuth web client (for Anita's login) — console.cloud.google.com → Credentials
44+GOOGLE_OAUTH_CLIENT_ID=xxx.apps.googleusercontent.com
55+GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxx
66+77+# Google Maps Platform key with Places API (New) + Geocoding API enabled
88+GOOGLE_MAPS_API_KEY=AIzaxxx
99+1010+# Any long random string; gates the one-time passkey enrollment page (/setup)
1111+SETUP_SECRET=pick-a-long-random-string
1212+1313+# Local-only convenience: enables password login on localhost as either user.
1414+# NEVER set this as a production secret (it's also hostname-gated, but still).
1515+DEV_PASSWORD=dev
···11+# aniverse
22+33+A private, Instagram-style photo feed for exactly two people — Anirudh and Anita. Multi-photo posts with captions, locations per photo or per post (Google Places autocomplete, pre-filled from EXIF GPS), comments, emoji reactions (double-tap to ❤️), switchable themes (Pastel / Warm / Brutalist / Noir, in the avatar menu), full-res originals kept forever.
44+55+**Stack:** one Cloudflare Worker serving a React SPA (Vite) + Hono JSON API · D1 (data) · R2 (photo originals + cached variants) · Images binding (resizing) · Google login (Anita) · passkeys (Anirudh) · installable PWA.
66+77+## Local development
88+99+```sh
1010+nix develop # node 22 + resvg (or use direnv: `direnv allow`)
1111+npm install
1212+cp .dev.vars.example .dev.vars # defaults work; Google keys optional locally
1313+npm run db:migrate:local
1414+npm run dev
1515+```
1616+1717+Then:
1818+1919+- **Easiest login:** with `DEV_PASSWORD=dev` in `.dev.vars`, the login page grows a "local dev" box — type the password and enter as either Anirudh or Anita. (Only rendered when the endpoint is enabled, and the endpoint only works on localhost.)
2020+- **Passkey login:** visit `http://localhost:5173/setup?key=dev-setup-secret` once to enroll, after that use "Sign in with passkey" on `/login`.
2121+- **Google login:** needs real OAuth credentials in `.dev.vars` (add `http://localhost:5173/api/auth/google/callback` as an authorized redirect URI on the client).
2222+- **Location autocomplete:** needs `GOOGLE_MAPS_API_KEY` in `.dev.vars`; without it the app works, the pickers just say "search unavailable".
2323+2424+## Deploying (one-time checklist)
2525+2626+1. **Cloudflare resources**
2727+ ```sh
2828+ npx wrangler login
2929+ npx wrangler d1 create aniverse # paste database_id into wrangler.jsonc
3030+ npx wrangler r2 bucket create aniverse-photos
3131+ npm run db:migrate:remote
3232+ ```
3333+2. **Fix the allowlist** — in `wrangler.jsonc`, replace `CHANGE_ME_anita@gmail.com` with Anita's real Google email.
3434+3. **Google Cloud console** (console.cloud.google.com, one project for both):
3535+ - *OAuth consent screen*: External, stay in **Testing** mode, add both of your emails as test users (no verification needed for 2 users).
3636+ - *Credentials → OAuth client (Web)*: authorized redirect URI `https://YOUR_DOMAIN/api/auth/google/callback`. Copy client ID + secret.
3737+ - *Enable APIs*: **Places API (New)** and **Geocoding API**. Create an **API key** restricted to those two. (Requires a billing account; usage at 2-person scale sits inside the free tier.)
3838+4. **Secrets**
3939+ ```sh
4040+ npx wrangler secret put GOOGLE_OAUTH_CLIENT_ID
4141+ npx wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET
4242+ npx wrangler secret put GOOGLE_MAPS_API_KEY
4343+ npx wrangler secret put SETUP_SECRET # any long random string
4444+ ```
4545+5. **Deploy**
4646+ ```sh
4747+ npm run deploy
4848+ ```
4949+6. **Domain first, then passkeys.** Passkeys are cryptographically bound to the hostname — decide the final domain (custom domain or the workers.dev URL) **before** enrolling. Then visit `https://YOUR_DOMAIN/setup?key=<SETUP_SECRET>` on each of Anirudh's devices (Face ID / Touch ID). Anita just taps "Continue with Google".
5050+7. **Phones:** open the site in Safari → Share → **Add to Home Screen**. It runs standalone like an app (the future native iOS app will reuse this same `/api`).
5151+5252+## Costs
5353+5454+Everything runs on Cloudflare's free tier: R2 has no egress fees, image transformations are cached back into R2 so each photo variant is generated exactly once (well inside the 5,000/month free transformations), and static assets are free. The only external dependency is Google Maps Platform (billing account required, but per-SKU free tiers cover this usage).
5555+5656+## Local-first
5757+5858+- The TanStack Query cache persists to localStorage, so the app paints the last-seen feed instantly on launch and revalidates in the background.
5959+- A service worker (production builds only — `npm run build && npx vite preview` to test) precaches the app shell, caches photos cache-first (they're immutable per URL), and falls back to the cached feed when offline. An "offline — showing saved memories" chip appears when disconnected.
6060+- Deleting a post is optimistic: it vanishes immediately and reappears if the server call fails.
6161+6262+## Notes
6363+6464+- The whole app is private: every `/api/*` and `/img/*` route requires a session; only allowlisted emails can ever create one.
6565+- Originals are stored untouched in R2 under `originals/<photoId>` (grab one at `/img/<photoId>/original`); feed/thumb/full variants are lazily generated webp.
6666+- Post timestamps are unix **milliseconds** in the DB (cursor tie-breaking); the API returns seconds.
6767+- EXIF orientation is handled: display dimensions are stored orientation-corrected, production Cloudflare Images auto-orients pixels, and the sharp-based local simulator (which doesn't) gets an explicit rotation compensation that only activates on localhost.
···11+-- Migration number: 0001 init
22+CREATE TABLE users (
33+ id TEXT PRIMARY KEY,
44+ email TEXT NOT NULL UNIQUE,
55+ name TEXT NOT NULL,
66+ avatar_url TEXT,
77+ google_sub TEXT UNIQUE,
88+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
99+);
1010+1111+CREATE TABLE webauthn_credentials (
1212+ id TEXT PRIMARY KEY, -- credential id, base64url
1313+ user_id TEXT NOT NULL REFERENCES users(id),
1414+ public_key TEXT NOT NULL, -- base64url-encoded COSE public key
1515+ counter INTEGER NOT NULL DEFAULT 0,
1616+ transports TEXT, -- JSON array
1717+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
1818+);
1919+2020+CREATE TABLE webauthn_challenges (
2121+ id TEXT PRIMARY KEY,
2222+ challenge TEXT NOT NULL,
2323+ kind TEXT NOT NULL CHECK (kind IN ('register', 'auth')),
2424+ user_id TEXT,
2525+ expires_at INTEGER NOT NULL
2626+);
2727+2828+CREATE TABLE sessions (
2929+ token_hash TEXT PRIMARY KEY, -- sha256 hex of the bearer token
3030+ user_id TEXT NOT NULL REFERENCES users(id),
3131+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
3232+ expires_at INTEGER NOT NULL
3333+);
3434+CREATE INDEX idx_sessions_expires ON sessions(expires_at);
3535+3636+CREATE TABLE locations (
3737+ id TEXT PRIMARY KEY,
3838+ google_place_id TEXT NOT NULL UNIQUE,
3939+ name TEXT NOT NULL,
4040+ address TEXT,
4141+ lat REAL,
4242+ lng REAL,
4343+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
4444+);
4545+4646+CREATE TABLE posts (
4747+ id TEXT PRIMARY KEY,
4848+ author_id TEXT NOT NULL REFERENCES users(id),
4949+ caption TEXT NOT NULL DEFAULT '',
5050+ location_id TEXT REFERENCES locations(id),
5151+ -- unix MILLISECONDS (cursor tie-breaking); other tables use seconds
5252+ created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
5353+);
5454+CREATE INDEX idx_posts_feed ON posts(created_at DESC, id DESC);
5555+5656+CREATE TABLE photos (
5757+ id TEXT PRIMARY KEY,
5858+ post_id TEXT REFERENCES posts(id), -- NULL until attached to a post
5959+ uploader_id TEXT NOT NULL REFERENCES users(id),
6060+ position INTEGER NOT NULL DEFAULT 0,
6161+ r2_key TEXT NOT NULL,
6262+ filename TEXT,
6363+ mime TEXT NOT NULL,
6464+ width INTEGER,
6565+ height INTEGER,
6666+ taken_at INTEGER,
6767+ gps_lat REAL,
6868+ gps_lng REAL,
6969+ location_id TEXT REFERENCES locations(id), -- per-photo location (overrides post's)
7070+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
7171+);
7272+CREATE INDEX idx_photos_post ON photos(post_id, position);
···11+-- Migration number: 0003 comments and reactions
22+CREATE TABLE comments (
33+ id TEXT PRIMARY KEY,
44+ post_id TEXT NOT NULL REFERENCES posts(id),
55+ author_id TEXT NOT NULL REFERENCES users(id),
66+ body TEXT NOT NULL,
77+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
88+);
99+CREATE INDEX idx_comments_post ON comments(post_id, created_at);
1010+1111+CREATE TABLE reactions (
1212+ post_id TEXT NOT NULL REFERENCES posts(id),
1313+ user_id TEXT NOT NULL REFERENCES users(id),
1414+ emoji TEXT NOT NULL,
1515+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1616+ PRIMARY KEY (post_id, user_id, emoji)
1717+);
···11+// Secrets are not declared in wrangler.jsonc, so `wrangler types` can't see them.
22+// Merged into the generated global Env via declaration merging.
33+interface Env {
44+ GOOGLE_OAUTH_CLIENT_ID: string;
55+ GOOGLE_OAUTH_CLIENT_SECRET: string;
66+ GOOGLE_MAPS_API_KEY: string;
77+ SETUP_SECRET: string;
88+ /** Local dev only (.dev.vars) — enables password login on localhost. Never set in production. */
99+ DEV_PASSWORD?: string;
1010+}