# 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>
```
