feat: initialize panel access before linking Minecraft accounts

Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials.

Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
Lemon-miaow committed 2026-10-04 03:05:05 +08:00
1 parent 75845d8f58
commit efbbe27629
59 files changed
+1702 -1564

No files matched your search

+5
View File
@@ -643,6 +643,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
// Staff can designate their own game identity after panel setup. Players
// retain the in-game proof flow above.
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
// one-time code for the caller's chosen address, /verify redeems it and flips
// email_verified. App-tier like the link routes — proving control of your own
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
)
type linkedProfile struct {
Source string `json:"source"`
Name string `json:"name"`
ProfileUUID string `json:"profile_uuid"`
MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
}
func profileAPIBase(src AuthSource) string {
if src.APIURL != "" {
return strings.TrimRight(src.APIURL, "/")
}
const suffix = "/sessionserver/session/minecraft/hasJoined"
if strings.HasSuffix(src.URL, suffix) {
return strings.TrimSuffix(src.URL, suffix)
}
return ""
}
func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
type sourceView struct {
Tag string `json:"tag"`
LookupAvailable bool `json:"lookup_available"`
}
sources := make([]sourceView, 0, len(a.AuthSources))
for _, src := range a.AuthSources {
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
}
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
}
func (a *API) handleLookupProfile(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
profile, err := a.lookupProfile(r.Context(), q.Get("source"), q.Get("profile"))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
// handleLinkProfile is a staff designation, not proof of game-account ownership.
// The user must already have panel authority and a fresh login factor. A client
// supplies only the selected source and native role UUID; mapping and target user
// are determined on the server.
func (a *API) handleLinkProfile(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if !a.requireReauth(w, r, p) {
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req struct {
Source string `json:"source"`
ProfileUUID string `json:"profile_uuid"`
}
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if _, err := uuid.Parse(req.ProfileUUID); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "profile_uuid must be a role UUID"))
return
}
profile, err := a.lookupProfile(r.Context(), req.Source, req.ProfileUUID)
if err != nil {
writeError(w, r, err)
return
}
err = a.Repo.LinkAccount(r.Context(), p.UserID, profile.MCUUID, profile.AuthSource)
if errors.Is(err, ErrConflict) {
writeError(w, r, newError(http.StatusConflict, "already_linked", "that Minecraft role is linked to another user"))
return
}
if err != nil {
writeError(w, r, err)
return
}
a.audit(r, "account.link_profile", "")
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": profile.MCUUID, "auth_source": profile.AuthSource})
}
func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedProfile, error) {
input = strings.TrimSpace(input)
id, idErr := uuid.Parse(input)
if idErr != nil && !mcUsernameRe.MatchString(input) {
return nil, newError(http.StatusBadRequest, "bad_request", "provide a Minecraft role name or UUID")
}
var src AuthSource
found := false
for _, candidate := range a.AuthSources {
if candidate.Tag == source {
src, found = candidate, true
break
}
}
if !found {
return nil, newError(http.StatusBadRequest, "auth_source_unknown", "select a configured authentication source")
}
var target, method string
var body io.Reader
if src.Identity {
method = http.MethodGet
if idErr == nil {
target = strings.TrimSuffix(src.URL, "/hasJoined") + "/profile/" + strings.ReplaceAll(id.String(), "-", "")
} else {
target = mojangProfileAPI + url.PathEscape(input)
}
} else {
base := profileAPIBase(src)
if base == "" {
return nil, newError(http.StatusBadRequest, "auth_source_lookup_unsupported", "this source needs api_url for role lookup; game-code linking is still available")
}
if idErr == nil {
method, target = http.MethodGet, base+"/sessionserver/session/minecraft/profile/"+strings.ReplaceAll(id.String(), "-", "")
} else {
method, target = http.MethodPost, base+"/api/profiles/minecraft"
encoded, _ := json.Marshal([]string{input})
body = bytes.NewReader(encoded)
}
}
req, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Use the same bounded, redirect-free client as game authentication.
resp, err := authHTTPClient.Do(req)
if err != nil {
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source is unavailable")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
}
if resp.StatusCode != http.StatusOK {
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source returned HTTP %d", resp.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<16))
var profile sessionProfile
if method == http.MethodPost {
var profiles []sessionProfile
if err := decoder.Decode(&profiles); err != nil {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
}
for _, candidate := range profiles {
if strings.EqualFold(candidate.Name, input) {
profile = candidate
break
}
}
if profile.ID == "" {
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
}
} else if err := decoder.Decode(&profile); err != nil {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
}
profileID, err := uuid.Parse(profile.ID)
if err != nil || !mcUsernameRe.MatchString(profile.Name) ||
(idErr == nil && profileID != id) || (idErr != nil && !strings.EqualFold(profile.Name, input)) {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "the source returned a mismatched or invalid role")
}
canonical, err := canonicalProfileUUID(src, profile.ID)
if err != nil {
return nil, err
}
authSource := authSourceThirdParty
if src.Identity {
authSource = authSourceMojang
}
return &linkedProfile{Source: src.Tag, Name: profile.Name, ProfileUUID: profile.ID, MCUUID: canonical.String(), AuthSource: authSource}, nil
}
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/google/uuid"
)
func TestStaffProfileDesignation(t *testing.T) {
const native = "123456781234423482341234567890ab"
stubMojangNames(t)
upstreamCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalls++
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/profiles/minecraft":
var names []string
if err := json.NewDecoder(r.Body).Decode(&names); err != nil || len(names) != 1 || names[0] != "LemonMiaow" {
t.Errorf("lookup names = %v err = %v", names, err)
}
_, _ = w.Write([]byte(`[{"id":"` + native + `","name":"LemonMiaow"}]`))
case strings.HasPrefix(r.URL.Path, "/sessionserver/session/minecraft/profile/"):
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
case strings.HasSuffix(r.URL.Path, "/hasJoined"):
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
default:
w.WriteHeader(http.StatusNoContent)
}
}))
defer server.Close()
repo := newFakeRepo()
repo.seedUser(UserView{ID: "owner", Username: "owner", Role: "owner"})
a := newTestAPI(repo, newFakeCluster())
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
a.External = staticExternal{p: p}
src := AuthSource{Tag: "littleskin", Prefix: "LS", URL: server.URL + "/sessionserver/session/minecraft/hasJoined"}
a.AuthSources = []AuthSource{src, {Tag: "custom", URL: server.URL + "/custom-check"}}
h := a.ExternalHandler()
lookup := func(source, profile string) *httptest.ResponseRecorder {
return do(h, "GET", "/api/v1/account/link/profile?"+url.Values{"source": {source}, "profile": {profile}}.Encode(), "", nil)
}
w := do(h, "GET", "/api/v1/account/link/sources", "", nil)
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), server.URL) {
t.Fatalf("sources = %d %s", w.Code, w.Body.String())
}
w = lookup("littleskin", "LemonMiaow")
if w.Code != http.StatusOK {
t.Fatalf("lookup = %d %s", w.Code, w.Body.String())
}
var profile linkedProfile
if err := json.Unmarshal(w.Body.Bytes(), &profile); err != nil {
t.Fatal(err)
}
canonical, _ := canonicalProfileUUID(src, native)
if profile.MCUUID != canonical.String() || profile.AuthSource != "thirdparty" || len(repo.links) != 0 {
t.Fatalf("preview = %+v links = %v", profile, repo.links)
}
game := httptest.NewRecorder()
a.handleHasJoined(game, httptest.NewRequest("GET", "/session/minecraft/hasJoined?username=LemonMiaow&serverId=abc123", nil))
var authenticated sessionProfile
if err := json.Unmarshal(game.Body.Bytes(), &authenticated); err != nil || game.Code != http.StatusOK || authenticated.ID != strings.ReplaceAll(profile.MCUUID, "-", "") {
t.Fatalf("preview differs from game identity: %d %s, err = %v", game.Code, game.Body.String(), err)
}
body := `{"source":"littleskin","profile_uuid":"` + native + `"}`
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK || repo.links[canonical.String()] != "owner" {
t.Fatalf("bind = %d %s links = %v", w.Code, w.Body.String(), repo.links)
}
// Server-side designation is idempotent and never consumes game link codes.
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK || len(repo.links) != 1 {
t.Fatalf("repeat = %d %s", w.Code, w.Body.String())
}
repo.links[canonical.String()] = "another-user"
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusConflict || repo.links[canonical.String()] != "another-user" {
t.Fatal("designation overwrote another user")
}
w = lookup("custom", "LemonMiaow")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "auth_source_lookup_unsupported" {
t.Fatalf("custom = %d %s", w.Code, w.Body.String())
}
before := upstreamCalls
w = lookup("unknown", "LemonMiaow")
if w.Code != http.StatusBadRequest || upstreamCalls != before {
t.Fatal("unknown source queried an upstream")
}
w = do(h, "POST", "/api/v1/account/link/profile", `{"source":"littleskin","profile_uuid":"`+native+`","user_id":"victim"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatal("caller can select another account")
}
// A local staff session must prove its factor before designating a role.
p.ViaSession = true
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
before = upstreamCalls
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" || upstreamCalls != before {
t.Fatalf("stale staff session = %d %s", w.Code, w.Body.String())
}
p.ReauthAt = a.now()
repo.links[canonical.String()] = "owner"
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("proven staff session = %d %s", w.Code, w.Body.String())
}
// A player's session never reaches either lookup or designation.
p.Role = "user"
before = upstreamCalls
for _, w := range []*httptest.ResponseRecorder{lookup("littleskin", "LemonMiaow"), do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)} {
if w.Code != http.StatusForbidden {
t.Fatalf("player route = %d %s", w.Code, w.Body.String())
}
}
if upstreamCalls != before {
t.Fatal("player reached role lookup")
}
}
func TestProfileLookupRejectsInvalidResponses(t *testing.T) {
const native = "123456781234423482341234567890ab"
for _, tc := range []struct {
name, body string
status, want int
}{
{"missing", "", 204, 404},
{"unavailable", "", 503, 503},
{"invalid JSON", "broken", 200, 502},
{"different UUID", `{"id":"223456781234423482341234567890ab","name":"LemonMiaow"}`, 200, 502},
{"invalid name", `{"id":"` + native + `","name":"invalid name"}`, 200, 502},
{"redirect", "", 302, 503},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.AuthSources = []AuthSource{{Tag: "test", APIURL: server.URL}}
_, err := a.lookupProfile(t.Context(), "test", native)
var apiErr *apiError
if !errors.As(err, &apiErr) || apiErr.status != tc.want {
t.Fatalf("lookup err = %v, want %d", err, tc.want)
}
})
}
}
func TestOfficialProfileUUIDIsPreserved(t *testing.T) {
const native = "123456781234423482341234567890ab"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
}))
defer server.Close()
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.AuthSources = []AuthSource{{Tag: "mojang", Identity: true, URL: server.URL + "/session/minecraft/hasJoined"}}
profile, err := a.lookupProfile(t.Context(), "mojang", native)
if err != nil || profile.MCUUID != uuid.MustParse(native).String() || profile.AuthSource != "mojang" {
t.Fatalf("profile = %+v err = %v", profile, err)
}
}
+2 -3
View File
@@ -5,14 +5,13 @@ import (
"net/http"
)
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
// Pre-session install-state probe. Until `felis setup` creates an Owner, local sign-in is
// off and every login door answers 403 local_auth_disabled, so the sign-in page would
// offer four doors that all fail. This Public route lets the page say instead that no
// Owner exists yet and how to bind one.
//
// It discloses one bit: whether the install is still unclaimed. Claiming it needs root
// on the host (`felis setup` or the break-glass console) plus a Minecraft join whose
// link code is typed into that terminal; no web door works before then, so knowing the
// on the host (`felis setup` or the break-glass console); no web door works before then, so knowing the
// bit gives a remote caller nothing to act on. It must answer while local auth is off,
// so unlike its sibling doors it is not gated on local_auth_enabled.
//
+16 -10
View File
@@ -78,6 +78,7 @@ type AuthSource struct {
Tag string
Prefix string
URL string
APIURL string // optional Yggdrasil API root for role lookup
Identity bool
}
@@ -145,15 +146,12 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
// nor onto another source's. resolveHasJoined has already screened both shapes and
// skipped unusable ones as failed; the two guards below are the last line before
// anything leaves, kept even though nothing reaches them.
var canonical uuid.UUID
if src.Identity {
id, err := uuid.Parse(prof.ID)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = id
} else {
canonical, err := canonicalProfileUUID(src, prof.ID)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
if !src.Identity {
// A third-party source is untrusted input, its name included: nothing stops a
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
// characters, all of which must not be relayed straight into the proxy's player
@@ -162,7 +160,6 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
// already keeps the two apart as identities, but the proxy's player registry is
@@ -224,6 +221,15 @@ func prefixedName(prefix, name string) string {
return p + name
}
// canonicalProfileUUID is shared by game login and staff-initiated role binding.
// Keep the source's native ID byte-for-byte: existing third-party identities use it.
func canonicalProfileUUID(src AuthSource, nativeID string) (uuid.UUID, error) {
if src.Identity {
return uuid.Parse(nativeID)
}
return uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+nativeID)), nil
}
// mojangProfileAPI answers the one question that decides a rename: is this username
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
// instead of the real Mojang.
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"strings"
)
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` host bootstrap
// flow mints a one-time token and prints a URL like:
//
// https://op.console.<root>/setup?token=<raw>
@@ -58,7 +58,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
}
// Hash the raw token — only the hash is stored (mirroring session cookies and
// setup token creation in performSetupMCBind).
// setup token creation in performSetupOwner).
sum := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(sum[:])
+34 -59
View File
@@ -264,86 +264,61 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
return userID, mcUUID, authSource, nil
}
// CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound
// account to the passwordless Owner (role='owner'), enables local auth, and stores
// the one-time first-login token in one transaction. It is the `felis setup`
// MC-bind path: the operator enters limbo, runs /link, and types the code here.
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
// login can never self-elevate — this DELIBERATELY elevates: an unlinked UUID is
// born directly as staff, and an already-linked account (player OR staff) is
// promoted in place, preserving its id so any live sessions and its username
// survive. The elevation is gated by the caller's local-root break-glass
// authority, not by anything in-band. Returns the Owner's (userID, mcUUID,
// authSource); an absent or expired code is ErrLinkCodeInvalid and consumes
// nothing. Any failure in the auth-toggle or token writes rolls the elevation and
// code consumption back, leaving the operator able to retry setup.
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (string, string, string, error) {
// CompleteOwnerSetup creates the first Owner and a one-time panel login under
// host-root authority. An unfinished setup renews the link without resetting the
// account; an Owner with a login factor is left untouched (ErrConflict).
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", "", "", err
return "", "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows):
return "", "", "", ErrLinkCodeInvalid
case err != nil:
return "", "", "", err
// Serialize first-run creation as well as link renewal, including the case
// where there is no user row to lock yet.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('felis.owner_setup'))`); err != nil {
return "", "", err
}
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
// staff row (role='owner') with a uuid-derived username; an already-linked
// account is promoted to role='owner' in place (idempotent when it already is),
// keeping its id and username. Setup elevates on purpose, so there is no staff
// refusal here — that guard belongs to the player path only.
userID := newUserID
switch err := tx.QueryRowContext(ctx,
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
userID, username := newUserID, "owner"
var onboarded bool
err = tx.QueryRowContext(ctx,
`SELECT u.id, u.username, u.email_verified OR EXISTS (
SELECT 1 FROM webauthn_credentials c WHERE c.user_id = u.id)
FROM users u WHERE u.role IN ('owner', 'admin') AND u.deleted_at IS NULL
ORDER BY (u.role = 'owner') DESC, u.created_at, u.id LIMIT 1 FOR UPDATE`,
).Scan(&userID, &username, &onboarded)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role, created_at) VALUES ($1, $2, 'owner', $3)`,
newUserID, mcUUID, now); err != nil {
return "", "", "", fmt.Errorf("create owner: %w", err)
newUserID, username, now); err != nil {
return "", "", fmt.Errorf("create owner: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid, auth_source, verified_at) VALUES ($1, $2, $3, $4)`,
newUserID, mcUUID, authSource, now); err != nil {
return "", "", "", fmt.Errorf("write account link: %w", err)
}
userID = newUserID
case err != nil:
return "", "", "", err
default:
if _, err := tx.ExecContext(ctx,
`UPDATE users SET role = 'owner' WHERE id = $1`, userID); err != nil {
return "", "", "", fmt.Errorf("promote owner: %w", err)
}
return "", "", err
case onboarded:
return userID, username, ErrConflict
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO platform_settings (key, value, updated_at) VALUES ($1, $2::jsonb, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
LocalAuthEnabledKey, "true", now); err != nil {
return "", "", "", fmt.Errorf("enable local auth: %w", err)
return "", "", fmt.Errorf("enable local auth: %w", err)
}
// A renewed link replaces any unused links for this account.
if _, err := tx.ExecContext(ctx, `DELETE FROM setup_tokens WHERE user_id = $1`, userID); err != nil {
return "", "", err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO setup_tokens (token_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, $4)`,
tokenHash, userID, tokenExpiresAt, now); err != nil {
return "", "", "", fmt.Errorf("mint setup token: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", "", "", fmt.Errorf("consume link code: %w", err)
return "", "", fmt.Errorf("mint setup token: %w", err)
}
if err := tx.Commit(); err != nil {
return "", "", "", err
return "", "", err
}
return userID, mcUUID, authSource, nil
return userID, username, nil
}
// QuotaCheck reports whether accepting a server with resource spec `incoming`
@@ -3008,8 +2983,8 @@ func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now tim
}
// CreateSetupToken persists a one-time first-web-login token, storing only its
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-creation
// path uses CompleteOwnerSetup so Owner creation, local auth, and this token
// commit atomically; this lower-level helper remains for callers that already
// established the user. The token is redeemed exactly once by RedeemSetupToken.
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {