ChessCopilot Sign in

ChessCopilot · web app

How ChessCopilot is put together

One small server runs the whole thing: a Node.js app, its PostgreSQL database and an HTTPS front door, in Docker on a Google Cloud VM. The chess itself — the board, the engine, the grading — runs in the player's browser. The server keeps accounts and history, and holds the one secret the browser must never see: the AI coach's key.

Site chesscopilot.duckdns.org Server Google Cloud e2-micro, Ubuntu 24.04 Stack Node.js 22 · Fastify · PostgreSQL 16 · Caddy 2 Runs as Docker Compose

What runs where

DuckDNS name → VM's IP address Player's browser board, grading, review Stockfish (WebAssembly) service worker (offline files) outbox for unsent saves session cookie, no secrets looks up the name Google Cloud VM · Docker Compose Caddy HTTPS, :80 :443 Let's Encrypt HTTPS App (Node.js) pages + sessions /api/auth, /api/admin /api/games, /records /api/coach + key email sender reads .env :8080 PostgreSQL volume pgdata SQL Gemini API Google AI Gmail SMTP :465 Google sign-in OAuth + keys adds key sends verifies browser visits Google during "Continue with Google"
Everything the browser does goes through Caddy to the app. Only the app talks to Gemini (adding the key, in orange), Gmail and Google's sign-in checks. The browser leaves the site only for the Google sign-in screen, and comes back with a one-time code the app exchanges itself.
PartRuns onDoesKeeps
Browser appthe player's deviceThe board, Stockfish, move grading, review, insights, the coach's checks.Cached files; unsent saves; the chosen profile.
CaddyVM, container caddyHTTPS on 443, redirects 80, gets and renews the certificate.Certificates (volume caddy_data).
AppVM, container appServes pages, the API, sessions, the coach proxy, email.Nothing on disk; settings from .env.
PostgreSQLVM, container dbStores accounts, sessions, history, usage and the audit log.Everything durable (volume pgdata).
Gemini, Gmail, Google sign-inGoogleCoach answers, outgoing email, identity.Nothing of ours beyond each request.

In the browser

The frontend is the PWA's code, plain JavaScript files in web/ loaded by index.html. Three pieces changed for the web app, and nothing else in the frontend knows the difference:

  • store.js keeps the interface the on-device version had, but calls the server instead of IndexedDB. See Saving history.
  • coach-gemini.js sends coach calls to /api/coach rather than to Google, and asks the server whether the coach is on and how many questions are left today.
  • sw.js, the service worker, caches the app's files for offline play but never touches /api, the sign-in pages or /admin, so nothing personal is left in a browser after signing out.

The sign-in, sign-up, invitation, reset and confirmation pages (web/auth/) are separate, small pages generated from tools/auth-pages.py. The Administration page is web/admin/.

Three pages need no account and are linked from the sign-in page: /about (what the app does, with a screenshot tour), /walkthrough (the in-app guide, drawn by the same guide.js for phone or desktop) and /architecture (this page). They are generated into web/public/ by tools/public-pages.py.

Opening / without a session redirects to /login; the app's files themselves are public, because they hold no data.

The server

A single Node.js process built with Fastify (server/). On start it reads and checks every setting, waits for the database, applies any new migrations, prints a one-time admin link if there are no accounts, sweeps expired sessions and links hourly, and listens on port 8080.

RoutesFileFor
/api/auth/*routes/auth.jsSign in and out, sign-up, invitations, resets, email confirmation, password change.
/api/auth/google/*routes/google.jsSign in with Google.
/api/profiles, /games, /records, /export, /importroutes/data.jsA player's history.
/api/coach/*routes/coach.jsThe Gemini proxy and daily allowance.
/api/admin/*, /adminroutes/admin.jsAccounts, invitations, usage, the audit log.
/, /login, filesapp.jsPages, static files, security headers, the Origin check.

server/cli.js does the same account work from a shell on the server, for when nobody can sign in.

Signing in

With a password

  • Passwords are hashed with Argon2id (19 MiB, 2 passes), at least 10 characters.
  • A session is a random 256-bit token in an httpOnly, SameSite=Lax, Secure cookie named __Host-tciq_session; the database stores only its SHA-256. Sessions last 30 days (SESSION_DAYS) and end on sign-out, password change (other devices), reset, or disabling.
  • Sign-in, sign-up and link attempts are limited to 10 per 15 minutes per address (LOGIN_ATTEMPTS).

Who can get an account

  • Invitation: an administrator makes a link (valid 7 days, once, for one address). The token travels after #, so it never appears in a server log.
  • Open sign-up (SIGNUP=open): anyone can make a player account. With email set up, it stays unusable until the address is confirmed from a link.
  • First administrator: with no accounts at all, the app prints a link for ADMIN_EMAIL at start.

With Google

Authorization-code flow with PKCE. The server keeps the state, nonce and verifier for 10 minutes and binds the state to the browser with a cookie; on return it exchanges the code and verifies the ID token's signature, issuer, audience, expiry and nonce. Then:

  • a Google account already linked to an account signs in;
  • a matching address links to the existing account only when Google proves the mailbox (Gmail, or a Workspace account in the address's domain); otherwise that person signs in with their password;
  • no account: an invitation (or open sign-up) makes one.

Saving history

Move played game record Store.saveGame one write at a time tries PUT /api/games/:id checks the profile is yours upsert games row jsonb, keyed by account server unreachable Outbox localStorage, newest per game sent when back online, or every 30 s
A save never costs a game. If the server can't be reached, the newest version of each game waits in the browser, tagged with the account, and goes when the connection returns. Writes go one at a time, so an older save can't land after a newer one.
  • Profiles, games and the coach's records are the app's own JSON documents, stored as jsonb in rows whose key begins with the account's id. Every query filters on the signed-in account.
  • Bounds per account: 50 profiles, 5,000 games, 5,000 records of each kind; one save at most 256 KB.
  • Export and import use the PWA's history file unchanged. Import adds under new profile ids and never replaces; files up to 16 MB.

The coach

The coach's reasoning (scenarios, playbooks, tools, validator) runs in the browser and is described in docs/coach-agent-architecture.html. The server's part is the line to Gemini:

  1. The browser sends the same generateContent request it would send to Google, to /api/coach/models/<model>:generateContent, with its session cookie.
  2. The server checks the model is allowed (the Gemini flash family, or COACH_MODELS), keeps only the fields the app builds, and caps output at 4,096 tokens.
  3. It takes one call from the account's allowance for the UTC day (COACH_DAILY_LIMIT, default 200), atomically, so two tabs can't overrun it.
  4. It adds GEMINI_API_KEY in a header, calls Google, and passes the answer back. A call that never reached Google, or that Google failed on its side, is given back; a timeout is not.
The key never leaves the server. It is only in .env on the VM; no response, page or log contains it. Without it, the coach is simply off.

The key is on Google's free plan, so the coach can run out of replies for a while. The server then answers with a reason (service-limit, or daily-limit for one account's allowance), and the app shows players a plain message saying so; that call isn't counted against their day. Everything else keeps working.

Email

Optional, through any SMTP server; this deployment uses Gmail with an app password. With it on:

  • open sign-ups must confirm their address from a link (24 hours); if the email can't be sent, the account isn't kept;
  • Forgot password? emails a reset link (24 hours) and answers the same whether or not the address has an account;
  • administrators can email invitations and reset links; the link is shown either way.

Without email, everything else works, and resets are links an administrator copies by hand.

Administration

  • Roles: player or administrator. There is always at least one active administrator: demotions and deletions are checked under a database lock, and nobody can disable or delete the account they are using.
  • Accounts: invite, rename, change role, disable (signs out everywhere, keeps games), send a reset link, sign out everywhere, delete (removes everything, confirmed by typing the address).
  • Visibility: coach usage per account, games stored, and an audit log of sign-ins, failed attempts and every account change. Administrators don't see anyone's games or questions in the interface.

The database

PostgreSQL 16, created by the migrations in server/migrations/, applied in order at start and recorded in schema_migrations.

TableHolds
usersEmail, name, Argon2id hash, linked Google account, role, status, confirmed-at.
sessionsHashed session tokens, expiry, last seen, browser and IP.
invites, password_resets, email_verifications, oauth_statesHashed one-time tokens with expiry.
profiles, games, recordsEach account's history, as the app's JSON documents.
coach_usageCoach calls per account per day.
audit_logWho did what to which account, and from where.

Migrations so far: 001_init (the schema), 002_oauth (Google sign-in), 003_email (confirmation and emailed resets).

Security

AgainstHow
Stolen database copyPasswords hashed with Argon2id; every token stored only as a hash.
Cross-site requestsEvery change must carry the site's own Origin; cookies are SameSite=Lax.
Script injectionStrict Content Security Policy: no inline scripts, nothing from other sites; user text written as text.
Password guessingAttempt limits per address; Caddy is the one trusted proxy hop, so clients can't fake their IP.
One account reaching anotherEvery history query scoped to the session's account; admin routes require the role.
Account takeover through GooglePKCE, nonce and state; linking by address only for addresses Google proves.
Leaked secretsKeys and passwords only in the server's .env (mode 600); tokens in links sit after #.
A runaway coach billModel allowlist, request allowlist, output cap, daily allowance per account.
Oversized dataBody limits (2 MB, 256 KB per save, 16 MB import) and per-account caps.
Personal data left in a browserThe service worker never caches accounts, history or sign-in pages.

Deployment

Your Mac docker buildx image for linux/amd64 save | ssh VM · /srv/chesscopilot docker load running → :previous compose up migrations run live :latest rollback.sh swaps :latest and :previous Untouched by both: .env · the database volume
First the live database is backed up to the Mac. The image is built there too, because the 1 GB server runs out of memory installing dependencies. The version being replaced is kept, so rolling back is a swap, not a rebuild. Rollback changes code only; a data problem is a backup restore.
PieceSetup
ServerGoogle Cloud e2-micro, us-central1, Ubuntu 24.04 LTS, 30 GB standard disk, 2 GB swap. Prepared by scripts/server-setup.sh.
Name and IPchesscopilot.duckdns.org → the VM's external IP. Stopping and starting the VM changes the IP; DuckDNS must then be updated.
FirewallGoogle Cloud allows HTTP and HTTPS; the VM allows 22, 80 and 443. PostgreSQL and the app are reachable only inside Docker.
ContainersCompose project thinkchessiq: app, db, caddy, all restarting unless stopped.
Settings/srv/chesscopilot/.env, only on the server. Changing one needs docker compose up -d, not a deploy.
BackupsOn the Mac, never the server: scripts/backup-to-laptop.sh streams the database into ~/chesscopilot-backups (newest 30 kept), and every deploy runs it first. scripts/restore-from-laptop.sh puts one back.
UpdatesUbuntu security updates install automatically; the certificate renews automatically; code updates follow docs/update-guide.html.

For running it on a laptop, compose.local.yml replaces Caddy with port 8080 on localhost and sends every email to Mailpit (localhost:8025).

Where the code is

PathWhat
web/The browser app (shared ancestry with the PWA in chess-coach/src/, now two copies).
web/auth/, tools/auth-pages.pySign-in pages and their generator.
web/admin/The Administration page.
server/The Node.js server: app.js, config.js, sessions.js, security.js, accounts.js, mail.js, routes/, cli.js.
server/migrations/Database schema, in order.
test/Server tests against a throwaway PostgreSQL (npm test).
bench/The app's own checks: engine, coach, insights, guide (npm run gate).
scripts/Server setup, deploy, rollback, backup, restore, test database.
Dockerfile, docker-compose.yml, compose.local.yml, CaddyfileHow it runs.
docs/This page, the update guide, and the coach's design.

Known limits

  • One server. No redundancy: if the VM is down, the site is down (games in progress keep their moves in the browser and save when it returns).
  • Backups happen only at a deploy or by hand, onto the Mac: there is no schedule, and they are only as safe as the laptop unless copied elsewhere.
  • No monitoring or alerts. Problems are found by using the site or reading docker compose logs.
  • Sessions don't expire when idle, only after 30 days or on sign-out.
  • Export builds the whole history in memory; fine at the per-account caps above.
  • No phone verification; email confirmation needs email to be on.
  • Two copies of the frontend: a change in web/ doesn't reach the Netlify PWA.