Files
ulicraft-server-v1/plan/16-landing-registration.md
Oier Bravo Urtasun df8ffca53e 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>
2026-06-09 21:55:51 +02:00

7.4 KiB

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)

[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.

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