# ADR-0004: Lucas Account — one shared login service

- Status: accepted (owner decisions 2026-08-02); **implemented 2026-08-02 in
  the separate `lucas-account` repo — never deployed, no cloud resource
  created**
- Scope: authentication for every Lucas Academy product, starting with the
  Inception Space editor
- Related: `docs/INCEPTION-SPACE-EXECUTION-PLAN.zh-en.md` §5 (authorization
  layers), `docs/SPACE-MUSEUM-DIRECTION.md` §10 (multiplayer)

## Context

Three products already ship their own OTP login (Painterly Chameleon,
art-lab, lucas-academy), and the `llm-server` docs describe a fourth: a
first-party token endpoint issuing ES256 JWTs with a JWKS. That is four
implementations of the same thing.

Two facts constrain the design:

- **Email sending is tied to Cloudflare.** Painterly's OTP mail goes through
  the Workers `send_email` binding (`wrangler.jsonc`: `"send_email":
  [{"name": "EMAIL"}]`), which exists only inside a Worker. A third-party
  API (Resend, Postmark, SES) would free us from that, but there is no
  reason to throw away a working sender.
- **The apps are moving to Render.** snake-lab already deploys there via
  `render.yaml`, and Inception Space will follow. Render hosts have no
  Cloudflare bindings.

## Decision

1. **One Cloudflare Worker, its own repository, its own domain** (owner
   2026-08-02). It owns OTP delivery, code storage, rate limiting, and JWT
   signing. Working name: **Lucas Account**; suggested host
   `auth.lucasacademy.org`.
2. **Authentication is central; authorization is per app.** The service only
   proves "this person controls this mailbox", and it does so for **anyone**
   — there is deliberately no global email allowlist, because real sign-ups
   must stay possible (owner 2026-08-02). Each app decides who may do what,
   from its own config file: the Inception Space editor's allowlist is a
   committed config containing `[email removed]`, with no database.
   Abuse of the service itself is held off by the origin allowlist and the
   rate limits, not by restricting who may receive a code.
3. **Apps stay stateless.** A client posts an email, the user receives a
   code, the client posts the code, and gets a signed JWT. Clients verify
   the signature against the cached JWKS. No session store, no user table in
   the museum.
4. **Inception Space is the only client for now** (owner 2026-08-02).
   Painterly / art-lab / lucas-academy migrate later, one at a time.
5. **Long-lived tokens with a kill switch** — see the note below.

## Token lifetime (owner asked for "permanent")

The owner asked for permanent JWTs, reasoning that the repos are private and
the token can live in a Render secret. One correction matters: **the Render
secret holds the signing key, not the user's token.** The JWT is a browser
credential — it lands in the editor's `localStorage` on a laptop. A truly
permanent token can never be withdrawn if that laptop is lost or a machine
is shared.

Recorded decision: issue tokens with a **one-year expiry** plus a `kid`
(key id) in the header. Rotating the signing key invalidates every token
ever issued, instantly and without a database. That is "permanent enough"
in daily use — the owner will not be asked for a code again for a year —
while keeping one lever to pull if anything leaks. Revisit if the owner
prefers literal `exp`-less tokens.

## Interface sketch

```text
POST /v1/otp/request   { email }                 -> 202 always (no probing)
POST /v1/otp/verify    { email, code }           -> { token, expiresAt, email }
GET  /v1/jwks                                    -> { keys: [...] }   (cacheable)
GET  /v1/me            Authorization: Bearer ... -> { email, sub, expiresAt }
GET  /health                                     -> liveness

Every call except /v1/jwks and /health carries `X-Lucas-App`.
```

JWT claims: `iss` (the service URL), `sub` (stable account id), `email`,
`iat`, `exp`, `kid` in the header. Nothing app-specific — no roles, no
entitlements; those live in each app's config.

Guards the service owns: per-email, per-IP, and service-wide hourly send
caps (the last one keeps a distributed flood from running up a mail bill),
a 60 s resend cooldown, a 10-minute code TTL, attempt caps with lockout,
single-use codes, and constant-time comparison. Registered origins are the
only callers: a browser app cannot hold a secret, so the declared Origin is
its credential.

Guards the **client** owns: the app's own allowlist check must run
**server-side wherever data is written**. A config allowlist in the browser
is UI, not security. While the editor only exports files locally, there is
nothing to protect; the check becomes mandatory the moment a save endpoint
exists.

## Consequences

- The museum can deploy to Render as a static site while login and email
  stay on Cloudflare.
- Multiplayer presence gets an identity for free once the service exists.
- Four OTP implementations collapse into one over time.
- New dependency: if the account service is down, editors cannot log in.
  Public viewing and student play must never require it.
