---
id: server-architecture/010
title: Real accounts — sign up, log in, and attach a device to an existing account
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-12
updated: 2026-08-13
claimed_by: session-a0f7b92d
claimed_at: 2026-08-12T07:36:15.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/010-accounts.md
---

## What

Named by Kris on 2026-08-12 as the next significant build, alongside the world map
(`world-map-and-portals`): "creating accounts and being able to log in and attach to your
account." He flagged it explicitly as needing **significant, careful architecture on both
the server and the client** — not a login form bolted on.

This is the deferred half of a decision already made. `overview.md`'s **Identity** section
committed to *persistent accounts from day one, no authentication for the MVP* — issue and
remember a token, get the identity column and its foreign keys right, and let the login
form come later. That reasoning was: adding real auth to an existing populated user table
is straightforward, retrofitting durable identity onto anonymous sessions is not. This task
is "later" arriving. **Do not re-litigate the deferral** — the deferral was correct and the
table it protected is exactly what makes this task tractable.

## Where it stands today

- `game/director/src/schema.sql` — `accounts (id, token UNIQUE, display_name, created_at)`.
  The comment states the current model plainly: "Whoever holds this token is this account,
  full stop, until real auth replaces it."
- `game/director/src/accounts.ts` — `createAccount`, `getAccountByToken`, `getAccountById`.
  No credentials, no sessions, no revocation.
- `worlds.owner_id` and `world_members.account_id` are already foreign-keyed to `accounts`,
  so real auth is additive rather than a data migration.
- The client stores its token per install. **Known symptom of the current model:** two
  native clients on one machine share a single account unless they get separate `$HOME`s —
  the token is the identity, and there is no way to say "this is me, on a second device."
- The title screen already has a "Change Account" button
  (`game/world/client/ui/title_screen.gd`), and `player_state.gd` is documented as *the*
  single seam where server-backed accounts and collections plug in.

## Scope

- **Sign up** — a real account with a credential, not just an issued token. Which
  credential (email + password, passkey, OAuth, device-code) is an open architectural call
  and part of this task's job to decide and justify.
- **Log in** — establishing a session against an existing account from a fresh install.
- **Attach a device to an account** — Kris's own phrasing. This is the interesting case,
  not an afterthought: the same human on a phone and a browser is one account with two
  devices, and today that's impossible. Sessions, not a single shared secret.
- **Upgrade path for existing token-only accounts.** People (Kris included) already have
  worlds and members tied to token accounts. Claiming an existing token-account with a
  credential has to work, or the deferral's whole benefit is lost.
- **Client architecture** — where identity lives, how a session is stored and refreshed,
  and how every screen reads it. `player_state.gd` is the intended seam; confirm it's still
  the right one at this scale or say what replaces it.
- **Server architecture** — session/token handling in the director, what the world
  processes are allowed to trust, and how `hello`'s `accountId` (currently asserted by the
  client, unverified — `world_server.gd`) stops being self-asserted.

## Explicit non-negotiables

- **Do not foreclose offline play.** Kris stated on 2026-08-12 that the finished game must
  be playable offline, and stated it precisely so nothing gets architected against it. An
  auth model that makes the client useless without a live director violates this. See
  `world-map-and-portals/overview.md`'s offline section.
- **Server-authoritative stays server-authoritative.** Identity being partly client-held
  must not become identity being client-asserted.

## Definition of done

Kris asked for this written up as a task now, with the intention of handing it to a
planning session for a full implementation plan before any code is written. So: **the
first deliverable of this task is the architecture, not the feature.** A plan that names
the credential model, the session model, the device-attach flow, the migration for existing
token accounts, and the client seam — reviewed by Kris — and then the build against it.

## The question this probably raises

Choosing a credential model may reach outside the repo (an email sender, an OAuth provider,
a hosted identity service) and may cost money — which clears `PICKUP.md`'s question bar.
If so, that is the *one* question worth raising on this task, with options and a
recommendation.

## Implementation plan — approved 2026-08-12

The architecture this task names as its first deliverable now exists and is approved:
**[`../010-accounts-plan.md`](../010-accounts-plan.md)** — planned and signed off by
Kris in a live session on 2026-08-12, design verified against the current code. The
implementing agent builds against that document (start at its "Staged build order",
stage 1); this task stays `ready` until claimed for the build.

**Two amendments from that session supersede the text above:**

- **Slate wipe.** The "Upgrade path for existing token-only accounts" scope item is
  void — Kris's explicit call. Existing `accounts` / `worlds` / `world_members` rows
  are disposed at deploy via a deliberate, guarded migration. Ignore the earlier
  "claiming an existing token-account with a credential has to work" line.
- **No gameplay mechanics.** Collections land as storage plus a server→client read
  path only (seeded with the current demo set). No pickup logic, no award paths, no
  health — nothing item-mechanical belongs to this task.

## Audit — 2026-08-13

Kris asked for an independent audit of the delivered build before moving the project
forward: **[`../010-accounts-audit.md`](../010-accounts-audit.md)**. Verdict in short:
foundation solid, handoff honest, both suites reproduce green — but one high-severity
security gap (password reset doesn't revoke sessions), the QR half of device-attach
never built (`?link=` web redemption missing), and the client's failure paths (no
reconnect, silent dead-ends — the likely cause of the observed "sometimes doesn't
run" bug). The audit's Recommendations section proposes the follow-up tasks.

**Decisions settled (no longer open calls):** one LoR account with linked sign-in
providers, guest-first (play never gates on sign-in); email+password is v1's typed
credential; **Resend** is the email sender (Kris sets up the account + DNS — the build
uses a log-fallback mailer until the key exists); device attach = sessions + link codes
with a QR; the self-asserted `hello` accountId is replaced by director-minted join
grants; the anti-cheat handoff's rate limits and movement-pacing fix ride along.
Google Play Games sign-in is split out as task 011.

## Build complete — in review, 2026-08-12

All six stages built, tested, committed, and pushed to `land-of-lor` — see
**[`land-of-lor/docs/handoff/historic/010-accounts.md`](../../land-of-lor/docs/handoff/historic/010-accounts.md)**
for the full report: what shipped stage by stage, test coverage (76/76 director +
75/75 pure-logic GDScript, both green), how the new e2e script was verified without
touching production, and the one thing deliberately left undone — **the live
droplet was not deployed to**. The runbook's slate-wipe step
(`LOR_WIPE_V1=yes-destroy-v1-data npm run migrate-v2`) destroys the droplet's
existing accounts/worlds rows irreversibly; that's Kris's call to make live, not
something to run unattended on a pre-approved plan. Everything up to that point —
code, tests, the runbook itself — is ready. This task stays `review` until Kris
either runs the deploy himself or directs a session to.

## Deployed 2026-08-12 — backend + web live, Android blocked

Kris gave explicit go-ahead to run the live deploy. Executed: verified backup
(`pg_dump`, non-empty, gzip-checked) before the wipe, `migrate-v2` on the droplet
(destroyed 76 accounts / 9 worlds / 74 members, matching the backup), director
restarted on v2, `verify-accounts-e2e` **23/23 passed against prod**. Web client
rebuilt and pushed live at `/play` (confirmed the shipped build actually contains
the accounts v2 client code, not just a timestamp bump). Full record in the handoff
note's new "Deploy" section, including a flagged-but-not-blocking gap in three older
live-check scripts that still use the pre-grant `hello`.

**Android is not done — stays out of `done` for this reason alone.** Rebuilding the
APK needs `.secrets/lor2026.keystore` (+ `.password`), which this workspace's own
`CLAUDE.md` says has lived in this container's `.secrets/` since 2026-08-04 but
which is not actually present here. The currently-downloadable APK now fails
`UNSUPPORTED_VERSION` against the live v2 director (expected cutover behavior, but
means Android play is broken until this is resolved). **Needs Kris**: restore
`lor2026.keystore` + `lor2026.keystore.password` into this container's
`land-of-lor/../.secrets/`, then a session can run `./export-android.sh --upload`
in minutes — everything else in that pipeline (Godot 4.7.1, export templates,
droplet upload path) was confirmed working this session.