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:
155
plan/16-landing-registration.md
Normal file
155
plan/16-landing-registration.md
Normal 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>
|
||||
```
|
||||
Reference in New Issue
Block a user