Skip to content

fix: split guest.capacity_reached into three, and let a player erase their own account - #420

Merged
Taure merged 5 commits into
mainfrom
fix/guest-capacity-diagnosis
Aug 9, 2026
Merged

Taure merged 5 commits into
mainfrom
fix/guest-capacity-diagnosis

Conversation

@Taure

@Taure Taure commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Reported from the Defold community: a fresh device pair per launch, then
503 guest.capacity_reached, which cleared on its own and came back. The
reporter also asked for a way to remove guests, and there was not one they
could reach.

Three refusals under one code

asobi_guest_controller:create/2 collapsed the global limiter, the
unlinked-guest cap, and the count query failing into one
guest.capacity_reached with a warning that named no reason, no count and no
cap. The third is the one that matches the report - a cap does not self-clear
without deletions, and the limiter recovers inside a second - and it was being
reported to the client as a full deployment.

Now guest.rate_limited (429), guest.capacity_reached (503) and
guest.unavailable (503, the node could not count). All three still fail
closed; only the diagnosis changed. Every denial logs guest_create_denied
with a reason, and the cap denial logs the numbers it compared. Also fixes a
case_clause that crashed every guest create on an unparseable
guest_unlinked_cap.

The 429 carries a fixed retry-after hint rather than the limiter's real
remaining window: that bucket is keyed on one constant, so it is the whole
node's guest-signup budget, and the true figure is the tuning input for a
minimum-cost lockout of every new player. The precise number stays in the log.

Account erasure

POST /api/v1/players/me/erase, authenticated, subject is always the caller.
An account with a password must echo it; a guest or provider-only account has
no credential the client can re-present, so its session is the whole
confirmation. Erasing somebody else stays behind the ops plane's erasure
capability.

Not a guest route, and not primarily about the report. Apple App Store
guideline 5.1.1(v) has required in-app account deletion from any app offering
account creation since June 2022, Google Play asks the same, and GDPR Art. 17
is the second leg - so a backend whose only erasure paths are an operator
retention key and an operator secret cannot ship to a phone. A guest-only gate
would also have protected exactly the wrong accounts, leaving a player who
upgraded yesterday with no deletion path at all.

POST rather than DELETE because nova_request_plugin skips JSON decoding
entirely on GET and DELETE, so a DELETE could not carry the confirmation. Same
shape as /ops/players/:id/erase one layer up.

Erasure gets its own rate-limit bucket: the wrong-password path runs the
password KDF, which is the argument /auth/register's dedicated bucket was
added on (#157), and /players/* otherwise sits on the 300/s api limiter.

Every erasure writes an audit row. ADR 0007's reason for auditing erasure - the
data is gone, so the row is the only surviving evidence - does not depend on who
asked, and this is the only irreversible thing a player can do to themselves. It
is not the reaper's no-row case: one account, asked for by a person, and it can
succeed at most once because the transaction deletes the caller's own tokens.
The actor carries a new subject source, the one asobi_ops_auth:source()
value naming the absence of operator capabilities rather than a way of holding
them. ADR 0007 amended.

From review

The architecture guardian and the security reviewer both ran over the first
draft. What they caught:

  • The first draft gated on is_unclaimed_guest/2, which is the upgrade
    predicate. asobi_oauth_controller:link/1 sets no password and leaves the
    guest identity in place, so a guest who linked Google still satisfied it -
    the route would have erased a live account and orphaned its
    iap_transactions, while asobi_guest_reaper spares exactly that account.
    The reaper's predicate is now shared rather than duplicated.
  • The re-check takes FOR UPDATE. Without it the delete predicates on id, not
    on the credential, so a concurrent upgrade lost instead of winning.
  • after_commit/1 moved out of the try ... of body, here and in
    asobi_player_erase (pre-existing): exceptions raised there escape the
    catch, so an eviction timeout answered failure for an erasure that had
    already committed, and logged nothing.

Upstream

Taure/seki#17, found alongside: with a negative erlang:monotonic_time/1 (the
normal case for ~18 years of uptime) window_start/2 rounds the wrong way and
every sliding_window limiter denies earlier than configured, so
guest_global allows between 100 and 50/sec rather than 100. Not fixed here.

Follow-ups

  • No SDK has an erase method yet, so the route is server-side only until the
    next SDK sync.
  • asobi_saas should make guest_reap_after tenant-settable. Retention on a
    time axis and a subject right on demand are different primitives; this route
    is not a substitute.

Closes #419

Taure added 4 commits August 9, 2026 13:43
Three unrelated refusals shared one error code and one warning that named
none of them, so a field report of "the deployment is not creating more
guests right now" could not be diagnosed from the node. The worst case was
a repo error while counting: cached_unlinked_count/0 answers `unknown`, the
cap check mapped that to false, and a database fault was reported to the
client as a full deployment.

They are now three codes - guest.rate_limited (429, with retry_after),
guest.capacity_reached (503) and guest.unavailable (503) - and every denial
logs guest_create_denied with a reason, plus the count and cap it compared.
Still fails closed in all three cases; only the diagnosis changed. An
unparseable guest_unlinked_cap now falls back to the default instead of
case_clause-ing every guest create.

Adds DELETE /api/v1/auth/guest. Until now the only ways to remove a guest
were guest_reap_after and the ops erasure route, both operator keys a cloud
tenant cannot set, so a cloud deployment that accumulated throwaway guests
could not shed a single one. An authenticated, still-unclaimed guest now
erases itself through asobi_player_erase, with the same in-transaction
re-check the reaper uses so a concurrent upgrade wins. It writes no ops
audit row: the subject is the caller, not an operator, and a route any
guest can call in a loop must not be able to flood the operator's log.

Closes #419
…route

Guardian + security review of the previous commit. Four defects, the first
of which is the reason this is not a follow-up PR:

`is_unclaimed_guest/2` is the *upgrade* predicate: no password AND owns a
guest identity. asobi_oauth_controller:link/1 sets no password and leaves
the guest identity in place, so a guest who linked Google or Steam still
satisfies it - and the delete route would have erased a live account,
orphaning its iap_transactions and landing the next provider sign-in on a
new empty player. asobi_guest_reaper already had the right predicate (no
password AND *every* identity is a guest) and spares that account. Export
it and use it, so the sweep and the route cannot drift on who may be
erased.

The re-check now takes FOR UPDATE on the player row. Without it the
re-check was a plain READ COMMITTED select and the DELETE predicates on
id, not on hashed_password, so a concurrent upgrade lost rather than won -
the opposite of what the comment promised.

after_commit/1 moved out of the `try ... of` body. Exceptions raised there
escape the catch, so a gen_server timeout evicting the auth cache would
have answered 500 for an erasure that had already committed, with nothing
logged and a live WS session still running against deleted rows.

asobi_client_gate_plugin matched on path alone, so it gated DELETE
/api/v1/auth/guest - a pre-auth captcha check, reading its token from a
body a DELETE does not have, in front of an authenticated route. On any
deployment that configures a gate the new route was a permanent 403,
which is exactly the deployment shape it was written for.

Also: the route drops `options` (the path already registers it via the
POST, and a second insert throws under use_strict_routing), the
route-invariant helper now asks whether the path answers a preflight
rather than whether each entry lists the method, denial/1 gets a
fail-closed catch-all, and guest.rate_limited answers with a retry-after
header and a top-level retry_after like every other 429 here.
Replaces the guest-only delete route from the previous commits. Apple App
Store guideline 5.1.1(v) has required in-app account deletion from any app
offering account creation since June 2022, Google Play asks the same, and
GDPR Art. 17 is the second leg - so a backend whose only erasure path is an
operator secret cannot ship to a phone. That makes this a shipping gate for
every game rather than a guest convenience, and the guest gate was
protecting exactly the wrong accounts: a player who upgraded yesterday had
no deletion path at all.

POST /api/v1/players/me/erase, authenticated, subject is always the caller.
An account with a password must echo it; a guest or provider-only account
has no credential the client can re-present, so its session is the whole
confirmation. Erasing somebody else stays behind the ops plane's `erasure`
capability, which is the real asymmetry between the two routes.

POST, not DELETE: nova_request_plugin skips JSON decoding entirely on GET
and DELETE, so a DELETE could never carry the confirmation this route is
gated on. Same shape as the operator's /ops/players/:id/erase one layer up.

Erasure gets its own rate-limit bucket. The wrong-password path runs the
password KDF, which is the argument /auth/register's dedicated bucket was
added on (asobi#157), and /players/* would otherwise sit on the 300/s api
limiter.

Every erasure writes an audit row. ADR 0007's reason for auditing erasure -
the data is gone, so the row is the only surviving evidence - does not care
who asked, and this is the only irreversible thing a player can do to
themselves. It is not the reaper's no-row case: a self-erasure is one
account, asked for by a person, and can succeed at most once per account
because the transaction deletes the caller's own tokens. The actor carries
a new `subject` source, the one asobi_ops_auth:source() value that names
the absence of operator capabilities rather than a way of holding them.
ADR 0007 amended with the case and the reasoning.

guest.rate_limited now answers with a fixed retry-after hint rather than
the limiter's real remaining window. That bucket is keyed on one constant,
so it is the whole node's guest-signup budget, and the true figure is the
tuning input for a minimum-cost lockout of every new player (measured on
this stack in asobi_saas#249). The precise number stays in the log.
…ody/2

A controller that sets a response header builds its own {json, Status,
Headers, Body} and calls asobi_error:legacy_body/2 rather than legacy/2,
which hardcodes empty headers. guest.rate_limited does exactly that to set
retry-after, and the guard's regex did not match it - so a documented code
looked unreachable and the guide read as drifted. Same contract as
legacy/2, same obligation to be documented.
@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

🟡 Code Coverage — 75.6%

6922 of 9153 lines covered.

Surfaced building the client SDKs. Reusing auth.invalid_credentials meant a
wrong confirmation password answered 401, and every SDK reads a 401 as
"refresh the token pair and replay the request" - so each wrong password
burned a refresh rotation and replayed a destructive call, on a session that
was never in question. The caller is authenticated; they failed a step-up
check. player.confirmation_failed (403) says that, and leaves the SDK
machinery alone.
@Taure
Taure merged commit 8045400 into main Aug 9, 2026
17 checks passed
@Taure
Taure deleted the fix/guest-capacity-diagnosis branch August 9, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guest.capacity_reached is three different failures under one code, and a guest cannot be deleted at all on cloud

1 participant