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>
156 lines
7.4 KiB
Markdown
156 lines
7.4 KiB
Markdown
# 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>
|
|
```
|