feat(landing): self-serve registration page via Drasl API (B1)

Add a static /register page (en/es/eu) that calls the Drasl REST API v2
directly from the browser: register -> login -> optional skin upload, all
in the pixel design. Guests never touch Drasl's own web UI.

Drasl config gains the pieces this needs:
- CORSAllowOrigins scoped to the apex (the API has no CORS until set;
  never "*").
- [RateLimit] for the now public-facing anonymous POST /users.
- [RegistrationNewPlayer] with RequireInvite driven by a new
  REGISTRATION_MODE (invite|open) .env flag.

REGISTRATION_MODE has two consumers from one key: render-config.sh derives
the TOML boolean for Drasl (drasl is TOML-only, no env), and the landing
build reads it to show/hide the invite-code field. render-config.sh halts
on any value other than invite/open.

Security verified against drasl source: anonymous POST /users cannot set
privileged fields (isAdmin/isLocked/chosenUuid/maxPlayerCount are gated on
callerIsAdmin in CreateUser), so browser-direct registration is safe.

Docs: plan/16-landing-registration.md captures the design + the B1 vs fork
decision; build-order, deploy, and the deploy skill wire REGISTRATION_MODE
and the landing-rebuild requirement (www/ is gitignored, not updated by a
git pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:55:51 +02:00
parent 067e990306
commit df8ffca53e
11 changed files with 766 additions and 11 deletions

View File

@@ -0,0 +1,155 @@
# Landing-page registration (B1) — register + skin on the apex
> Guests create their Drasl account and set a skin from the landing site itself
> (`${BASE_DOMAIN}/register`), in the pixel design — never touching Drasl's own
> web UI. The page is static; the browser calls the Drasl REST API directly
> (CORS). See `15-drasl-ui-customization.md` for the option survey this came from.
## TL;DR
- **Chosen: B1 — browser-direct.** A static Astro `/register` page ships vanilla
JS that `fetch()`es the Drasl API v2 from the browser. No backend, no secret in
the page, zero coupling to Drasl's internals (survives upstream upgrades).
- Rejected **A (fork Drasl + rebuild)** — perpetual rebase cost, and B1 makes
guests stop visiting Drasl's pages anyway. Asset-mount reskin (tier 2) is still
the cheap way to brand the pages guests *do* hit (admin/login).
- Rejected **B2 (server-side proxy)** — adds a runtime + admin secret; only worth
it for captcha / invite-bypass. Revisit if open-mode spam becomes a problem.
## Flow
```
guest @ https://${BASE_DOMAIN}/register (static Astro page + inline JS)
1. POST ${draslApiUrl}/users {username,password,playerName[,inviteCode]} (anon)
→ 200 APIUser
2. POST ${draslApiUrl}/login {username,password}
→ 200 {apiToken, user} (token kept in memory)
3. (optional) PATCH ${draslApiUrl}/players/{user.players[0].uuid}
Authorization: Bearer <apiToken>
{skinBase64, skinModel} → skin set
```
`draslApiUrl = https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.ts`).
## Drasl API contract (verified against unmojang/drasl @ master)
Route prefix `/drasl/api/v2`. Auth = `Authorization: Bearer <api_token>`.
Errors are uniform `{ "message": "..." }` with a non-2xx status.
- `POST /users` — create user. Callable **anonymous**. Key request fields:
`username`, `password`, `playerName`, `inviteCode`, `skinBase64`/`skinUrl`.
Returns `APIUser` (`uuid`, `username`, `players[]` each with `uuid`,`name`,`skinUrl`).
- `POST /login``{username,password}``{apiToken, user}` (`APILoginResponse`).
- `PATCH /players/{uuid}``skinBase64`|`skinUrl`, `skinModel` (`classic`|`slim`),
`deleteSkin`. Needs Bearer.
- `POST /invites` — admin only (mint invite codes).
## Security findings (read before touching this)
All verified in Drasl source, not assumed:
1. **Privileged fields are gated downstream**, not in the handler. `CreateUser`
(user.go) rejects non-admin/anonymous callers that set them — so anonymous
`POST /users` with `isAdmin:true` returns 400, **not** escalation:
- `isAdmin && !callerIsAdmin` → error (user.go:217)
- `isLocked && !callerIsAdmin` → error (user.go:221)
- `chosenUuid` needs `CreateNewPlayer.AllowChoosingUUID` or admin (user.go:192)
- `maxPlayerCount` → admin only (user.go:227)
- `RegistrationNewPlayer.Allow` + `RequireInvite` enforced for non-admin (177/181)
2. **`CORSAllowOrigins` is the on-switch.** Drasl applies CORS to API routes
**only if** `CORSAllowOrigins` is non-empty (main.go). Empty ⇒ no CORS headers
⇒ browser blocks every call. Echo backfills `AllowMethods` (incl. PATCH/DELETE)
and reflects the requested headers (so `Content-Type`/`Authorization` pass
preflight). **Scope to the apex — never `"*"`** (would let any site script the
auth API).
3. **Rate-limit the now public API** (`[RateLimit]`) — anonymous `POST /users` is
internet-facing.
4. **TLS only** — passwords cross the wire; already HTTPS via host nginx.
5. **Invite vs open** — on a public host, prefer invite. See feature flag below.
## Feature flag — `REGISTRATION_MODE` (invite | open)
One `.env` key, two consumers, default `invite`. Drasl config is TOML-only (no
env), so the script derives the boolean; the landing reads the same key at build.
| Consumer | Path |
|---|---|
| Drasl | `render-config.sh` validates the enum → `REGISTRATION_REQUIRE_INVITE` (true/false) → envsubst into `config.toml.tmpl` `[RegistrationNewPlayer] RequireInvite` |
| Landing | `site.ts` `registrationRequiresInvite = REGISTRATION_MODE === "invite"` → register form shows/hides the invite-code field |
`render-config.sh` **halts** on any value other than `invite`/`open` (no
half-rendered config). `invite`→form requires a code (admin mints via
`POST /drasl/api/v2/invites`); `open`→self-serve (keep `[RateLimit]`).
## Drasl config delta (`drasl/config/config.toml.tmpl`)
```toml
[RegistrationNewPlayer]
Allow = true
RequireInvite = ${REGISTRATION_REQUIRE_INVITE} # derived from REGISTRATION_MODE
CORSAllowOrigins = ["https://${BASE_DOMAIN}"] # apex only, never "*"
[RateLimit]
RequestsPerSecond = 5
Burst = 10
```
Applied by re-render + `docker compose restart drasl` — no image rebuild.
## Landing implementation
- Route: `src/pages/[...lang]/register.astro``/register`, `/es/register`,
`/eu/register` (mirrors `[...lang].astro`; both coexist, build-verified).
- i18n: `i18n/ui.ts` `register` block (en/es/eu) + `LANG_REGISTER_PATH`.
- Styles: `.reg-*` block in `styles/main.css` (reuses the bevel/slot design tokens).
- `site.ts`: `draslApiUrl` + `registrationRequiresInvite`.
- Client JS: inline `<script define:vars={{cfg}}>` (apiUrl + flag + runtime
strings). Flow = register → login → reveal success (player UUID) → optional
PNG→base64 skin upload (classic/slim). Surfaces Drasl `{message}` errors inline.
- The Drasl web UI stays the "manage existing account" target (linked from the
form footer). Account *edit*/admin still live there — guests don't need them.
## Build / deploy wiring
`REGISTRATION_MODE` must reach **both** the Drasl render and the landing build —
both source `.env`. The landing build is NOT a docker step; it emits static
`www/` (gitignored), so it must run on the host whenever `landing/` **or**
`REGISTRATION_MODE` changes.
```bash
# Re-render Drasl config (reads .env incl. REGISTRATION_MODE)
tooling/render-config.sh
# Rebuild landing into www/ — source .env so BASE_DOMAIN + REGISTRATION_MODE bake in
( cd landing && set -a && . ../.env && set +a && pnpm run build )
docker compose restart drasl # picks up CORS/RateLimit/RequireInvite
```
Toggling the flag = run all three (config restart + landing rebuild). `www/` is a
generated artifact and is not in git, so a plain `git pull` does **not** update
the served page — the landing rebuild is required for any `landing/` change to go
live. See `12-build-order.md` (ops) and `14-deploy.md`.
## Runtime acceptance (can't verify offline)
- [ ] `CORSAllowOrigins` live in the running drasl (restart after render).
- [ ] Preflight: browser `OPTIONS ${draslApiUrl}/users` from the apex returns
`Access-Control-Allow-Origin: https://${BASE_DOMAIN}`.
- [ ] Admin account exists; in `invite` mode, mint codes via
`POST /drasl/api/v2/invites` (admin Bearer) and share out-of-band.
- [ ] End-to-end: register → login → skin → join with the new account.
## Future / open
- Account **login + skin-change** panel on the landing (same API, add `/account`).
- `AllowTextureFromURL` → let guests paste a skin URL instead of uploading.
- B2 proxy only if open-mode abuse forces captcha / invite-bypass.
## Related
- `15-drasl-ui-customization.md` — the A/B option survey + asset-mount reskin.
- `03-drasl.md` — Drasl config + secure-profile gotcha.
- `09-landing.md` — landing stack, i18n, theme.
- Drasl API: <https://doc.drasl.unmojang.org> · config:
<https://github.com/unmojang/drasl/blob/master/doc/configuration.md>
```