Hint targets combined with AND: without Steam, role and higher, new
users by registration_date, empty library flag. Four automatic hints
(NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE), on by default; the
admin turns each on or off (audited). Users snooze one for 7 days (X)
or hide it for good (tick), stored on the server. Hint text may use
**bold** and [text](url).
Forgot password with a single-use reset link (1 hour, ACTIVE users
only, same answer for every address), change password (current session
stays, others end), admin reset mail with audit row, 'password
changed' mail. Login and refresh wait for a running password change.
Remove the platform_mails opt-out. Broadcasts go to all active users.
V28 drops the column; the PUT /api/users/settings/platform-mails
endpoint and the footer line about turning mails off are gone.
Registrations become two-phase: first UNCONFIRMED with a 24-hour
single-use email link, then PENDING after confirmation and password
entry. Prevents registering with someone else's address. Admins see
unconfirmed requests and can delete them; they cannot accept them.
Adds endpoints for PLATFORM_ADMIN to send mail to all active users or
picked ones. Broadcasts and account mails share the same outbox;
broadcasts take the rest of the daily limit after 10 mails are kept free
for account mail. Users can opt out via settings. History is kept in
mail_broadcast with recipient, sent and failed counts. The footer says
where to opt out.
The public profile ticket now carries the user's role so Core can check
ownership of shop items. PLATFORM_ADMIN gets every catalog item for free.
The role comes from the users table; a ticket without the claim means USER.
A10 A11 A12 A13 A14 A15 X10 X11 X12: role check denies outside a request, no API versioning, open-in-view off, no passwords in toString, X-Forwarded-For only from private peers, CORS from APP_FRONTEND_URL, non-root image.
Profile "local" starts without keys: LocalKeysEnvironmentPostProcessor
makes a new signing key per start and refuses to run next to prod. A
Flyway callback in db/local adds dev@localhost as PLATFORM_ADMIN with a
linked Steam ID.
Every service method a controller calls carries @RequiresRole or @Public, checked by a method interceptor and by ArchUnit. deleteByAdmin needs PLATFORM_ADMIN; the Steam callback's linkVerifiedSteamId stays unmarked.
The localhost default pointed Auth at itself in Docker, so the nightly cleanup failed silently at 04:15. Without a default a missing APP_CORE_INTERNAL_URL stops the start.
users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
DELETE /api/users/me with the password sets users.deleted_at, ends all logins and frees the Steam ID. A nightly job removes users flagged 30 days ago, Core first, and prunes Core orphans. Dry run by default.
The Steam ID is no longer typed in at registration or in the settings.
A logged-in user links it through Steam OpenID 2.0: Auth checks the
answer with Steam (check_authentication) and stores the ID as verified
(V11 steam_id_verified). A one-time state plus a binding cookie tie the
callback to the browser that started the link, so a foreign Steam login
link cannot move someone's Steam ID into another account.
Typed-in IDs from before stay unverified; a verified link from another
account takes them away. DELETE /api/users/steam-link unlinks.
The login token moves out of localStorage into an HttpOnly cookie
(15 min), so a script on the page can no longer read it. A random
refresh token (30 days, sliding) renews it; only its SHA-256 is
stored. Each refresh replaces the token, and a replaced token that
comes back revokes every token of that login.
Unsafe requests need an X-TBP-Client or Authorization header, and
CORS allows only the exact frontend origin. A missing or expired
token now answers 401 instead of 403, so the frontend can refresh.
Auth serves its Ed25519 public key at GET /.well-known/jwks.json, so
Core can find the key by the token kid. Startup fails on a blank
JWT_KID, because compose turns an unset value into "" and Core finds
keys only by kid.
10 failed registrations per IP block it for 15 minutes (429 TOO_MANY_ATTEMPTS with Retry-After). Login and registration now take an attempt before the check, so a burst of parallel requests cannot pass the limit.
user_settings.public_profile (off by default, needs a display name).
GET /api/public/profiles/{displayName} answers only for enabled users with a
public profile and returns a 10-minute EdDSA ticket for Core. A token with a
typ claim is no login, so the ticket cannot be used as one.