From df8ffca53ea96547e1a329682175fd950612c047 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 9 Jun 2026 21:55:51 +0200 Subject: [PATCH] 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) --- .claude/skills/deploy/SKILL.md | 16 +- .env.example | 8 + drasl/config/config.toml.tmpl | 25 +- landing/src/data/site.ts | 23 +- landing/src/i18n/ui.ts | 138 +++++++++ landing/src/pages/[...lang]/register.astro | 310 +++++++++++++++++++++ landing/src/styles/main.css | 58 ++++ plan/12-build-order.md | 8 +- plan/14-deploy.md | 20 +- plan/16-landing-registration.md | 155 +++++++++++ tooling/render-config.sh | 16 +- 11 files changed, 766 insertions(+), 11 deletions(-) create mode 100644 landing/src/pages/[...lang]/register.astro create mode 100644 plan/16-landing-registration.md diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md index b1622a4..9977d2b 100644 --- a/.claude/skills/deploy/SKILL.md +++ b/.claude/skills/deploy/SKILL.md @@ -45,11 +45,25 @@ invoking this skill is the authorization to proceed, but: report (local changes on the host need manual resolution). - `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env` (needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`, - `DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`). + `DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`, `REGISTRATION_MODE`). It halts on + an invalid `REGISTRATION_MODE` (must be `invite` or `open`). - `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored; skips if already valid). Missing jar = minecraft crashloops with "Error opening zip file or JAR manifest missing". +2b. **Landing rebuild (only if needed).** The static site `www/` is gitignored — + `git pull` does NOT update it. If the pulled commits (from the step-1 + pre-check) touch `landing/`, or `REGISTRATION_MODE` changed, rebuild it; + otherwise skip: + ```bash + ssh cochi 'set -e + cd /home/ubuntu/mc/ulicraft-server-v1 + cd landing && set -a && . ../.env && set +a && pnpm run build' + ``` + Sourcing `.env` bakes `BASE_DOMAIN` + `REGISTRATION_MODE` (invite-field on/off) + into the output. A flag-only change just needs this rebuild + a `drasl` + restart (already covered by step 2's `up`), not the world downtime. + 3. **Verify.** ```bash ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps' diff --git a/.env.example b/.env.example index bf8acca..50df8ca 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,14 @@ BASE_DOMAIN=ulicraft.net RCON_PASSWORD=change-me-to-something-random +# Registration mode for self-hosted accounts (Drasl) + landing register form. +# invite = closed; guests need an admin-minted invite code (recommended on a +# public, internet-present host). Admin mints via POST /drasl/api/v2/invites. +# open = anyone can self-register with username+password (set [RateLimit]!). +# Consumed by BOTH render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite) +# and the landing build (-> show/hide the invite-code field). Default: invite. +REGISTRATION_MODE=invite + # caddy host-published port. The host's own nginx terminates TLS and # reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so # bind caddy to a high localhost-only port — only nginx should reach it. diff --git a/drasl/config/config.toml.tmpl b/drasl/config/config.toml.tmpl index d782d56..e4ac38f 100644 --- a/drasl/config/config.toml.tmpl +++ b/drasl/config/config.toml.tmpl @@ -1,6 +1,7 @@ # Drasl config — password-login mode (NO Keycloak/OIDC for now). # TOML only (drasl has no env support). Rendered by tooling/render-config.sh -# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted. +# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and +# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env). # Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port. # Public scheme is HTTPS — the host nginx terminates TLS in front of caddy. @@ -21,5 +22,27 @@ SignPublicKeys = false # Free-text owner label shown in the web UI (a string, not a table). ApplicationOwner = "Ulicraft" +# New-account registration (username+password). RequireInvite is derived from +# REGISTRATION_MODE in .env by render-config.sh: invite -> true, open -> false. +# When true, non-admin callers (web UI + landing register form) must supply a +# valid invite code; admins bypass. Mint codes via POST /drasl/api/v2/invites +# with an admin api token. +[RegistrationNewPlayer] +Allow = true +RequireInvite = ${REGISTRATION_REQUIRE_INVITE} + +# CORS: the landing register/account form (served from the apex) calls the +# Drasl API from the browser. Without this the API has NO CORS headers and the +# browser blocks every cross-origin call. Scope to the apex only — never "*", +# which would let any site script the auth API. Echo backfills AllowMethods +# (incl. PATCH/DELETE) and reflects requested headers (Content-Type/Authorization). +CORSAllowOrigins = ["https://${BASE_DOMAIN}"] + +# Rate limit the now public-facing API (anonymous POST /users etc). Token +# bucket; admins are exempt. Conservative for a 4-10 player server. +[RateLimit] +RequestsPerSecond = 5 +Burst = 10 + # OIDC block intentionally omitted for now (no Keycloak). # [[RegistrationOIDC]] <- future task diff --git a/landing/src/data/site.ts b/landing/src/data/site.ts index 07bf059..faa2ac9 100644 --- a/landing/src/data/site.ts +++ b/landing/src/data/site.ts @@ -4,6 +4,10 @@ // BASE_DOMAIN is read at build time; falls back to ulicraft.local. const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local"; +// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's +// render-config.sh. "invite" => the register form must collect an invite code. +const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite"; + // Minecraft / pack facts, surfaced in a few places. const MC_VERSION = "1.21.1"; @@ -12,6 +16,11 @@ export const site = { baseDomain: BASE_DOMAIN, mcVersion: MC_VERSION, + // Register form behaviour, driven by REGISTRATION_MODE in .env. + // true => show the invite-code field (Drasl RequireInvite = true) + // false => open self-registration + registrationRequiresInvite: REGISTRATION_MODE === "invite", + // Design knobs (replace the design's in-browser TweaksPanel). Baked at build. // mood: "grass" | "nether" | "end" // hero: "centered" | "split" | "spotlight" @@ -30,14 +39,24 @@ export const site = { // Drasl auth web UI + authlib-injector API root (public HTTPS). authUrl: `https://auth.${BASE_DOMAIN}`, authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`, + // Drasl REST API v2 root — the /register form calls this from the browser. + // Requires CORSAllowOrigins to include the apex in drasl config.toml. + draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`, // packwiz modpack entrypoint (public HTTPS, pack. subdomain). packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`, - // Static server-list panel (Status section). No fake live count — see plan - // 09-landing.md option B. + // NMSR avatar renderer root. Player heads come from OUR Drasl skins, not + // Mojang/Crafatar — head URL is `${avatarUrl}/headiso/?size=64`. + avatarUrl: `https://avatar.${BASE_DOMAIN}`, + + // Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown + // before/without JS; live count comes from mcstatus.io pinging the public + // address. mcstatus.io sets Access-Control-Allow-Origin:* → direct browser + // fetch, no proxy/key. Uptime Kuma stays the history + alerting backend. status: { slots: 10, + statusApi: `https://api.mcstatus.io/v2/status/java/${BASE_DOMAIN}`, }, // Honest stat tiles. Keys (numbers/versions) are language-neutral; labels diff --git a/landing/src/i18n/ui.ts b/landing/src/i18n/ui.ts index c9bc225..b37c5a8 100644 --- a/landing/src/i18n/ui.ts +++ b/landing/src/i18n/ui.ts @@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"]; export const LANG_LABEL: Record = { en: "EN", es: "ES", eu: "EU" }; // URL path per locale (en is default, served at root). export const LANG_PATH: Record = { en: "/", es: "/es/", eu: "/eu/" }; +// Register page path per locale (mirrors LANG_PATH: en at /register). +export const LANG_REGISTER_PATH: Record = { + en: "/register", + es: "/es/register", + eu: "/eu/register", +}; export interface UI { htmlLang: string; @@ -65,6 +71,39 @@ export interface UI { }; }; footer: { join: string; disc: string }; + register: { + navHome: string; // "← Home" link in nav + eyebrow: string; + title: string; + lead: string; + usernameLabel: string; + usernamePlaceholder: string; + passwordLabel: string; + passwordPlaceholder: string; + inviteLabel: string; + invitePlaceholder: string; + inviteHint: string; // where to get a code + submit: string; + submitting: string; + haveAccount: string; // "Already have an account?" + loginLink: string; // links to the Drasl web UI + successTitle: string; + successBody: string; // {name} = player name + uuidLabel: string; + skinTitle: string; + skinLead: string; + skinChoose: string; + modelLabel: string; + modelClassic: string; + modelSlim: string; + skinUpload: string; + skinUploading: string; + skinOk: string; + finish: string; // proceed to How to Join + errFields: string; + errSkinType: string; + errNetwork: string; + }; } export const ui: Record = { @@ -152,6 +191,39 @@ export const ui: Record = { join: "How to Join", disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.", }, + register: { + navHome: "← Home", + eyebrow: "Account", + title: "Create your Ulicraft account.", + lead: "One username and password for the server, your skin, and your cape. Takes a minute.", + usernameLabel: "Username", + usernamePlaceholder: "your in-game name", + passwordLabel: "Password", + passwordPlaceholder: "choose a password", + inviteLabel: "Invite code", + invitePlaceholder: "paste your invite code", + inviteHint: "Ask an admin for an invite code.", + submit: "Create account", + submitting: "Creating…", + haveAccount: "Already have an account?", + loginLink: "Manage it here", + successTitle: "Account created.", + successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.", + uuidLabel: "Player UUID", + skinTitle: "Set your skin (optional)", + skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.", + skinChoose: "Choose PNG…", + modelLabel: "Model", + modelClassic: "Classic", + modelSlim: "Slim", + skinUpload: "Upload skin", + skinUploading: "Uploading…", + skinOk: "Skin set!", + finish: "Continue to How to Join", + errFields: "Fill in every field.", + errSkinType: "Pick a PNG image.", + errNetwork: "Network error — try again.", + }, }, es: { @@ -238,6 +310,39 @@ export const ui: Record = { join: "Cómo entrar", disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.", }, + register: { + navHome: "← Inicio", + eyebrow: "Cuenta", + title: "Crea tu cuenta de Ulicraft.", + lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.", + usernameLabel: "Usuario", + usernamePlaceholder: "tu nombre en el juego", + passwordLabel: "Contraseña", + passwordPlaceholder: "elige una contraseña", + inviteLabel: "Código de invitación", + invitePlaceholder: "pega tu código de invitación", + inviteHint: "Pide un código de invitación a un admin.", + submit: "Crear cuenta", + submitting: "Creando…", + haveAccount: "¿Ya tienes cuenta?", + loginLink: "Gestiónala aquí", + successTitle: "Cuenta creada.", + successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.", + uuidLabel: "UUID del jugador", + skinTitle: "Pon tu skin (opcional)", + skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.", + skinChoose: "Elegir PNG…", + modelLabel: "Modelo", + modelClassic: "Clásico", + modelSlim: "Fino", + skinUpload: "Subir skin", + skinUploading: "Subiendo…", + skinOk: "¡Skin puesta!", + finish: "Continuar a Cómo entrar", + errFields: "Rellena todos los campos.", + errSkinType: "Elige una imagen PNG.", + errNetwork: "Error de red: inténtalo de nuevo.", + }, }, eu: { @@ -324,5 +429,38 @@ export const ui: Record = { join: "Nola sartu", disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.", }, + register: { + navHome: "← Hasiera", + eyebrow: "Kontua", + title: "Sortu zure Ulicraft kontua.", + lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.", + usernameLabel: "Erabiltzailea", + usernamePlaceholder: "zure jokoko izena", + passwordLabel: "Pasahitza", + passwordPlaceholder: "aukeratu pasahitz bat", + inviteLabel: "Gonbidapen-kodea", + invitePlaceholder: "itsatsi zure gonbidapen-kodea", + inviteHint: "Eskatu gonbidapen-kode bat admin bati.", + submit: "Sortu kontua", + submitting: "Sortzen…", + haveAccount: "Baduzu kontua?", + loginLink: "Kudeatu hemen", + successTitle: "Kontua sortuta.", + successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.", + uuidLabel: "Jokalariaren UUIDa", + skinTitle: "Jarri zure azala (aukerakoa)", + skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.", + skinChoose: "Aukeratu PNGa…", + modelLabel: "Eredua", + modelClassic: "Klasikoa", + modelSlim: "Mehea", + skinUpload: "Igo azala", + skinUploading: "Igotzen…", + skinOk: "Azala jarrita!", + finish: "Jarraitu Nola sartura", + errFields: "Bete eremu guztiak.", + errSkinType: "Aukeratu PNG irudi bat.", + errNetwork: "Sare-errorea: saiatu berriro.", + }, }, }; diff --git a/landing/src/pages/[...lang]/register.astro b/landing/src/pages/[...lang]/register.astro new file mode 100644 index 0000000..5ea347a --- /dev/null +++ b/landing/src/pages/[...lang]/register.astro @@ -0,0 +1,310 @@ +--- +import { site, HEAD_FONTS } from "../../data/site"; +import { + ui, + LANGS, + LANG_LABEL, + LANG_PATH, + LANG_REGISTER_PATH, + type Lang, +} from "../../i18n/ui"; +import Creeper from "../../components/Creeper.astro"; +import "../../styles/main.css"; + +// One static register page per locale: /register, /es/register, /eu/register. +export function getStaticPaths() { + return [ + { params: { lang: undefined }, props: { locale: "en" as Lang } }, + { params: { lang: "es" }, props: { locale: "es" as Lang } }, + { params: { lang: "eu" }, props: { locale: "eu" as Lang } }, + ]; +} + +const { locale } = Astro.props; +const t = ui[locale]; +const r = t.register; +const { theme } = site; +const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; +const homePath = LANG_PATH[locale]; +const requiresInvite = site.registrationRequiresInvite; + +// Strings the client script needs at runtime (kept minimal, passed via define:vars). +const clientCfg = { + apiUrl: site.draslApiUrl, + requiresInvite, + submit: r.submit, + submitting: r.submitting, + skinUpload: r.skinUpload, + skinUploading: r.skinUploading, + skinOk: r.skinOk, + successBody: r.successBody, + errFields: r.errFields, + errSkinType: r.errSkinType, + errNetwork: r.errNetwork, +}; +--- + + + + + + + {site.name} — {r.title} + + + + {LANGS.map((l) => )} + + + + + +
+
+
+
+ {r.eyebrow} +

{r.title}

+

{r.lead}

+ + +
+
+ + +
+
+ + +
+ {requiresInvite && ( +
+ + + {r.inviteHint} +
+ )} + + +
+ +

+ {r.haveAccount} {r.loginLink} +

+ + + +
+
+
+
+ + + + + + + diff --git a/landing/src/styles/main.css b/landing/src/styles/main.css index 6033c4e..4c162ed 100644 --- a/landing/src/styles/main.css +++ b/landing/src/styles/main.css @@ -588,6 +588,64 @@ section { position: relative; z-index: 1; } .reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; } } +/* ============================================================ + REGISTER PAGE + ============================================================ */ +.reg-wrap { width: min(560px, calc(100% - 48px)); margin-inline: auto; } +.reg-card { + background: var(--surface); + padding: clamp(24px, 4vw, 40px); + box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo), 0 8px 24px oklch(0 0 0 / 0.4); +} +.reg-card .eyebrow { display: block; margin-bottom: 12px; } +.reg-card h1 { font-size: clamp(28px, 4vw, 40px); } +.reg-card .lead { margin: 14px 0 0; font-size: 17px; } + +.reg-form { display: flex; flex-direction: column; gap: 18px; margin-top: 28px; } +.reg-field { display: flex; flex-direction: column; gap: 7px; } +.reg-field label { + font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; + color: var(--dim); text-transform: uppercase; line-height: 1.8; +} +.reg-input { + font-family: var(--font-body); font-size: 16px; color: var(--text); + background: var(--slot); border: 0; padding: 13px 14px; + box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); +} +.reg-input::placeholder { color: var(--dim); } +.reg-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.reg-field .hint { color: var(--dim); font-size: 13px; } + +.reg-models { display: flex; gap: 10px; } +.reg-models label { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-body); font-size: 15px; letter-spacing: 0; text-transform: none; + color: var(--muted); cursor: pointer; + background: var(--slot); padding: 9px 14px; + box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); +} +.reg-models input { accent-color: var(--accent); } + +.reg-msg { font-size: 15px; padding: 12px 14px; box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); } +.reg-msg[hidden] { display: none; } +.reg-msg.err { background: oklch(0.30 0.10 30); color: oklch(0.92 0.04 40); } +.reg-msg.ok { background: var(--slot); color: var(--accent); } + +.reg-foot { margin-top: 22px; color: var(--dim); font-size: 14.5px; } +.reg-foot a { color: var(--accent); } + +.reg-success[hidden], .reg-skin[hidden] { display: none; } +.reg-success { margin-top: 26px; display: flex; flex-direction: column; gap: 16px; } +.reg-uuid { + font-family: var(--font-mono); font-size: 17px; color: var(--gold); + background: var(--slot); padding: 8px 12px; word-break: break-all; + box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); +} +.reg-skin { margin-top: 8px; display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; } +.reg-skin h3 { font-size: 22px; } +.reg-skin .lead { font-size: 15px; } +.reg-file-name { color: var(--muted); font-size: 14px; } + /* ============================================================ RESPONSIVE ============================================================ */ diff --git a/plan/12-build-order.md b/plan/12-build-order.md index eda98d7..74eb7ea 100644 --- a/plan/12-build-order.md +++ b/plan/12-build-order.md @@ -31,6 +31,9 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps ## Phase 5 — landing 10. `feat(landing): Astro+TS onboarding site → www/` 11. `chore(landing): wire logo + .gitignore for www/` +11b. `feat(landing): /register page → Drasl API (B1) + REGISTRATION_MODE flag` + (CORS/RateLimit/RegistrationNewPlayer in drasl config.toml.tmpl; see + `16-landing-registration.md`). ## Phase 6 — launcher distribution 12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)` @@ -49,8 +52,9 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps ## Phase 10 — operational (no commits) - [ops] Prep online (once): `tooling/render-config.sh`, build landing - (`cd landing && pnpm run build`), `tooling/fetch-launcher.sh`, fetch - authlib-injector into `runtime/`. + (`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources + `.env` so `BASE_DOMAIN` + `REGISTRATION_MODE` bake into the static output), + `tooling/fetch-launcher.sh`, fetch authlib-injector into `runtime/`. - [ops] TLS: `tooling/issue-letsencrypt.sh`, then `tooling/render-nginx.sh --install`. - [ops] Bring up: `docker compose up -d --build`. diff --git a/plan/14-deploy.md b/plan/14-deploy.md index 8f46b93..bb33b28 100644 --- a/plan/14-deploy.md +++ b/plan/14-deploy.md @@ -37,6 +37,21 @@ ssh cochi 'set -e `render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz pack) from `.env` before the stack comes back up. +### Landing rebuild (not part of `compose`) + +The landing site (incl. `/register`) is static `www/`, gitignored — a `git pull` +does **not** update it. Rebuild on the host whenever `landing/` changed or you +toggled `REGISTRATION_MODE` (it bakes the invite-field on/off at build, and Drasl's +`RequireInvite` is derived from the same key by `render-config.sh`): + +```bash +( cd landing && set -a && . ../.env && set +a && pnpm run build ) # → www/ +docker compose restart drasl # apply CORS / RateLimit / RequireInvite +``` + +`down`/`up` is not needed for a landing-only or flag-only change — rebuild `www/` +and restart `drasl`. + ## What survives a hard restart Named volumes persist — world and auth data are safe: @@ -52,8 +67,9 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again. - SSH access as the `cochi` alias. - `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`, - `CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, and the Let's Encrypt block - (`render-config.sh` halts on an unset `BASE_DOMAIN`). + `CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`, + defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an + unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`). - Docker + compose plugin, `packwiz`, `envsubst`, `git`. - Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`. - **First deploy only** — online prep (build landing, fetch launcher, fetch diff --git a/plan/16-landing-registration.md b/plan/16-landing-registration.md new file mode 100644 index 0000000..f2de1fb --- /dev/null +++ b/plan/16-landing-registration.md @@ -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 + {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 `