Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Spanglish — Language Exchange Event Platform
A full-stack web app for organizing and managing language exchange events (Asunción, Paraguay).
Features
- Public site: events, booking, contact, community, photo galleries, legal pages, bilingual (EN/ES)
- Stable
/nextand/featuredURLs that redirect to the current event - Human-readable event URL slugs (with legacy-ID redirect support)
- Stable
- User dashboard: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- Admin (
/admin): events, tickets/check-in, users/roles, payments, email templates, media uploads, photo galleries- One unified ticket-creation modal with first-class payment status
- Payments: one automatic provider (Lightning via LNbits) plus manual providers (TPago link, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- Photo galleries (standalone
photo-apiGo service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). Seephoto-api/. - API: Swagger UI at
/api-docs, OpenAPI JSON at/openapi.json, health check at/health
Tech stack
- Backend: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- Photo service: standalone Go module (
photo-api/), its own binary/deploy unit, sharing the backend database andJWT_SECRET - Auth: Better Auth — httpOnly cookie sessions (DB-validated on every request for instant revocation), Argon2id password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
- Email:
nodemailer(SMTP) with optional provider config - Frontend: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
Local development
Prerequisites
- Node.js 18+
- npm
- Go 1.26+ (for the
photo-apiservice) - Optional:
libvips-tools(orlibheif-examples) on the host to accept HEIC photo uploads
Setup
npm install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
Initialize database (SQLite by default)
npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
Run
npm run dev
npm run dev starts the backend, frontend, and photo-api together (via concurrently).
Default URLs:
- Frontend:
http://localhost:3002 - Backend API:
http://localhost:3001 - Photo API:
http://localhost:3003(the Next dev server rewrites/api/photos/*to it) - API docs:
http://localhost:3001/api-docs
First user becomes admin
The first user to register becomes the admin. Register at /register.
Useful scripts (workspace)
Run these from the repo root:
npm run dev # backend + frontend + photo-api
npm run build # build all workspaces
npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
You can also run per workspace:
npm run dev --workspace=backend
npm run dev --workspace=frontend
Environment variables
Backend (backend/.env)
Key settings (see backend/.env.example for the full list):
- DB:
DB_TYPE=sqlite|postgres,DATABASE_URL=./data/spanglish.db(or Postgres URL) - Auth:
BETTER_AUTH_SECRET(required in production, 32+ chars),BETTER_AUTH_URL(public site origin; falls back toFRONTEND_URL), optionalGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET - URLs/ports:
PORT,API_URL,FRONTEND_URL - Email:
EMAIL_PROVIDER(console|smtp|resend) and corresponding credentials - Payments (optional): LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- Scaling (optional):
REDIS_URL,DB_POOL_MAX, andS3_*(see "Horizontal scaling" below)
Photo service (photo-api/.env)
Key settings (see photo-api/.env.example):
- Port:
PORT=3003(dev) - DB:
DB_TYPEandDATABASE_URL— point at the same database as the backend - Auth: none needed for user auth — the service validates Better Auth session cookies against the shared database.
PHOTO_VIEW_SECRETsigns gallery image view tokens (falls back toJWT_SECRETduring migration). - Storage:
STORAGE_PATH(local disk) orS3_ENDPOINT+S3_BUCKET(S3/Garage/MinIO); S3 downloads use short-lived presigned URLs - Uploads/worker:
MAX_UPLOAD_MB,WORKER_CONCURRENCY(a worker generates thumb/preview JPEG variants with EXIF stripped)
Frontend (frontend/.env)
Key settings (see frontend/.env.example):
- Server port:
PORT=3002 - API base URL:
NEXT_PUBLIC_API_URL(optional)- Leave empty to use same-origin
/api(recommended when running behind nginx) - In local dev, Next.js rewrites
/api/*and/uploads/*to the backend
- Leave empty to use same-origin
- Social links (optional):
NEXT_PUBLIC_WHATSAPP,NEXT_PUBLIC_INSTAGRAM, etc.
Database
SQLite (default)
- DB file defaults to
backend/data/spanglish.db(viaDATABASE_URL=./data/spanglish.db) - Run migrations with
npm run db:migrate
PostgreSQL
Set in backend/.env:
DB_TYPE=postgres
DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
Then run:
npm run db:migrate
Backups (export / import)
Create backups and restore if needed:
# Export (creates timestamped file in backend/data/backups/)
npm run db:export
# Export to custom path
npm run db:export -- -o ./my-backup.db # SQLite
npm run db:export -- -o ./my-backup.sql # PostgreSQL
# Import (stop the backend server first)
npm run db:import -- ./data/backups/spanglish-2025-03-07-143022.db
npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip confirmation
Note: Stop the backend before importing so the database file is not locked.
Production deployment (nginx + systemd)
This repo includes example configs in deploy/:
- systemd:
deploy/spanglish-backend.service,deploy/spanglish-frontend.service,deploy/spanglish-photos.service- Backend runs on 3018, frontend on 3019, photo-api on 3020 by default (see the unit files)
- Backend needs write access to
backend/dataandbackend/uploads - The photo service runs its own migrations on start (
photo-api migrate) and needs write access to itsSTORAGE_PATH(or S3 config)
- nginx:
deploy/spanglish_upstreams.confdefines upstreams for ports 3018/3019/3020deploy/front-end_nginx.confproxies/api/photosto the photo service,/apiand/uploadsto the backend, and everything else to the frontenddeploy/back-end_nginx.confis a dedicated API vhost example with CORS handling
Typical production flow:
npm ci
npm run build
npm run build:photos
npm run db:migrate
npm run migrate:photos
Then install/enable the systemd services and nginx configs for your server.
Horizontal scaling
The backend can run as a single instance with zero extra configuration (the default), or as multiple replicas behind a load balancer. Scaling support is fully optional and backward compatible: if you set none of the variables below, the app behaves exactly as before with in-memory state and local-disk uploads.
Requirements for multiple instances
- Use PostgreSQL. Set
DB_TYPE=postgres. SQLite is a single local file and cannot be shared safely across instances. - Set
REDIS_URL. This makes the following subsystems shared across instances instead of per process:- distributed cache
- rate limiting (shared sliding/fixed window)
- pub/sub for real-time payment events, so an SSE client connected to one instance still receives an event when the LNbits webhook lands on another
- distributed locks (so only one instance seeds email templates per boot and only one instance polls LNbits per pending ticket)
- the email hourly cap (
MAX_EMAILS_PER_HOUR) becomes a global cap
- Tune the DB pool.
DB_POOL_MAXis the max Postgres connections per instance (default 10). KeepDB_POOL_MAX * replicasbelow the Postgresmax_connectionssetting (default 100). For example, 5 replicas atDB_POOL_MAX=15uses up to 75 connections.
If Redis is configured but becomes unreachable at runtime, each subsystem degrades gracefully (rate limiter fails open, cache misses fall through to the DB, locks proceed) and the API keeps serving rather than crashing.
Uploads across instances
Media uploads default to local disk (./uploads). With more than one instance
you must use shared storage so a file uploaded on one instance is readable on
the others. Two options:
- S3-compatible storage (recommended): set
S3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY(and optionallyS3_PUBLIC_URL,S3_REGION,S3_FORCE_PATH_STYLE). Works with Garage, MinIO, or AWS S3. - Shared volume: mount the same
./uploadsdirectory (e.g. NFS) into every instance.
Real-time payment SSE behind a load balancer
The payment status stream (/api/lnbits/stream/:ticketId) is a long-lived SSE
connection. With Redis pub/sub enabled, any instance can deliver the payment
event regardless of which instance holds the socket, so sticky sessions are not
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
a reasonable optimization.
Health and observability
GET /health always returns 200 and reports Redis connectivity and which
backend each subsystem selected, for example:
{
"status": "ok",
"redis": { "enabled": true, "healthy": true },
"backends": {
"cache": "redis",
"rateLimiter": "redis",
"pubsub": "redis",
"lock": "redis",
"storage": "s3"
}
}
The same selection is logged once at startup.
docker-compose example (N replicas + Redis)
A ready-to-edit snippet lives at deploy/docker-compose.scale.yml. It runs
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
up with:
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
Documentation
- Specs / notes:
about/ - Legal docs:
frontend/legal/
License
MIT