feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1923 -1444

No files matched your search

+27 -5
View File
@@ -100,6 +100,12 @@ type API struct {
// console (console.<root_domain>) the gate is inert.
AdminHostname string
// PanelHostname is the player console host (console.<root_domain>) from
// config. Used to render user-facing panel URLs (the /link code's panel_url
// hint); empty falls back to console.<RootDomain> (see panelURL), mirroring
// AdminHostname's fallback.
PanelHostname string
// WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs
// on the wake lever). Zero disables throttling.
WakeCooldown time.Duration
@@ -143,6 +149,21 @@ type API struct {
streamCap *streamLimiter
}
// panelURL returns the public player-console origin ("https://console.<root>"),
// preferring the configured PanelHostname and falling back to the conventional
// console.<RootDomain> label — the same convention hostIsAdminConsole applies
// to the operator host. Empty when neither is configured (a bare test API).
func (a *API) panelURL() string {
host := a.PanelHostname
if host == "" && a.RootDomain != "" {
host = "console." + a.RootDomain
}
if host == "" {
return ""
}
return "https://" + host
}
// now returns the current time using the injected clock.
func (a *API) now() time.Time {
if a.Now != nil {
@@ -237,7 +258,6 @@ func (a *API) internalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
{Method: "GET", Pattern: "/api/v1/servers/by-host/{host}", h: a.handleByHost},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
@@ -282,10 +302,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
// before returning the canonical profile (handlers_hasjoined.go).
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
// Op-login (passwordless console login): an in-game op requests a login that
// the web owner/admin approves, then redeems for a session. Internal face
// carries the pending queue and the approve action (service-token auth, no
// Principal); the external face carries the start/status/finish the op drives.
// Op-login (passwordless op.console login): a staff member starts the login
// on the web, and an ONLINE in-game admin vouches for it via velocity's
// /felis web op approve. Internal face carries the pending queue and the
// approve action (service-token auth, no Principal); the public face carries
// the start/status/finish the staff member's browser drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
@@ -362,6 +383,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission},
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/luckperms/{player}", h: a.handleAccessLuckPermsInfo},
{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
// Identity self-read (spec §14 tiering): the panel reads this once at boot to
// learn its own tier and decide which navigation surfaces to render. App-tier —
+1 -40
View File
@@ -52,7 +52,7 @@ type fakeRepo struct {
linkAuthSource map[string]string
// world backups (spec §7, §22). A nil slice lists empty.
backups []fakeBackup
// local-password auth (spec §B). staff is keyed by username (the login key);
// session auth (spec §B, passwordless). staff is keyed by username (the login key);
// sessions by token_hash; settings by key. They mirror the PG contract so the
// hermetic tests exercise the same fail-closed semantics the integration impl
// honors.
@@ -1600,45 +1600,6 @@ func TestMeIdentity(t *testing.T) {
})
}
// ---- by-host ----
func TestByHost(t *testing.T) {
cl := newFakeCluster()
cl.bySub["survival"] = &ServerInfo{Name: "survival", Subdomain: "survival", Phase: "Running", Ready: true}
api := newTestAPI(newFakeRepo(), cl)
h := api.InternalHandler()
tok := map[string]string{"Authorization": "Bearer "} // okInternal ignores it
t.Run("foreign domain rejected", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/survival.evil.example.org", "", tok)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
})
t.Run("multi-label rejected", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/a.b."+testRoot, "", tok)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
})
t.Run("unknown server 404", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/creative."+testRoot, "", tok)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("found", func(t *testing.T) {
w := do(h, "GET", "/api/v1/servers/by-host/survival."+testRoot, "", tok)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var info ServerInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Name != "survival" {
t.Fatalf("unexpected body %s err %v", w.Body.String(), err)
}
})
}
// ---- fleet (SysAdmin cockpit read) ----
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
+111
View File
@@ -403,6 +403,117 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) {
})
}
// lpPermissionView is one parsed LuckPerms permission entry returned by the
// luckperms read projector. World is surfaced only when the entry carries a
// world= context (the one context the panel renders); Value comes from the
// entry's color code (LuckPerms renders granted nodes green, negated red).
type lpPermissionView struct {
Node string `json:"node"`
Value bool `json:"value"`
World string `json:"world,omitempty"`
}
// maxLPInfoPages bounds how many "permission info" pages the read projector
// chases per request. LuckPerms paginates its reply, so one command shows only
// the first page; we follow the header's page count up to this cap.
// ponytail: 10 pages ≈ 150 entries — raise if a real user outgrows it.
const maxLPInfoPages = 10
// handleAccessLuckPermsInfo is the read projector for a player's LuckPerms
// state: it runs "lp user <player> permission info" over the same owner-gated
// RCON spine as every access mutation and returns a best-effort parse — parent
// groups split out from plain permission nodes — PLUS the raw reply, like the
// whitelist/players/banlist reads. Page 1 goes through issueAccessCommand (the
// gate); further pages are fetched best-effort directly, so a mid-fetch failure
// keeps what was already read instead of erroring a half-served response.
// No audit (a read).
func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
player := r.PathValue("player")
if !mcNameRe.MatchString(player) {
writeError(w, r, errInvalidPlayer)
return
}
out, ok := a.issueAccessCommand(w, r, name, "lp user "+player+" permission info")
if !ok {
return
}
raw := out
entries, pages := parseLuckPermsInfo(out)
for page := 2; page <= pages && page <= maxLPInfoPages; page++ {
more, err := a.Console.RunCommand(r.Context(), name,
fmt.Sprintf("lp user %s permission info %d", player, page))
if err != nil {
break // best-effort: keep the pages we have
}
raw += "\n" + more
e, _ := parseLuckPermsInfo(more)
entries = append(entries, e...)
}
// Split parent groups ("group.<name>", granted, no context) from plain
// permission nodes. A negated or world-scoped group.* entry stays in
// permissions — folding it into groups would lose the negation/scope.
groups := []string{}
permissions := []lpPermissionView{}
for _, e := range entries {
if g, isGroup := strings.CutPrefix(e.Node, "group."); isGroup && e.Value && e.World == "" && lpCtxRe.MatchString(g) {
groups = append(groups, g)
continue
}
permissions = append(permissions, e)
}
writeJSON(w, http.StatusOK, map[string]any{
"player": player, "groups": groups, "permissions": permissions, "output": raw,
})
}
var (
// lpEntryRe matches one "permission info" entry: the "> " marker, then any
// legacy color codes, then the node (lpNodeRe's charset). Anchoring on the
// marker rather than lines follows banEntryRe's rationale: RCON concatenates
// multi-message replies with a server-dependent separator, so a line split is
// unreliable. Group 1 keeps the color codes so the entry's value survives the
// later color strip (§a = granted, §c = negated).
lpEntryRe = regexp.MustCompile(`>\s*((?:§[0-9a-fk-or])*)([A-Za-z0-9_.*-]{1,64})`)
// lpPageRe reads the pagination header ("page 1 of 3") AFTER color stripping.
lpPageRe = regexp.MustCompile(`page\s+(\d+)\s+of\s+(\d+)`)
// lpColorRe strips legacy §-color codes.
lpColorRe = regexp.MustCompile(`§[0-9a-fk-or]`)
// lpWorldRe reads a world= context from an entry's color-stripped tail.
lpWorldRe = regexp.MustCompile(`world=([A-Za-z0-9_-]{1,48})`)
)
// parseLuckPermsInfo extracts permission entries and the total page count from
// one "lp user <player> permission info" reply. Best-effort and
// LuckPerms-specific (INTEGRATION-ONLY against a real server) — the caller
// always returns the raw reply alongside, so an unrecognised format loses
// nothing. An entry's value defaults to granted when no color code precedes the
// node (a color-stripping RCON transport); pages is 0 when no header parses.
func parseLuckPermsInfo(out string) (entries []lpPermissionView, pages int) {
matches := lpEntryRe.FindAllStringSubmatchIndex(out, -1)
for i, m := range matches {
colors := out[m[2]:m[3]]
node := out[m[4]:m[5]]
// The entry's tail (up to the next marker) carries its contexts.
tailEnd := len(out)
if i+1 < len(matches) {
tailEnd = matches[i+1][0]
}
tail := lpColorRe.ReplaceAllString(out[m[5]:tailEnd], "")
e := lpPermissionView{Node: node, Value: !strings.Contains(colors, "§c")}
if wm := lpWorldRe.FindStringSubmatch(tail); wm != nil {
e.World = wm[1]
}
entries = append(entries, e)
}
if pm := lpPageRe.FindStringSubmatch(lpColorRe.ReplaceAllString(out, "")); pm != nil {
pages, _ = strconv.Atoi(pm[2])
}
return entries, pages
}
// parseWhitelistOutput extracts player names from vanilla's "whitelist list"
// reply, whose format is "There are N whitelisted player(s): a, b, c" (and "There
// are no whitelisted players" / a trailing colon for the empty case). The parse
+39 -10
View File
@@ -51,6 +51,26 @@ func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// deriveAuthSource infers the auth source from the UUID's version nibble when
// the minting backend omitted auth_source. Felis-nano rewrites every
// third-party profile to a name-based UUIDv3 under its namespace before it ever
// reaches the proxy, while Mojang profiles keep their random v4 — so on a
// nano-fronted deployment the version nibble alone identifies the source, and
// no Java plugin has to learn the field. Anything unparseable keeps the
// historical Mojang-priority default.
func deriveAuthSource(mcUUID string) string {
hex := strings.ReplaceAll(mcUUID, "-", "")
if len(hex) != 32 {
return authSourceMojang
}
switch hex[12] {
case '3':
return authSourceThirdParty
default:
return authSourceMojang
}
}
// newLinkCode returns a cryptographically random, unambiguous link code.
func newLinkCode() (string, error) {
buf := make([]byte, linkCodeLen)
@@ -88,12 +108,13 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
// Default an omitted source to Mojang (spec §10 priority) but reject an
// unrecognised one — a typo'd source must not silently land as a stored value
// the panel will later mislabel.
// Default an omitted source from the UUID's version nibble (v3 = felis-nano
// third-party rewrite, v4 = Mojang; see deriveAuthSource) but reject an
// unrecognised explicit one — a typo'd source must not silently land as a
// stored value the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = authSourceMojang
authSource = deriveAuthSource(req.MCUUID)
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
@@ -110,10 +131,17 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
// panel_url tells the in-game side where the player redeems the code, so
// every plugin renders the same address from one source of truth instead of
// each baking in its own hostname. Omitted when no hostname is configured.
resp := map[string]any{
"code": code,
"expires_at": expiresAt.UTC(),
})
}
if u := a.panelURL(); u != "" {
resp["panel_url"] = u
}
writeJSON(w, http.StatusCreated, resp)
}
// handleLinkStatus reports whether an in-game UUID has finished linking yet — the
@@ -125,8 +153,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
// as a QR → player scans it on a phone already signed in to console.<root_domain>
// → that web session's verify (handleLinkVerify) writes the durable account_links
// row bound to THAT user → velocity polls HERE for the same UUID it minted against
// → on {linked:true} it admits the player, binding the in-game session to user_id
// with no reconnect — the whole point of scanning over typing.
// → on {linked:true} it admits the player with no reconnect — the whole point of
// scanning over typing. The response is deliberately just the boolean: the plugin
// keys everything on the UUID it already holds, so no identity detail crosses back.
//
// The poll is keyed by the verified mc_uuid velocity already holds, not by the
// scanned code, so it is a pure idempotent read of the durable link (UserByMCUUID):
@@ -147,7 +176,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
_, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
switch {
case errors.Is(err, ErrNotFound):
// Not linked yet. For the poller this is simply "keep waiting": velocity
@@ -159,7 +188,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "user_id": userID})
writeJSON(w, http.StatusOK, map[string]any{"linked": true})
}
// linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
+24
View File
@@ -120,6 +120,30 @@ func TestCreateLinkCode(t *testing.T) {
}
}
})
t.Run("panel_url points at the web console", func(t *testing.T) {
// The mint response carries the redeem address so every plugin renders the
// same hostname from one source of truth (derived console.<root> here).
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["panel_url"]; got != "https://console."+testRoot {
t.Errorf("panel_url = %v, want https://console.%s", got, testRoot)
}
})
t.Run("omitted auth_source with a v3 UUID derives thirdparty", func(t *testing.T) {
// A felis-nano rewrite is a name-based UUIDv3; the version nibble alone must
// classify it so no Java plugin has to learn the auth_source field.
const v3UUID = "33333333-3333-3333-8333-333333333333"
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+v3UUID+`"}`, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("derived authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("explicit thirdparty is stored", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
+9 -9
View File
@@ -7,11 +7,11 @@ import (
)
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
// This is the passwordless counterpart of handleLogin and the returning-player
// counterpart of handleBindRedeem: an account that already proved control of an
// email (email_verified, migration 0010) logs back in with a one-time code mailed
// to that address — no password, no in-game Bind Code. The two halves are Public,
// pre-session routes: the caller has no principal yet, so identity is resolved from
// This is the returning-player counterpart of handleBindRedeem: an account that
// already proved control of an email (email_verified, migration 0010) logs back in
// with a one-time code mailed to that address — no password exists anywhere in the
// product, and no in-game Bind Code is needed the second time. The two halves are
// Public, pre-session routes: the caller has no principal yet, so identity is resolved from
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
// code.
//
@@ -55,9 +55,9 @@ type loginEmailStartRequest struct {
}
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
// pre-session). It gates on local sessions being enabled — like handleLogin and
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// pre-session). It gates on local sessions being enabled — like handleBindRedeem
// and the op-login door, minting a code toward a felis_session while SessionAuth
// would reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
// An address with no verified account yields the SAME 202 as a successful send with
// no code minted: the response never distinguishes the two, and the reservation is
@@ -164,7 +164,7 @@ type loginEmailVerifyRequest struct {
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
// on success mints the same host-only felis_session as handleLogin. A missing account,
// on success mints the same host-only felis_session as handleBindRedeem. A missing account,
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
+4 -4
View File
@@ -56,7 +56,7 @@ func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string)
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
// address resolves the mixed-case stored account, the code is mailed to the account's
// STORED casing (the address of record), and redeeming it mints the same host-only
// felis_session as the password door — single-use, audited on both halves by the
// felis_session as the other session doors — single-use, audited on both halves by the
// account's username. The redeem never rewrites users.email (login re-proves an
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
// untouched by definition.
@@ -113,7 +113,7 @@ func TestLoginEmailVertical(t *testing.T) {
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
}
// The HttpOnly cookie is the whole point — same contract as handleLogin.
// The HttpOnly cookie is the whole point — same contract as every session door.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
@@ -208,8 +208,8 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
// credential-minting routes — same rationale as handleLogin), and the input gates
// that must reject before any mint or lookup.
// credential-minting routes — same rationale as handleBindRedeem), and the input
// gates that must reject before any mint or lookup.
func TestLoginEmailGates(t *testing.T) {
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
+4 -23
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"strings"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
@@ -43,25 +42,6 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}
// handleByHost resolves host=subdomain.{root_domain} to its server (spec §7
// GET /servers/by-host/{host}). The host is validated against the configured
// root domain — the only place the deployment zone enters the lookup.
func (a *API) handleByHost(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.PathValue("host"))
if err := naming.ValidateHostname(host, a.RootDomain); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_host", "invalid host: %v", err))
return
}
subdomain := strings.TrimSuffix(host, "."+a.RootDomain)
info, err := a.Cluster.GetBySubdomain(r.Context(), subdomain)
if err != nil {
a.writeLookupError(w, r, err)
return
}
writeJSON(w, http.StatusOK, info)
}
// handleReady accepts a backend's push that a server is up (spec §7
// /internal/servers/{name}/ready). The RCON probe is the authoritative gate, so
// this is advisory: it audits the signal and returns 204.
@@ -159,8 +139,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
return
}
// Global running-server cap (spec §9.1), shared with the external wake. velocity
// treats 503 at_capacity as "cluster full, hold the player", distinct from the
// 429 cooldown's "already waking, keep waiting".
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
// NOT enqueue them (nothing is coming up, so waiting would only strand them),
// distinct from the 429 cooldown's "already waking, keep waiting".
ok, err := a.withinRunningCap(r.Context(), info)
if err != nil {
writeError(w, r, err)
@@ -278,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
// the lobby GUI needs to render one server tile, composed from the lifecycle view
// (phase/ready/players from the CRD status) and the business ownership row
// (claimable = nobody owns it yet). It is the only internal response carrying
// claimable, so it has its own shape — the §11 list/by-host/status views never
// claimable, so it has its own shape — the §11 list/status views never
// expose ownership, and folding owner data into ServerInfo would force the
// lifecycle layer to consult Postgres.
//
+6 -5
View File
@@ -32,7 +32,7 @@ import (
// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6
// keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make
// blind brute force non-viable, and rate-limiting is deferred to the edge exactly as
// for the public /auth/login. The idempotent returning-player branch (a UUID already
// for the other public session doors (email-OTP, op-login). The idempotent returning-player branch (a UUID already
// linked to a role=user player is fetched, not re-created) is a DELIBERATE standing
// "log in via the game" door, not merely first-time onboarding: control of the
// in-game identity is the root of trust, so re-minting a code always re-grants a
@@ -62,15 +62,16 @@ type bindRedeemRequest struct {
// handleBindRedeem redeems a Bind Code into a player account + session (Public). It is
// the account-less player's only door into console.<root_domain>: no prior principal,
// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting
// public route, so it requires local sessions to be enabled and a JSON content type
// (the cross-site-forgery guard) and mints the same host-only felis_session cookie.
// no Zero Trust in front (unlike op.console). Like the email-OTP login door it is a
// cookie-minting public route, so it requires local sessions to be enabled and a JSON
// content type (the cross-site-forgery guard) and mints the same host-only
// felis_session cookie.
// The code is trimmed and uppercased so a player who typed it with stray spaces or in
// lowercase still matches, mirroring handleLinkVerify.
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
// The minted session is a felis_session cookie, honored only when local sessions
// are enabled (SessionAuth). Minting one while they are off would hand back a dead
// cookie, so refuse loudly and consistently with handleLogin. This couples the
// cookie, so refuse loudly, consistently with the other session doors. This couples the
// player bootstrap to the same toggle that gates op.console local login; a future
// deployment wanting player cookies without local admin login would decouple them
// in SessionAuth — out of scope here (KNOWN coupling).
+1 -1
View File
@@ -227,7 +227,7 @@ func TestBindRedeemExpiredCode(t *testing.T) {
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
// SessionAuth would not honor: with local sessions off it returns 403, never a dead
// cookie, mirroring handleLogin.
// cookie, mirroring the email-OTP login door.
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
+13 -10
View File
@@ -10,14 +10,15 @@ import (
// op.console STAFF login (spec §B op-login): the two-factor door for the most
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0012), all Public
// three-call state machine over op_login_requests (migration 0016), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// velocity drives on behalf of online admins:
// for the in-game side (approve is driven by velocity's /felis command; pending has
// no consumer yet — see handleOpLoginPending):
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
@@ -26,9 +27,9 @@ import (
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
// column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). Only a linked
// role=admin account may approve; velocity additionally gates the command on
// in-game op, so the API check is defence in depth over its own user table.
// request via velocity's /felis command (internal approve). The API's own user
// table is the sole authority: only a UUID linked to a role=admin account may
// approve (velocity's command runs for any player and relies on this check).
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
@@ -317,8 +318,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
}
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
// face). Velocity polls it and pushes the waiting requests to online admins, who
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
// face). Today no plugin consumes it: the approver learns the request id out-of-band
// (the op.console start screen shows it to the person logging in) and runs
// /felis web op approve <id>. The route exists so velocity can later push the waiting
// list to online admins without an API change. Internal-only: velocity holds a service
// token and no pending request is secret to the operator crew.
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
@@ -340,8 +343,8 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — defence in depth over velocity's own
// in-game op gate, checked against the API's authoritative user table.
// and refuses unless that account is role=admin — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
}
+2 -2
View File
@@ -20,8 +20,8 @@ import (
// all collapse to one op_login_invalid envelope; an early-but-correct code is
// preserved (approval is read before the code is consumed), and a wrong code costs
// an attempt without burning the approval.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
// API's own user table, defence in depth over velocity's in-game op gate.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the API's own
// user table is the sole gate (velocity's command itself is unprivileged).
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
+14 -14
View File
@@ -12,11 +12,12 @@ func statusPath(mcUUID string) string {
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
// proves its load-bearing invariant: the internal completion poll reports the link
// only after the WEB verify writes it, and reports it bound to the exact Principal
// that verified — never to a UUID the poll itself could name. velocity mints and
// polls on the internal face (it holds no web Principal); the durable bind is born
// on the external face from a logged-in user. That split is the whole security
// model of the scan, so the test drives both faces of one API.
// only after the WEB verify writes it. velocity mints and polls on the internal
// face (it holds no web Principal); the durable bind is born on the external face
// from a logged-in user. That split is the whole security model of the scan, so
// the test drives both faces of one API. The poll carries ONLY the boolean — the
// plugin keys everything on the UUID it already holds, so no identity detail
// (user_id) ever crosses back, in either state.
func TestQRLoginCompletionPollVertical(t *testing.T) {
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
user := &Principal{UserID: "u-scan", Email: "[email protected]", Role: "user"}
@@ -54,9 +55,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Now the poll flips: velocity sees linked:true and the user_id it must bind the
// in-game session to — and that user_id is the verifier's, the only identity the
// poll could ever return, since the poll cannot mint a link of its own.
// Now the poll flips: velocity sees linked:true and admits the player. The
// response stays identity-free — linked is the entire contract.
w = do(ih, "GET", statusPath(mcUUID), "", nil)
if w.Code != http.StatusOK {
t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String())
@@ -65,8 +65,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
if b["linked"] != true {
t.Fatalf("post-verify poll body = %v, want linked:true", b)
}
if got := b["user_id"]; got != user.UserID {
t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID)
if _, ok := b["user_id"]; ok {
t.Fatalf("post-verify poll leaked user_id: %v", b)
}
}
@@ -101,8 +101,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["linked"] != true || b["user_id"] != "u-held" {
t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b)
if b["linked"] != true {
t.Fatalf("poll %d body = %v, want linked:true", i, b)
}
}
// The read must not have disturbed the durable link.
@@ -112,8 +112,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
}
// TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It
// reads who a UUID is linked to — a fact the public web face must not be able to
// fish out by UUID — so crossing onto the external face must 404, not answer.
// reads whether a UUID is linked — a fact the public web face must not be able
// to fish out by UUID — so crossing onto the external face must 404, not answer.
func TestQRLoginStatusFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())
+16
View File
@@ -203,6 +203,22 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
// Player counts are presentational and best-effort, mirroring handleFleet's
// owner join: the list exists for ownership/claim state, so a cluster hiccup
// must degrade to 0/0 counts, never 500 the whole list. The CRD status is the
// only source of live counts (spec §1) — Postgres never stores them.
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
byName := make(map[string]ServerInfo, len(infos))
for _, s := range infos {
byName[s.Name] = s
}
for i := range servers {
if info, ok := byName[servers[i].Name]; ok {
servers[i].PlayersOnline = info.PlayersOnline
servers[i].PlayersMax = info.PlayersMax
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
}
+9 -1
View File
@@ -437,7 +437,15 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
return
}
if body.AuthSource == "" {
body.AuthSource = "mojang"
// Same version-nibble inference as the mint path (handlers_account.go):
// defaulting to mojang here would leave a force-linked thirdparty UUID
// outside the reclaim guard.
body.AuthSource = deriveAuthSource(body.MCUUID)
}
if !validAuthSource(body.AuthSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
return
}
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
+5 -4
View File
@@ -818,10 +818,11 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool
// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim
// exception (spec §B3). The EXISTS joins account_links to users on exactly three
// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the
// linked user is an admin. It intentionally does not test password_hash: an Operator
// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash
// and must be protected just the same — the hash is orthogonal to "is staff" and "logs
// in via the Login Server". Keyed by UUID, the only identity velocity holds.
// linked user is an admin. It intentionally does not test HOW the account signs in:
// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local
// passwordless door and must be protected just the same — the sign-in method is
// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the
// only identity velocity holds.
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
var ok bool
err := p.db.QueryRowContext(ctx,
+16 -11
View File
@@ -18,13 +18,18 @@ type ServerRecord struct {
}
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
// may auto-start, or may claim.
// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in
// Postgres — handleMyServers joins them best-effort from the CRD status
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never
// a 500.
type MyServerView struct {
Name string `json:"name"`
Subdomain string `json:"subdomain"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"`
Name string `json:"name"`
Subdomain string `json:"subdomain"`
Owned bool `json:"owned"`
Claimable bool `json:"claimable"`
Phase string `json:"phase,omitempty"`
PlayersOnline int32 `json:"playersOnline"`
PlayersMax int32 `json:"playersMax"`
}
// AuditEntry is one row written to audit_logs (spec §6). The actor is the Access
@@ -197,8 +202,8 @@ type Repo interface {
//
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
// - the uuid is not yet linked → create a role='user' player row with id
// newUserID (NULL password_hash, username derived from the uuid so it is unique
// and deterministic), write the account_links binding, consume the code, and
// newUserID (username derived from the uuid so it is unique and
// deterministic), write the account_links binding, consume the code, and
// return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code;
@@ -456,9 +461,9 @@ type Repo interface {
// Mojang-priority reclaim must never bar them. The predicate is exactly three
// conjuncts: the UUID is linked (account_links), that link authenticated via
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin').
// It deliberately does NOT require a local password hash: an Operator who signs
// in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin'
// with no password_hash, and must be protected all the same — a password hash is
// It deliberately does NOT ask HOW the staff account signs in: an Operator may
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
// passwordless door, and must be protected all the same — the sign-in method is
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
// UUID, a Mojang-sourced link, or a non-admin link all yield false, so the
// exception never broadens to ordinary thirdparty players (Mojang priority still