Accounts, Devices and Ratings

The player-facing web system: a person signs up, links their board, adds friends, and gets rated games and leaderboards. It runs on two Workers over one player database, and it is deliberately kept at arm’s length from the company’s own systems.

Two identity layers

Provisioned at first boot, long-lived. This is how an appliance authenticates before any human account exists, and it alone is enough to play a code game (D-70, D-73).

A person. A device can be linked to an account so games, friends and ratings attach to the human rather than the box; several accounts may use one board over time.

D-65 — Accounts ship in v1, and the product is architected toward a public service

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §4 · Status: in force

Context v1 is friends-only play. Identity could have been deferred entirely.

Decision Ship a real user store, device$\leftrightarrow$account linking and a friends graph in v1, and shape the server so a public matchmaking layer slots in later without a rewrite.

Rejected Deferring identity to v2. Identity is load-bearing from day one for anything that outlives a single game — results, ratings, friends — and retrofitting it means rewriting the parts that already work.

Consequences Ranked play, leaderboards and a matchmaking queue are reachable from here rather than being a second system. It also means consumer PII exists from v1, which is what makes D-72 matter.

D-72 — Player data lives in a separate D1 from the corporate database

Decided 2026-07-03 · Origin: docs/networked-play-docs/WEB-APP-PLAN.md §2 · Status: in force

Context The company’s web/ Worker already has a D1 (kirin-admin) holding team members, articles, legal documents and CAD, behind invite-only admin auth.

Decision A new, separate kirin-players D1 owned by the game backend holds every player, device, friendship, game and rating. The code for password hashing and sessions was copied from the corporate Worker; the database was not.

Rejected One shared database. A public consumer signup surface pointed at the store holding the company’s legal and CAD data is an unacceptable blast radius — one signup bug, abuse wave or leak would reach corporate IP — and the two auth models are genuinely different (invited admins versus open consumer signup).

Consequences The two systems scale and fail independently, and no reinvention of password hashing was needed. Two Workers bind the one player database: game-server (the realtime relay, which gained exactly one responsibility — writing the authoritative result on game-over) and chess-web (accounts, friends, leaderboard API, and the SPA). The shared D1 is the only coupling, and it is read-mostly.

Provisioning and linking a board

A board is provisioned once, at first boot, receiving a device id and a device token. The token is stored hashed at rest server-side (SHA-256 — the right primitive for a 256-bit secret, so no salt or KDF) and persisted on the board in QSettings by DeviceLinkClient.

Linking that board to an account uses the phone, because typing on a chess board is poor UX:

  1. the board asks the Worker for a link token and renders it as a QR code (a vendored qrcodegen painter, kirin-gui/src/qr_image.h);

  2. the phone opens chess.kirinautonomy.com, signs in (passwordless email magic-link), and completes the link;

  3. the board polls until the link lands.

Note

Not everyone linking a board has a camera. The same link row also carries a short human-typeable code (8 characters from a 32-character unambiguous alphabet — no 0/1/I/O), and either redeems it. The keyspace plus a 15-minute TTL plus single use is what lets that code carry no separate rate limiter, unlike the 6-digit game code.

Factory provisioning keys

Provisioning originally shipped open: anyone who could reach the endpoint could create unlimited device rows and tokens. It is now gated by a single-use key issued per physical unit and spent by that board’s first provision call. Redemption claims the key before inserting the device and rolls the claim back if the insert throws, so two boards racing one key cannot both win and a key is never stranded. The minting secret is deliberately not the shared admin secret that is spread across three Workers — minting boards is strictly more powerful than reading presence.

Warning

This is not attestation. It proves a key was presented once, not that a real board is playing. A provisioned board keeps its token in plaintext settings on a Pi its owner controls, so “this rated game was played on real hardware” remains unenforceable in software; closing that needs a secure element. What the gate does buy is real but narrower: the devices table is no longer public-writable, every row traces to a unit, and a leaked SD image exposes at most that unit’s already-spent key.

The data model

Migrations are append-only. The v1 shape, in kirin-players:

Table

Holds

users

handle, email, optional password hash (null under magic-link auth).

sessions

server-side sessions; the cookie is the session id.

devices

one row per board: hashed token, nullable linked_user_id.

link_tokens

single-use, short-TTL device-link and login tokens (plus the typeable code).

provisioning_keys

one per unit, hashed, recording what it became.

friendships

directed rows with a status (pending / accepted / blocked).

games

one row per finished game, written by the relay DO.

game_analysis

the per-ply artifact (D-60).

ratings

Glicko-2 (rating, rd, vol) per user.

Anonymous code games still write a games row with null users and rated=0, so totals and future backfill work. Only games where both seats proved a device linked to distinct accounts are rated=1 and touch ratings — no self-play, and no rating a game against your own engine (D-79).

Ratings and leaderboards

The relay attributes and rates on game-over: it resolves both seats’ devices to linked users, marks the game rated only under the rule above, and runs a Glicko-2 update writing both rating rows in the same batch as the game insert. The rating math is pure and unit-tested against Glickman’s own worked example rather than against itself — a wrong rating system still produces plausible numbers that go up when you win, so “it looks right” proves nothing.

The leaderboard endpoint serves two scopes (global, friends) and two periods: all-time by rating, and weekly by rated wins in the trailing seven days, counted straight from games with no history table. The caller’s own rank is always resolved exactly, even when it falls below the returned page.

Ratings on the server and the Kirin rating on the board (Chapter Playing a Game) carry the same (rating, rd, vol) triple deliberately, so the two never diverge in meaning — but they measure different things and are never conflated in the UI.

Presence

“Is this board online?” is answered by the live-feed object, not by a heartbeat or a last_seen column: a board’s feed socket being open is its presence (Chapter The Live Board Feed and Telemetry, D-75). chess-web maps users to their linked devices and asks the relay in one batched, bearer-authenticated internal call; a user is online if any linked board is.

That call fails safe to offline when the relay is unconfigured or unreachable. Presence is a nicety and must never become load-bearing — an outage should mean “nobody looks online”, not “nothing works”.