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