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:
2026-06-09 21:55:51 +02:00
parent 067e990306
commit df8ffca53e
11 changed files with 766 additions and 11 deletions

View File

@@ -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'

View File

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

View File

@@ -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

View File

@@ -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/<uuid>?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

View File

@@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"];
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
// URL path per locale (en is default, served at root).
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
// Register page path per locale (mirrors LANG_PATH: en at /register).
export const LANG_REGISTER_PATH: Record<Lang, string> = {
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<Lang, UI> = {
@@ -152,6 +191,39 @@ export const ui: Record<Lang, UI> = {
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<Lang, UI> = {
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<Lang, UI> = {
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.",
},
},
};

View File

@@ -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,
};
---
<!doctype html>
<html
lang={t.htmlLang}
data-mood={theme.mood}
data-head={theme.headFont}
style={`--font-head: ${headFont}`}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {r.title}</title>
<meta name="description" content={r.lead} />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)}
</head>
<body>
<!-- NAV -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<nav class="nav-links">
<a href={homePath}>{r.navHome}</a>
</nav>
<span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language">
{LANGS.map((l) => (
<a
href={LANG_REGISTER_PATH[l]}
class:list={[l === locale && "on"]}
aria-current={l === locale ? "true" : undefined}
>{LANG_LABEL[l]}</a>
))}
</div>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div>
</header>
<main id="top">
<section class="pad">
<div class="reg-wrap">
<div class="reg-card">
<span class="eyebrow">{r.eyebrow}</span>
<h1>{r.title}</h1>
<p class="lead">{r.lead}</p>
<!-- REGISTER FORM -->
<form class="reg-form" id="reg-form" novalidate>
<div class="reg-field">
<label for="reg-username">{r.usernameLabel}</label>
<input class="reg-input" id="reg-username" name="username"
type="text" autocomplete="username" placeholder={r.usernamePlaceholder} required />
</div>
<div class="reg-field">
<label for="reg-password">{r.passwordLabel}</label>
<input class="reg-input" id="reg-password" name="password"
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
</div>
{requiresInvite && (
<div class="reg-field">
<label for="reg-invite">{r.inviteLabel}</label>
<input class="reg-input" id="reg-invite" name="invite"
type="text" placeholder={r.invitePlaceholder} required />
<span class="hint">{r.inviteHint}</span>
</div>
)}
<div class="reg-msg err" id="reg-error" role="alert" hidden></div>
<button class="mc-btn primary" id="reg-submit" type="submit">{r.submit}</button>
</form>
<p class="reg-foot">
{r.haveAccount} <a href={site.authUrl}>{r.loginLink}</a>
</p>
<!-- SUCCESS + SKIN -->
<div class="reg-success" id="reg-success" hidden>
<div>
<span class="eyebrow" style="color:var(--accent)">{r.successTitle}</span>
<p class="lead" id="reg-success-body" style="margin-top:10px"></p>
</div>
<div class="reg-field">
<label>{r.uuidLabel}</label>
<div class="reg-uuid" id="reg-uuid"></div>
</div>
<div class="reg-skin" id="reg-skin">
<div>
<h3>{r.skinTitle}</h3>
<p class="lead">{r.skinLead}</p>
</div>
<div class="reg-field">
<label class="mc-btn sm" for="reg-skin-file" style="align-self:flex-start">{r.skinChoose}</label>
<input id="reg-skin-file" type="file" accept="image/png" hidden />
<span class="reg-file-name" id="reg-skin-name"></span>
</div>
<div class="reg-field">
<label>{r.modelLabel}</label>
<div class="reg-models">
<label><input type="radio" name="skin-model" value="classic" checked /> {r.modelClassic}</label>
<label><input type="radio" name="skin-model" value="slim" /> {r.modelSlim}</label>
</div>
</div>
<div class="reg-msg" id="reg-skin-msg" role="status" hidden></div>
<button class="mc-btn" id="reg-skin-upload" type="button">{r.skinUpload}</button>
</div>
<a class="mc-btn primary" href={`${homePath}#join`}>{r.finish}</a>
</div>
</div>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="footer">
<div class="wrap footer-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}
</p>
</div>
</footer>
<script define:vars={{ cfg: clientCfg }}>
const $ = (id) => document.getElementById(id);
const form = $("reg-form");
const errBox = $("reg-error");
const submitBtn = $("reg-submit");
const successBox = $("reg-success");
const headers = { "Content-Type": "application/json" };
let apiToken = null;
let player = null;
const showErr = (box, msg) => {
box.textContent = msg;
box.hidden = false;
};
const apiMessage = async (res, fallback) => {
try {
const data = await res.json();
return data && data.message ? data.message : fallback;
} catch {
return fallback;
}
};
form.addEventListener("submit", async (e) => {
e.preventDefault();
errBox.hidden = true;
const username = $("reg-username").value.trim();
const password = $("reg-password").value;
const inviteEl = $("reg-invite");
const invite = inviteEl ? inviteEl.value.trim() : null;
if (!username || !password || (cfg.requiresInvite && !invite)) {
showErr(errBox, cfg.errFields);
return;
}
submitBtn.disabled = true;
submitBtn.textContent = cfg.submitting;
try {
const body = { username, password, playerName: username };
if (invite) body.inviteCode = invite;
let res = await fetch(`${cfg.apiUrl}/users`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
showErr(errBox, await apiMessage(res, cfg.errNetwork));
return;
}
// Log in to get an API token for the (optional) skin upload.
res = await fetch(`${cfg.apiUrl}/login`, {
method: "POST",
headers,
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
showErr(errBox, await apiMessage(res, cfg.errNetwork));
return;
}
const data = await res.json();
apiToken = data.apiToken;
player = data.user.players[0];
form.hidden = true;
$("reg-uuid").textContent = player.uuid;
$("reg-success-body").textContent = cfg.successBody.replace("{name}", player.name);
successBox.hidden = false;
} catch {
showErr(errBox, cfg.errNetwork);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = cfg.submit;
}
});
// ---- Skin upload ----
const fileInput = $("reg-skin-file");
const fileName = $("reg-skin-name");
const skinMsg = $("reg-skin-msg");
const skinBtn = $("reg-skin-upload");
fileInput.addEventListener("change", () => {
skinMsg.hidden = true;
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
});
const readBase64 = (file) =>
new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result).split(",")[1]);
fr.onerror = reject;
fr.readAsDataURL(file);
});
skinBtn.addEventListener("click", async () => {
skinMsg.hidden = true;
skinMsg.classList.remove("ok", "err");
const file = fileInput.files && fileInput.files[0];
if (!file) {
skinMsg.classList.add("err");
showErr(skinMsg, cfg.errSkinType);
return;
}
if (file.type !== "image/png") {
skinMsg.classList.add("err");
showErr(skinMsg, cfg.errSkinType);
return;
}
const model = document.querySelector('input[name="skin-model"]:checked').value;
skinBtn.disabled = true;
skinBtn.textContent = cfg.skinUploading;
try {
const skinBase64 = await readBase64(file);
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
method: "PATCH",
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
body: JSON.stringify({ skinBase64, skinModel: model }),
});
if (!res.ok) {
skinMsg.classList.add("err");
showErr(skinMsg, await apiMessage(res, cfg.errNetwork));
return;
}
skinMsg.classList.add("ok");
showErr(skinMsg, cfg.skinOk);
} catch {
skinMsg.classList.add("err");
showErr(skinMsg, cfg.errNetwork);
} finally {
skinBtn.disabled = false;
skinBtn.textContent = cfg.skinUpload;
}
});
</script>
</body>
</html>

View File

@@ -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
============================================================ */

View File

@@ -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`.

View File

@@ -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

View 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>
```

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# render-config.sh — turn *.tmpl files into real configs via envsubst.
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
# file (caddy, toml) is left untouched.
# Substitutes ONLY a small whitelist so literal $-syntax that belongs to the
# target file (caddy, toml) is left untouched.
# Halts on any unset var or missing template (cautious: no half-rendered output).
set -euo pipefail
@@ -10,9 +10,19 @@ cd "$(dirname "$0")/.." # repo root
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
# REGISTRATION_MODE (invite|open) -> Drasl RequireInvite boolean. drasl is
# TOML-only with no env support, so we derive the literal here and substitute it.
: "${REGISTRATION_MODE:=invite}"
case "$REGISTRATION_MODE" in
invite) REGISTRATION_REQUIRE_INVITE=true ;;
open) REGISTRATION_REQUIRE_INVITE=false ;;
*) echo "REGISTRATION_MODE must be 'invite' or 'open' (got '$REGISTRATION_MODE')" >&2; exit 1 ;;
esac
export REGISTRATION_REQUIRE_INVITE
render() { # $1=template $2=output
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
envsubst '${BASE_DOMAIN}' < "$1" > "$2"
envsubst '${BASE_DOMAIN} ${REGISTRATION_REQUIRE_INVITE}' < "$1" > "$2"
echo "rendered $2"
}