Accounts v1: sign up with a username, sign in on any device, reset a password by email #364
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
spec
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
amaurycoudr/chess-project#364
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
A Player's identity lives in one browser. Their Guest secret sits in that browser's storage, so their decided Games and the Seats they hold are out of reach from a phone, a second computer, or the same browser after its storage is cleared. Everyone shows as "Guest", so an opponent has no name to recognise. There is no lasting identity to build history, a rating or a public profile on later.
Solution
A Player can create an Account with an email, a password and a Username: unique ignoring case, chosen at sign-up, fixed for good. They sign in and sign out; each signed-in browser is a Session held in two cookies the page cannot read (ADR 0030). A Player who forgets their password asks for a reset link by email and sets a new one. Sign-up, sign-in and reset requests are rate-limited. From an account page they can download everything stored about them, or delete the Account.
A signed-in browser takes Seats as its Account. Signing in on another device opens the same Room in the same Seat, as one more Connection on it (ADR 0034). A decided Game played as an Account belongs to it, and "My games" on any signed-in browser lists the Account's Games. Everywhere a name shows (beside each Seat in the Room, in "My games", on the Game page, in the PGN), an Account shows as its Username and a Guest as "Guest".
Playing without an Account works exactly as before.
User Stories
Sign-up and sign-in
Password reset
Rate limiting
Account page
Seats and Games as an Account
Owner and operations
Seams
loadConfig, unit tests beside the existing config tests: the signing secret, mail URL, sender and public URL are required, and the rate-limit overrides parse. Covers 35.rpc.fixtures.tson the PGlite database from the Postgres spec, extended with a cookie jar per client, an in-memory outbox standing in for the mail transport, a fake clock and a cheap hash cost. Covers 1, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 16, 17, 18, 19, 20, 22, 23, 24, 25, 27, 28, 29, 30, 37, 38:game.listandgame.getas the Account;apps/e2e), one new spec with two tests. Covers 26 and 36, and walks 1, 5, 6, 12, 16, 25 and 33 end to end:Implementation Decisions
ADRs that apply: ADR 0030 (Sessions as JWT cookies backed by a Postgres row), ADR 0028 (the origin guard, the only CSRF protection), ADR 0034 (a Guest or an Account holds a Seat and owns its Games), ADR 0027 (Nest implementing the oRPC contract), ADR 0026 (folder layering), ADR 0011 (front verticals), ADR 0019 (Query owns server state), ADR 0025 (Paraglide copy), ADR 0022 (e2e binds only to what a Player perceives).
ADRs this spec needs (written in the spec PR, with the owner's agreement):
Glossary (lands in
GLOSSARY.mdwith this spec):Schema (on top of the Postgres and Guest history migrations)
accounts: id, email, Username as typed, password hash, created at. Unique indexes on the lowercased email and the lowercased Username, through alowerindex helper reused from core-project.sessions, exactly as ADR 0030 lists: id, account id (cascading on delete), refresh token hash, the hash of the token it replaced and when that replacement happened (for the grace period), created at, last used at, expires at, user agent.password_reset_tokens: SHA-256 hash of the token, account id (cascading), expires at. A token row is deleted when used.games, owned by the Guest history spec, gains a nullable account owner per Color beside its Guest reference, referencingaccountswithON DELETE SET NULL. A check allows at most one owner per Color. An index on each account owner column with ended at, for "My games". A Game left with no owner after a deletion stays, readable by link.Contract: two new routers in
@chess/contracts, in core-project's shape, resource-oriented (ADR 0027).auth:signUp(POST): email, password, Username. Answers the signed-in Account and sets both cookies. Errors:EMAIL_TAKEN,USERNAME_TAKEN,USERNAME_NOT_ALLOWED,TOO_MANY_REQUESTS.signIn(POST): email, password. Answers the Account and sets both cookies. Errors:INVALID_CREDENTIALS(one code for an unknown email and a wrong password, with a dummy hash compared on an unknown email so timing matches),TOO_MANY_REQUESTS.signOut(POST): deletes the Session named in the access token and clears both cookies.refresh(POST): rotates the refresh token per ADR 0030. A failed refresh answersUNAUTHORIZEDand clears both cookies, so the browser is signed out rather than stuck.requestPasswordReset(POST): email. Always the same answer. Error:TOO_MANY_REQUESTS.resetPassword(POST): token, new password. Deletes every Session, opens a new one and sets its cookies. Error:RESET_LINK_INVALIDfor unknown, used and expired alike.usernameAvailability(GET, by Username): free, taken or not allowed. Usernames are public, so reading this reveals nothing new.account:me(GET): the signed-in Account (Username, email, join date), or none without a Session.export(GET): a JSON document of the Account, its Sessions (created, last used, expires, user agent; no token hash) and its Games (id, timestamps, Color, winner, ending, opponent name, UCI line and PGN, from the engine'sGame.pgn).delete(DELETE): the current password. Deletes the Account and clears both cookies. Error:WRONG_PASSWORD.TOO_MANY_REQUESTS(429) carries the seconds to wait, in aRetry-Afterheader and in its error data, for the front's message.UNAUTHORIZEDis 401. Both join the refusal status table._and-. The reserved and blocked lists stay server-side. Passwords are at least 8 characters and at most 72 bytes, bcrypt's limit.game.listandgame.getgain the occupant's name: an Account with its Username, or a Guest. No email, account id or hash ever crosses the wire. The front renders "Guest" from its catalogue.API
An
authfeature module, at the path ADR 0030's Governs column names, holds the Account and Session tables, the contract's two routers, the token service, the hashing service and the reset flow.HashingService(bcrypt) is reused from core-project. Its cost is a constructor option so tests run fast.jose(HS256), carrying the Account id and the Session id per ADR 0030.A global guard, reworked from core-project, reads the access cookie. Each procedure declares one of three modes:
gameprocedure and foraccount.me;An absent cookie in optional mode is an anonymous call. A present but expired or invalid one is
UNAUTHORIZEDin every mode, so the front refreshes instead of silently turning a seated Player into a Spectator. Every call with a valid token loads the Account, so a deleted one is refused at once (ADR 0030).@ActiveUser()(reworked from core-project) hands a procedure the Account. The RPC context gains the optional Account (id and Username) next to the Guest hash.Cookies:
The Room's occupant gains an Account variant (id and Username) beside the Guest one the Guest history spec gave it.
room.changesstream identifies its Connection once, when it opens.The decided-Game record names each Color's owner as an Account or a Guest. Writing an owner whose Account has since been deleted stores that Color with no owner, like a vacated Seat (ADR 0034 decision 7, as resolved in "How does an account relate to the Seat token?").
game.listandgame.getresolve the caller as their Account when there is a Session, else their Guest. Merging a Guest's Games into "My games" while signed in is the claim spec's.Rate limiter: a
common/service of sliding-window counters with an injected clock, called from the procedures it guards. Limits, each a config default an environment variable may override:signIn(failures only)signIn(failures only)requestPasswordResetrequestPasswordResetsignUpMail: a
mailmodule with one port ("send this message"). Its production adapter uses nodemailer over one SMTP URL from the environment. Tests inject an in-memory outbox through the app options, as they inject theRoomServicetoday. The reset email is plain text plus minimal HTML, in English, with the link only.Reset link:
Referer. The page reads it, removes it from the address bar and posts it.A sweep, run hourly on the injected clock, deletes expired Sessions and reset tokens.
Config gains:
Layer order: the new folders join the API's declared layer order (ADR 0026).
The API
CODING_STANDARDS.mddescribesroom/as the one feature module and the Seat token header rule. The PR body proposes the update rather than editing it.Front
auth/, at the path ADR 0030's Governs column names. It holds:/sign-up,/sign-in,/forgot-password,/reset-passwordand/account.account.me.lib/, since every vertical's client needs it and verticals cannot import each other. On UNAUTHORIZED it callsauth.refreshonce per tab at a time, then retries the call once. A failed refresh marks the Account query signed out.Mail and deploy (the checklist folded in by "Last product decisions before the account specs")
no-reply@chess.core-project.space. Resend's SPF and DKIM records go on thechesssubdomain, which keeps its reputation apart from the othercore-project.spaceapps. Add our own_dmarcatp=nonewith a report address.wizardskill: create the Resend account, verify the subdomain, add the DNS records, create the SMTP credential, store it as a secret, then send one real reset to the owner's inbox and check SPF, DKIM and DMARC pass in its headers.env:syncwrites its SMTP and API addresses. Tests tell messages apart by recipient.test:slowtask passes the Mailpit address through.Testing Decisions
rpc.integration.test.tsandorigin-guard.integration.test.tswithrpc.fixtures.tsfor the wire, extended with a cookie jar;setupUserhelper for anaccountClientfixture;config.test.tsforloadConfig;create-and-join.spec.tsandroom.fixtures.ts(openAnotherTab,expectBothSeated) for the e2e.Secureand__Host-cookies. The first ticket confirms this before anything builds on it.Out of Scope
Further Notes
@ActiveUser(),HashingService, thelowerunique index helper, and the auth tests. Dropped: one Session per user in Redis, bearer tokens in client storage, open CORS./to-tickets. A likely cut:mentioned in issue #378
mentioned in issue #379
mentioned in issue #380
mentioned in issue #381
mentioned in issue #382
mentioned in issue #383
mentioned in issue #384
marked this issue as related to #378
marked this issue as related to #379
marked this issue as related to #380
marked this issue as related to #381
marked this issue as related to #382
marked this issue as related to #383
marked this issue as related to #384
mentioned in merge request !523
mentioned in commit
28ec752adfmentioned in commit
0ab4a52d92mentioned in commit
8f876665f2mentioned in issue #399
mentioned in issue #400
mentioned in issue #402