What runs where
| Part | Runs on | Does | Keeps |
|---|---|---|---|
| Browser app | the player's device | The board, Stockfish, move grading, review, insights, the coach's checks. | Cached files; unsent saves; the chosen profile. |
| Caddy | VM, container caddy | HTTPS on 443, redirects 80, gets and renews the certificate. | Certificates (volume caddy_data). |
| App | VM, container app | Serves pages, the API, sessions, the coach proxy, email. | Nothing on disk; settings from .env. |
| PostgreSQL | VM, container db | Stores accounts, sessions, history, usage and the audit log. | Everything durable (volume pgdata). |
| Gemini, Gmail, Google sign-in | Coach 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.jskeeps the interface the on-device version had, but calls the server instead of IndexedDB. See Saving history.coach-gemini.jssends coach calls to/api/coachrather 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.
| Routes | File | For |
|---|---|---|
/api/auth/* | routes/auth.js | Sign in and out, sign-up, invitations, resets, email confirmation, password change. |
/api/auth/google/* | routes/google.js | Sign in with Google. |
/api/profiles, /games, /records, /export, /import | routes/data.js | A player's history. |
/api/coach/* | routes/coach.js | The Gemini proxy and daily allowance. |
/api/admin/*, /admin | routes/admin.js | Accounts, invitations, usage, the audit log. |
/, /login, files | app.js | Pages, 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,Securecookie 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_EMAILat 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
- Profiles, games and the coach's records are the app's own JSON documents, stored as
jsonbin 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:
- The browser sends the same
generateContentrequest it would send to Google, to/api/coach/models/<model>:generateContent, with its session cookie. - 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. - 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. - It adds
GEMINI_API_KEYin 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.
.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.
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.
| Table | Holds |
|---|---|
users | Email, name, Argon2id hash, linked Google account, role, status, confirmed-at. |
sessions | Hashed session tokens, expiry, last seen, browser and IP. |
invites, password_resets, email_verifications, oauth_states | Hashed one-time tokens with expiry. |
profiles, games, records | Each account's history, as the app's JSON documents. |
coach_usage | Coach calls per account per day. |
audit_log | Who 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
| Against | How |
|---|---|
| Stolen database copy | Passwords hashed with Argon2id; every token stored only as a hash. |
| Cross-site requests | Every change must carry the site's own Origin; cookies are SameSite=Lax. |
| Script injection | Strict Content Security Policy: no inline scripts, nothing from other sites; user text written as text. |
| Password guessing | Attempt limits per address; Caddy is the one trusted proxy hop, so clients can't fake their IP. |
| One account reaching another | Every history query scoped to the session's account; admin routes require the role. |
| Account takeover through Google | PKCE, nonce and state; linking by address only for addresses Google proves. |
| Leaked secrets | Keys and passwords only in the server's .env (mode 600); tokens in links sit after #. |
| A runaway coach bill | Model allowlist, request allowlist, output cap, daily allowance per account. |
| Oversized data | Body limits (2 MB, 256 KB per save, 16 MB import) and per-account caps. |
| Personal data left in a browser | The service worker never caches accounts, history or sign-in pages. |
Deployment
| Piece | Setup |
|---|---|
| Server | Google Cloud e2-micro, us-central1, Ubuntu 24.04 LTS, 30 GB standard disk, 2 GB swap. Prepared by scripts/server-setup.sh. |
| Name and IP | chesscopilot.duckdns.org → the VM's external IP. Stopping and starting the VM changes the IP; DuckDNS must then be updated. |
| Firewall | Google Cloud allows HTTP and HTTPS; the VM allows 22, 80 and 443. PostgreSQL and the app are reachable only inside Docker. |
| Containers | Compose 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. |
| Backups | On 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. |
| Updates | Ubuntu 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
| Path | What |
|---|---|
web/ | The browser app (shared ancestry with the PWA in chess-coach/src/, now two copies). |
web/auth/, tools/auth-pages.py | Sign-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, Caddyfile | How 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.