Files
Claude 9b421c9b47 Admin: log in once, keep a session cookie instead of the key in the URL
The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:

- POST /api/admin/login {key} sets bingo_admin=<random token>
  (HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
  admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
  ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
  from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only

Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
2026-10-30 20:00:21 +00:00

229 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — Balance 2.0 Bingo
Context for anyone (human or Claude Code) picking up this project. Read this first.
## What this is
A single web page for a team wellbeing initiative, **"Project: Balance — Season 2" ("Баланс 2.0")**,
run by Olga (project manager) for a remote team of ~20–25 people at Adacta Fintech, some of them
contractors from other companies. The season runs **2 Nov – 21 Dec 2026**, 8 weeks. The link gets
pinned in a Microsoft Teams channel.
- Each person sees a **personal 4×4 bingo card** of small wellbeing habits and taps the ones they did this week.
- Every tick feeds one shared **"team battery"** that fills up over the whole season.
- The card resets every Monday 00:00 (season timezone). The battery never resets.
- **No accounts and no personal data.** This is a hard requirement — see "Anonymity" below.
The UI and all user-facing copy are **in Russian** and must stay Russian. Code comments and README
are also Russian (historical). The owner and her husband now work in English — conversation, commit
messages and new docs can be English.
The original brief (Russian) is in `docs/original-brief.md`. Some of it has been overridden; see below.
## Decisions that changed the brief
| Brief said | Now | Why |
|---|---|---|
| Only full **rows** give +5 | **Rows, columns and both diagonals** each give +5 (10 lines total) | Owner asked: "a diagonal is bingo too", then "vertical too" |
| Goal 300 steps | **Goal 500** | Owner's call after lines were added |
| Hosting: Cloudflare / Netlify / Firebase | **Self-hosted Docker Compose + nginx**, no external services | Owner wants independence from third parties |
| Fonts from Google Fonts | Fonts **self-hosted** in `public/fonts/` | No third-party requests (privacy, offline) |
The "+5 per row" wording also appears in the project's planning docs (outside this repo); the page's
rule text now reads "Собрал ряд, столбец или диагональ — +5 в батарейку".
## Architecture
```
Browser ──https──▶ nginx :443 (host) ──http──▶ 127.0.0.1:8080 container "bingo" ──▶ volume bingo-data (/data/bingo.sqlite)
```
- **Server:** `server/server.mjs` — plain `node:http`, Node.js ≥ 22.13, **built-in `node:sqlite`**,
**zero npm dependencies** (keep it that way). Serves static files from `public/` (preloaded into
memory at startup) and a small JSON API.
- **DB:** one SQLite file, WAL mode. Schema in `schema.sql`, applied idempotently on every start.
- **Frontend:** `public/index.html` (page) and `public/admin.html` (stats) — each a single self-contained
file with inline CSS/JS, no build step, no framework. All asset/API paths are **relative**
(`api/...`, `fonts/...`) so the app works on its own subdomain *or* in a subfolder behind nginx.
- **Container:** `Dockerfile` (base `node:22.22-alpine`), `compose.yaml` (project name `balance-bingo`).
Runs as user `node`, read-only rootfs, `cap_drop: ALL`, `no-new-privileges`, healthcheck on `/healthz`.
Port bound to `127.0.0.1` only.
- **Reverse proxy:** nginx on the host terminates TLS. Configs in `deploy/nginx/`.
### File map
```
public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode
public/admin.html organiser stats, CSV export; key login → session cookie, logout
public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets
server/server.mjs HTTP server, API, season/week logic, security headers, daily backups
server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5
server/cli.mjs `backup`, `reset --yes`
schema.sql tables marks, pulse, totals
tests/api.test.mjs end-to-end tests (spawns the real server on a temp DB)
deploy/nginx/bingo.conf nginx: own subdomain (recommended)
deploy/nginx/bingo-subpath.conf nginx: subfolder of an existing site
tools/build-demo.mjs builds dist/demo.html — serverless demo for previews
Dockerfile, compose.yaml, .env.example, .dockerignore
README.md deployment guide (Russian) incl. full nginx config
docs/original-brief.md original requirements (Russian)
```
## Commands
```bash
npm start # run locally on :8080, DB in ./data (needs Node >= 22.13)
npm test # 7 end-to-end API tests, no deps
npm run build:demo # dist/demo.html (forced demo mode, Google Fonts link for preview only)
cp .env.example .env # set ADMIN_KEY (>= 16 chars)
docker compose up -d --build
docker compose logs bingo
docker compose exec bingo node server/cli.mjs backup
docker compose exec bingo node server/cli.mjs reset --yes # wipe test data before the season
```
Local testing during the pre-season: set `SEASON_START` to a past Monday, otherwise the server
rejects marks (`season_closed`). `http://localhost:8080/#demo` forces demo mode (no server calls).
## Configuration (`.env`, read by the server at start)
| Var | Default | Notes |
|---|---|---|
| `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start |
| `ADMIN_SESSION_HOURS` | `8` | admin session lifetime from login, absolute (no sliding); 0 < h ≤ 720 |
| `COOKIE_SECURE` | on | `0` drops the `Secure` cookie flag — only for plain-http testing |
| `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone |
| `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server |
| `SEASON_END` | `2026-12-21` | inclusive; after it: season closed |
| `GOAL` | `500` | battery target; above it the UI shows "Овердрайв!" and keeps counting |
| `BACKUP_KEEP` | `14` | daily `VACUUM INTO` copies in `/data/backups`; 0 = off |
| `HOST_BIND` / `HOST_PORT` | `127.0.0.1` / `8080` | compose port mapping only |
The page fetches goal/dates/timezone from `/api/total`, so no HTML edits are needed for config changes.
Invalid config makes the server exit at startup with a clear message.
**Open question for the owner:** 21 Dec 2026 is a Monday, so "week 8" is one day long.
`SEASON_END=2026-12-27` would give 8 full weeks. Not decided yet.
## Scoring rules (server is the source of truth)
- 1 ticked cell = 1 step. Each completed line = +5. Lines: rows 1–4, columns 1–4,
diagonals 01-06-11-16 and 04-07-10-13. Bonuses stack (a full card = 16 + 10×5 = 66).
- Unticking in the same week removes the step and the bonus of every line it breaks.
- Only the **current week** can be changed (client sends `week`, server returns 409 if it's stale).
- Primary key `(device, week, cell)` prevents double-counting.
- After every mark the server **recomputes the total from scratch** inside one transaction
(`RECOUNT_SQL` = count(marks) + 5 × count(LINES_SQL)) and caches it in `totals`.
Polling `/api/total` reads that one row.
- The client mirrors the same rules (`LINES` in `index.html`) for instant feedback and celebrations,
then reconciles with the server response. **If you change scoring, change both** `server/db.mjs`
and the `LINES` array in `public/index.html`, and update the tests and README.
## API
| Method & path | Body / query | Response |
|---|---|---|
| `GET /api/total` | — | `{total, goal, tz, start, end, week, weekNo, weeks, phase}`; phase = `pre` / `active` / `post` |
| `GET /api/me?d=<id>` | — | `{week, cells:[...], pulse}` — this device's current week |
| `POST /api/mark` | `{d, week, cell 1–16, on: bool}` | `{ok, week, total, cells}`; 409 `week_changed`, 403 `season_closed`, 400 validation |
| `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}`; one answer per device per week, can be changed |
| `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=<token>`; 401 `bad_key` (after 500 ms); 403 `admin_disabled` |
| `POST /api/admin/logout` | — | deletes the session, clears the cookie |
| `GET /api/admin/stats` | session cookie, or `Authorization: Bearer <ADMIN_KEY>` for scripts | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts, `session.expiresAt`; 401 without auth |
| `GET /healthz` | — | `{ok:true}`; blocked by nginx from outside |
Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1`),
regex `^[A-Za-z0-9-]{16,64}$`. Request bodies are capped at 2 KB (413 above that).
## Anonymity — non-negotiable
- Store nothing beyond: random device id, week (Monday date), cell number, pulse answer, the total.
**No** names, emails, logins, IPs, timestamps, user agents, analytics or third-party requests.
- The server logs only startup and errors — never request lines or IPs.
- nginx: `access_log off`; `X-Forwarded-For` deliberately **not** forwarded; error log at `crit`.
- Only the team total is public. The admin page shows aggregates only.
- Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data.
## Admin authentication
- The key is never kept in the URL. `/admin#k=KEY` still works as a login link: the page immediately
`history.replaceState`s the fragment away, POSTs the key to `/api/admin/login`, then shows plain `/admin`.
The login form does the same without touching the URL.
- The server answers with `bingo_admin=<32 random bytes, base64url>; Max-Age; HttpOnly; SameSite=Strict; Secure`.
**No `Path` on purpose**: the browser defaults it to the request directory (`/api/admin`, or
`/<prefix>/api/admin` behind the subpath nginx config), so the cookie only goes to admin API calls.
- Sessions live in an in-memory `Map` keyed by sha256(token) → expiry (max 200, oldest evicted).
Absolute lifetime `ADMIN_SESSION_HOURS` (default 8 h), no sliding. A container restart logs everyone out — accepted.
- Wrong key → 401 after a 500 ms delay. The key is compared with `timingSafeEqual` over sha256 digests.
- `Authorization: Bearer <ADMIN_KEY>` is still accepted on `/api/admin/stats` for curl/scripts.
## Teams embedding gotcha
The page must be embeddable as a Teams "Website" tab. The server sends
`Content-Security-Policy: frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://*.office.com https://*.cloud.microsoft`.
**Never add `X-Frame-Options`** (app or nginx); it breaks the tab. The subpath nginx config uses
an `add_header` inside `location` specifically to stop inheriting a site-wide X-Frame-Options.
A test asserts the header is absent.
## Frontend notes
- Visual spec from the brief: background `#12233D`, cells `#1B3150` with border `#2A4468`, radius 20px,
text `#F2F5FA` / muted `#A9B8CC`. Row colours: Тело `#FF8A65`, Фокус `#7CC4FF`, Границы `#B9A6FF`,
Люди и радость `#C6F432`. Accent `#C6F432`. Fonts: Unbounded (display), Manrope (text). Tokens are in `:root`.
- Deliberately single dark theme (brand look), not light/dark.
- Cells are real `<button>`s with `aria-pressed`; announcements go to an `aria-live` region; respects `prefers-reduced-motion`.
- On phones (< 760px) each row becomes 2×2, so diagonals/columns are shown via a light ring on the cells
(`.on-line`) instead of visually.
- Completed row: whole row highlighted + confetti (canvas). Completed column/diagonal: ring on its cells + confetti bursts.
- Polls `/api/total` every 20 s (`POLL_MS`), only while the tab is visible.
- Falls back to **demo mode** automatically if the API is unreachable (example battery, local-only ticks).
- Weekly pulse question "Твоя батарейка сейчас?" (⚡ Полная / 🔋 Средне / 🪫 На нуле) — anonymous counts per week,
used for a before/after comparison at the finale.
## Verification status
- `npm test` passes, 11 tests: scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse,
admin totals, admin login/cookie flags/logout, session expiry, admin disabled, COOKIE_SECURE=0, headers.
- Admin login flow verified in a real browser over https through both nginx configs (30 Oct 2026):
key removed from URL/history, cookie HttpOnly + Secure + SameSite=Strict with path `/api/admin`
(or `/bingo/api/admin`), survives reload, logout ends it, wrong key shows an error.
- Manually verified (17–18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow;
pre-season / overdrive / weekly reset states; container healthy, data persists across restart and
`down`/`up`, runs with network disconnected, non-root, read-only rootfs.
- nginx 1.24: both configs pass `nginx -t`; full page + admin worked through both in a real browser;
rate limit returns 429; large body 413; zero access-log lines written.
- **Not yet verified:** the build on the real `node:22.22-alpine` image (the build sandbox couldn't reach
Docker Hub; a local image with the same Node 22.22.0 was used). First real `docker compose up --build`
is the check. If the `22.22-alpine` tag doesn't exist anymore, use `node:22-alpine` (needs ≥ 22.13 for `node:sqlite`).
- Not tested inside an actual Teams tab yet.
## Deployment checklist (production)
1. `cp .env.example .env`; set `ADMIN_KEY` (`openssl rand -base64 24`); confirm dates/goal; decide `SEASON_END`.
2. `docker compose up -d --build`; `docker compose ps` → healthy; `curl -s 127.0.0.1:8080/api/total`.
3. nginx: copy `deploy/nginx/bingo.conf` → `/etc/nginx/conf.d/`, set `server_name` + cert paths
(or `certbot --nginx -d <domain>`), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx`.
4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab,
then restore `2026-11-02`, `docker compose up -d`, `docker compose exec bingo node server/cli.mjs reset --yes`.
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin` (log in with the key;
`/admin#k=<ADMIN_KEY>` also works as a one-click login link and is stripped from the URL).
6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume.
## Conventions
- No dependencies, no build step, no frameworks — the whole thing should stay auditable in an afternoon.
- Keep `public/*.html` self-contained (inline CSS/JS), Russian copy, relative URLs.
- Russian UI copy style: warm, short, a little self-irony, no coaching/therapy language
("энергия, фокус, нагрузка", not "забота о себе"). Never single anyone out.
- After changes: `npm test`; if UI changed, check 375px width and the Teams-embedding headers.
- Commit messages in English.
## Wider project context (not code, but useful)
The bingo is one part of a season of activities (weekly Monday posts, Tuesday 20-minute "quiet Tuesday"
calls, Friday chat check-ins). Weekly themes: 0 teaser (26 Oct) · 1 start · 2 where the day leaks ·
3 body at the laptop · 4 saying "no" · 5 my best fail · 6 productivity myths · 7 recharging outside work ·
8 finale (21 Dec) with battery results. Part of the team is sceptical of "self-care" — playfulness and
humour over earnestness. After the finale there's a pre-New-Year release crunch, so only light async activities.