fix(setup): stop the forced-onboarding gate trapping players who have no email

setup_required is what the SPA polls to decide whether the onboarding wall is
still owed, and it disagreed with the middleware that actually enforces the
wall. requireOnboarded lifts on a verified email OR an enrolled passkey;
setup_required answered `u.Email == "" || !hasPasskey`. A console-tier player
joins through the bind-code door with no email at all — by design, there is no
SMTP at that point — so the email term never clears and the SPA keeps them on
the setup screen forever, even after they enroll the passkey that already
unlocked the API for them.

The predicate now lives in one place (setupRequired) and both endpoints call
it, so the next edit to the unlock condition cannot drift them apart again.
Keying it on EmailVerified rather than email presence is the deliberate part:
presence is exactly the term that trapped the no-email player, and it was also
wrong on its own terms — an unverified address is not an authentication
factor, so it was never what the lockdown could safely lift on.

Also lands the regression test for the mechanism behind the live claim-403
report: /me/servers answers 200 for a bind-onboarded player (which is why the
dashboard renders the 认领 button at all) while claim, wake and status all
answer 403 with code "setup_required" — i.e. the refusal comes from
requireOnboarded before the handler, not from isOwnerOrAdmin inside it, which
would have said "forbidden". Enrolling a passkey and changing nothing else
lifts all three, which isolates the gate as the sole cause. The backend authz
is correct; the button that leads a locked-down player into a 403 is the
frontend's to hide.
This commit is contained in:
flyemoji committed 2026-07-22 14:40:28 +09:00
1 parent 60b0ec97ac
commit c4c964578e
4 files changed
+198 -10

No files matched your search

+4 -10
View File
@@ -104,11 +104,8 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
// Setup completes on email recorded + passkey enrolled. NOT email_verified:
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
// login credential, so it — not email verification — is the durable gate.
"setup_required": u.Email == "" || !hasPasskey,
// setup_required MUST mirror requireOnboarded's unlock (api.go:615); see setupRequired.
"setup_required": setupRequired(u.EmailVerified, hasPasskey),
})
}
@@ -135,10 +132,7 @@ func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
// Setup completes on email recorded + passkey enrolled. NOT email_verified:
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
// login credential, so it — not email verification — is the durable gate.
"setup_required": u.Email == "" || !hasPasskey,
// setup_required MUST mirror requireOnboarded's unlock (api.go:615); see setupRequired.
"setup_required": setupRequired(u.EmailVerified, hasPasskey),
})
}