Files
balance2/bingo/CLAUDE.md
T
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

15 KiB
Raw Blame History

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

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.replaceStates 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.