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>
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). See15-drasl-ui-customization.mdfor the option survey this came from.
TL;DR
- Chosen: B1 — browser-direct. A static Astro
/registerpage ships vanilla JS thatfetch()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. ReturnsAPIUser(uuid,username,players[]each withuuid,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:
- Privileged fields are gated downstream, not in the handler.
CreateUser(user.go) rejects non-admin/anonymous callers that set them — so anonymousPOST /userswithisAdmin:truereturns 400, not escalation:isAdmin && !callerIsAdmin→ error (user.go:217)isLocked && !callerIsAdmin→ error (user.go:221)chosenUuidneedsCreateNewPlayer.AllowChoosingUUIDor admin (user.go:192)maxPlayerCount→ admin only (user.go:227)RegistrationNewPlayer.Allow+RequireInviteenforced for non-admin (177/181)
CORSAllowOriginsis the on-switch. Drasl applies CORS to API routes only ifCORSAllowOriginsis non-empty (main.go). Empty ⇒ no CORS headers ⇒ browser blocks every call. Echo backfillsAllowMethods(incl. PATCH/DELETE) and reflects the requested headers (soContent-Type/Authorizationpass preflight). Scope to the apex — never"*"(would let any site script the auth API).- Rate-limit the now public API (
[RateLimit]) — anonymousPOST /usersis internet-facing. - TLS only — passwords cross the wire; already HTTPS via host nginx.
- 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.tsregisterblock (en/es/eu) +LANG_REGISTER_PATH. - Styles:
.reg-*block instyles/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)
CORSAllowOriginslive in the running drasl (restart after render).- Preflight: browser
OPTIONS ${draslApiUrl}/usersfrom the apex returnsAccess-Control-Allow-Origin: https://${BASE_DOMAIN}. - Admin account exists; in
invitemode, mint codes viaPOST /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