Accounts v1: sign up with a username, sign in on any device, reset a password by email #364

Closed
opened 2026-10-11 16:08:46 +00:00 by root · 22 comments
root commented 2026-10-11 16:08:46 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#364, opened by @amaurycoudr on 2026-10-11 16:08 UTC.


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

  1. As a Player, I want to create an Account with an email, a password and a Username, so that I have one identity on every device.
  2. As a Player signing up, I want to see whether a Username is free while I type, so that I don't submit the form only to be refused.
  3. As a Player signing up, I want a Username that breaks the rules (length, characters, reserved, offensive) refused with the reason, so that I know what to change.
  4. As a Player signing up with an email or a Username already used, in any letter case, I want to be told which one is taken, so that I can sign in instead or pick another name.
  5. As a Player, I want to be signed in as soon as my Account exists, so that I can play straight away.
  6. As a Player, I want to sign in with my email in any letter case and my password, so that I reach my Account from any device.
  7. As a Player who mistypes, I want one message for a wrong email or a wrong password, so that a stranger cannot learn which emails have an Account from the sign-in form.
  8. As a Player, I want to stay signed in on a browser for 30 days after I last used it, so that I don't sign in every visit.
  9. As a Player with two tabs open, I want both to keep working when my Session refreshes, so that one tab never signs the other out.
  10. As a Player, I want to sign out of this browser only, so that my other devices stay signed in.
  11. As a Player, I want the site's navigation to show my Username when I'm signed in and a way to sign in when I'm not, so that I always know who I'm playing as.

Password reset

  1. As a Player who forgot their password, I want to ask for a reset link by email, so that I can get back into my Account.
  2. As anyone asking for a reset link, I want the same answer whether or not the email has an Account, so that the form reveals nothing.
  3. As a Player, I want the link to work once and only for a limited time, so that an old email cannot be used against me.
  4. As a Player opening an expired, used or unknown link, I want to be told so and offered a new one, so that I'm not stuck.
  5. As a Player who has reset their password, I want every other browser of my Account signed out and this one signed in, so that whoever had my old password loses access.

Rate limiting

  1. As a Player whose Account is being guessed at, I want sign-in attempts on my email limited, so that my password cannot be brute-forced.
  2. As a Player, I want reset emails to my address limited, so that nobody can flood my inbox.
  3. As the owner, I want sign-ups from one address limited, so that a script cannot fill the database or squat Usernames.
  4. As a Player who hits a limit, I want to be told when I can try again, so that I don't keep failing.

Account page

  1. As a signed-in Player, I want an account page showing my Username, email and join date, so that I can see what the site knows about me.
  2. As a signed-in Player, I want to download a file with my Account, my Sessions and my Games, so that I can keep or move my data.
  3. As a signed-in Player, I want to delete my Account after confirming with my password, so that my email and password are gone for good.
  4. As a Player whose opponent deleted their Account, I want our Game to stay in my "My games", so that their choice doesn't erase my history.

Seats and Games as an Account

  1. As a signed-in Player, I want a Room I create or a Seat I take to be held by my Account, so that it follows me to my other devices.
  2. As a signed-in Player, I want to open a Room on another signed-in device and be back in my Seat, with my Color and my turn, so that I can switch devices mid-Game.
  3. As a signed-in Player with my Seat open on two devices, I want to stay one Player, never take the other Seat, so that I cannot play myself.
  4. As a Player who took a Seat as a Guest and then signed in, I want to keep playing that Seat in this browser, so that signing in never kicks me out of a Game (ADR 0034).
  5. As a Player who signs out mid-Game, I want my Seat to stay taken and disconnected, and to resume it when I sign in again, so that signing out is not leaving.
  6. As a signed-in Player, I want a Game decided while my Account held a Seat stored as mine, so that it shows in "My games" on every device.
  7. As a signed-in Player, I want "My games" to list my Account's Games, newest first, so that my history is the same everywhere.
  8. As a signed-in Player opening one of my Games, I want it oriented to my Color with my side labelled "You", so that it reads as I played it.
  9. As a Player, I want each Seat in a Room labelled with its occupant's Username, or "Guest", so that I know who I'm playing.
  10. As a Player, I want "My games", the Game page and the exported PGN to name each side by Username, or "Guest", so that a record says who played.

Owner and operations

  1. As the owner, I want the API to refuse to start without its signing secret, mail URL and public URL, so that a missing setting fails loudly.
  2. As the owner, I want password reset emails sent through Resend in production and caught by Mailpit everywhere else, so that previews, dev and the browser suite never email a real address.
  3. As the owner, I want expired Sessions and reset links removed on their own, so that the tables don't grow forever.
  4. As the owner, I want only hashes stored for passwords, refresh tokens and reset tokens, so that a dump or the Gateway shows nothing that signs anyone in.

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.
  • The RPC wire, through the real app in process with rpc.fixtures.ts on 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:
    • sign-up, sign-in and sign-out set and clear the two cookies with their attributes;
    • refresh rotates, honours the 30-second grace, and revokes the Session on a later reuse;
    • an expired access token is UNAUTHORIZED on a public procedure, never an anonymous call;
    • Username rules, case-insensitive uniqueness of email and Username, the availability read;
    • reset: the email carries a working link, the link is single-use and expires, success deletes every Session;
    • each rate limit at its boundary, with a retry-after, and its window rolling on the fake clock;
    • export contents; deletion cascades to Sessions and leaves the Account's side of each Game with no owner;
    • Seats taken and proven as an Account, one Seat per occupant per Room, a Guest-held Seat still proven after sign-in;
    • a decided Game stored with the Account as owner, game.list and game.get as the Account;
    • a sweep removes expired Sessions and reset links.
  • The front views, each beside its view with its vertical's harness and MSW. Covers 2, 11, 15, 21, 31, 32, 33, 34:
    • the sign-up, sign-in, forgot-password and reset-password views, with every refusal, a rate-limit message and the live Username check;
    • the account page, its export download and its delete confirmation;
    • the site navigation signed in and signed out;
    • one refresh and one retry after an UNAUTHORIZED, and a failed refresh showing the Player signed out;
    • the Room view's Seat labels, "My games" rows and the Game page names, orientation and PGN tags as an Account.
  • e2e (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:
    • one Player signs up and creates a Room; a second browser joins as a Guest and sees the first Player's Username beside their Seat; a third browser signs in to the same Account, opens the Room link, is seated with the same Color and plays a Move that the Guest sees;
    • a Player asks for a reset link, reads it from Mailpit, sets a new password and is signed in; signing in with the old password then fails.

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):

  • A Username is unique ignoring case, chosen at sign-up and fixed. It is the Account's public name and the key of its profile URL. It covers the format, the reserved and blocked lists, case-insensitive uniqueness through a lowercase unique index, and renaming only by hand through the Gateway. Revisit when renaming is offered.
  • Rate limits are in-memory counters in the API. One API instance per stack, counters lost on redeploy, keyed by IP and by email, IP read from the proxy's forwarded header. Revisit when a stack runs a second API instance.

Glossary (lands in GLOSSARY.md with this spec):

  • Account: reworded to "an email, a password, a Username, and the Games they own".
  • New term Username: an Account's public name, unique ignoring case, chosen at sign-up and fixed. Aliases to avoid: display name, handle, nickname, login.

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 a lower index 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, referencing accounts with ON 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 answers UNAUTHORIZED and 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_INVALID for 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's Game.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 a Retry-After header and in its error data, for the front's message. UNAUTHORIZED is 401. Both join the refusal status table.
  • The Username input schema holds the format: 3 to 20 characters, ASCII letters, digits, _ and -. The reserved and blocked lists stay server-side. Passwords are at least 8 characters and at most 72 bytes, bcrypt's limit.
  • The Seat view in the Room snapshot and the side shape in game.list and game.get gain 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 auth feature 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.
    • The JWT is signed with jose (HS256), carrying the Account id and the Session id per ADR 0030.
    • Every source of non-determinism (clock, token minting) is a constructor option (API rule 2).
  • A global guard, reworked from core-project, reads the access cookie. Each procedure declares one of three modes:

    • required, the default;
    • optional, for every Room and game procedure and for account.me;
    • none, for sign-up, sign-in, refresh and the reset procedures.
      An absent cookie in optional mode is an anonymous call. A present but expired or invalid one is UNAUTHORIZED in 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 access cookie's lifetime is the Session's (30 days, rolling), while the JWT inside expires in 15 minutes. Otherwise the browser drops the cookie and the API cannot tell "signed out" from "access expired", because the refresh cookie travels only to the refresh route.
    • Cookie names and attributes are exactly ADR 0030's.
    • Set and cleared through the response headers oRPC exposes to a handler.
  • The Room's occupant gains an Account variant (id and Username) beside the Guest one the Guest history spec gave it.

    • Create and take record the Account when the call has a Session, else the Guest.
    • A call is a Seat's occupant when either its Account or its Guest matches. This is what lets a browser that signed in mid-Game keep its Guest-held Seat, and a second device pick up an Account-held one.
    • A caller who already occupies a Seat by either identity cannot take the other.
    • The room.changes stream 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.list and game.get resolve 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:

    Procedure Keyed by Limit
    signIn (failures only) email 5 per 15 minutes
    signIn (failures only) IP 20 per 15 minutes
    requestPasswordReset email 3 per hour
    requestPasswordReset IP 10 per hour
    signUp IP 10 per hour
    • Emails are counted lowercased, whether or not they have an Account, so a limit reveals nothing.
    • Express trusts one proxy hop, so the IP is the client's as Traefik forwards it.
    • Dev, CI and previews raise the per-IP limits through the environment, since the browser suite signs up from one address. The per-email limits keep production values everywhere, because every test uses a fresh email.
  • Mail: a mail module 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 the RoomService today. The reset email is plain text plus minimal HTML, in English, with the link only.

  • Reset link:

    • 256 random bits, valid one hour, single use. A successful reset deletes all of the Account's reset tokens.
    • The token travels in the URL fragment of the front's reset page, so it never reaches a server log or a 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:

    • required: signing secret, SMTP URL, sender address, public URL (for links);
    • optional: the rate-limit overrides.
  • Layer order: the new folders join the API's declared layer order (ADR 0026).

  • The API CODING_STANDARDS.md describes room/ as the one feature module and the Seat token header rule. The PR body proposes the update rather than editing it.

Front

  • A new vertical, auth/, at the path ADR 0030's Governs column names. It holds:
    • the sign-up, sign-in, forgot-password, reset-password and account views, with their services and query options;
    • routes /sign-up, /sign-in, /forgot-password, /reset-password and /account.
  • The signed-in Account is one Query entry from account.me.
    • Signing in, signing out, resetting and deleting reset the whole Query cache, because every Room snapshot and "My games" depends on who asks.
    • An open Room reconnects its change stream, so its Connection is identified again.
  • The refresh-and-retry wrapper lives in lib/, since every vertical's client needs it and verticals cannot import each other. On UNAUTHORIZED it calls auth.refresh once per tab at a time, then retries the call once. A failed refresh marks the Account query signed out.
  • Create and take mint a Guest only without a Session. A browser keeps sending its Guest header whenever it holds one, so a Guest-held Seat stays playable after sign-in. What happens to that Guest on claim and sign-out is the claim spec's.
  • The site navigation shows "Sign in" signed out, and the Username with links to the account page and "Sign out" signed in.
  • The sign-up form checks the Username format locally with the contract's schema, then its availability with a debounced read.
  • Every new string goes in the Paraglide catalogue, in English and French (ADR 0025).

Mail and deploy (the checklist folded in by "Last product decisions before the account specs")

  • Production sends through Resend over SMTP. The SMTP URL is a GitHub secret on the production deploy; changing provider is a secret change (as resolved in "Which email provider sends password resets?").
  • Sender: no-reply@chess.core-project.space. Resend's SPF and DKIM records go on the chess subdomain, which keeps its reputation apart from the other core-project.space apps. Add our own _dmarc at p=none with a report address.
  • Checklist for the owner, run once with the wizard skill: 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.
  • Previews: one Mailpit container per preview stack, in a preview-only overlay. It never delivers, holds messages in memory and goes with the stack on teardown. Production runs no Mailpit.
    • Its UI and API are served on the preview's own host under a path prefix, behind Traefik basic auth whose credentials are a GitHub secret. Same host, so no extra certificate.
    • The preview's e2e job passes that URL and the credentials to the suite.
  • Dev and the local browser suite: one Mailpit beside the shared dev Postgres in the dev Compose project from the Postgres spec, shared by every worktree. env:sync writes its SMTP and API addresses. Tests tell messages apart by recipient.
  • CI's e2e job runs Mailpit as a service container next to Postgres. The test:slow task passes the Mailpit address through.

Testing Decisions

  • Prior art:
    • rpc.integration.test.ts and origin-guard.integration.test.ts with rpc.fixtures.ts for the wire, extended with a cookie jar;
    • core-project's authentication and user e2e tests for the auth cases, and its setupUser helper for an accountClient fixture;
    • config.test.ts for loadConfig;
    • the Room view tests with their harness and MSW handlers for views;
    • create-and-join.spec.ts and room.fixtures.ts (openAnotherTab, expectBothSeated) for the e2e.
  • The e2e suite reads Mailpit over its HTTP API with Playwright's request context. Mailpit is the Player's inbox, not a hook added to the app (e2e rule 3). Each test signs up with a fresh email and Username defined in its domain data. The three browsers of the main story are three contexts; the two sharing an Account share nothing but the credentials.
  • Locally the suite runs on plain-HTTP localhost, where Chromium accepts Secure and __Host- cookies. The first ticket confirms this before anything builds on it.
  • Edge and unhappy paths:
    • email and Username taken in another letter case;
    • each Username rule: too short, too long, a bad character, reserved, blocked;
    • password too short, and over 72 bytes;
    • sign-in with an unknown email and with a wrong password give the same error;
    • refresh with an unknown token, with the replaced token inside and after the 30 seconds, and after sign-out;
    • an expired access token on a Room read is UNAUTHORIZED, and none at all is a Spectator;
    • a deleted Account's still-valid access token is refused;
    • a reset link used twice, expired, unknown, and older than a completed reset;
    • a reset request for an unknown email sends nothing and answers the same;
    • each limit one under and at the boundary, its retry-after, and the window rolling;
    • one Account on two devices with a free Seat never takes it;
    • sign-in mid-Game keeps the Guest-held Seat;
    • sign-out leaves the Seat taken and disconnected;
    • a Game decided after its Account owner was deleted;
    • "My games" for an Account with none;
    • deletion with the wrong password;
    • a front call that gets UNAUTHORIZED twice (refresh succeeds, retry fails) shows the Player signed out rather than looping.

Out of Scope

  • Claiming a Guest into an Account, and any prompt or merge of Guest Games into "My games": the claim spec ("A claim rewrites a Guest's Games to the Account", ADR 0035).
  • The public profile and any public listing of an Account's Games: the public profile spec. Games stay listed only to their owners (ADR 0034).
  • Email verification at sign-up, changing the email or the Username, and a reporting flow for names (as resolved in "Last product decisions before the account specs").
  • Changing the password while signed in: a reset covers it.
  • A device list and "sign out everywhere" (the columns allow them later).
  • Translating the reset email; it is English only.
  • Social or passwordless sign-in.
  • Game and Guest storage themselves (the Guest history spec) and the database tooling (the Postgres spec).

Further Notes

> _Imported from GitHub [amaurycoudr/chess-project#364](https://github.com/amaurycoudr/chess-project/issues/364), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 16:08 UTC._ <!-- github-issue:364 --> --- ## 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** 1. As a **Player**, I want to create an **Account** with an email, a password and a **Username**, so that I have one identity on every device. 2. As a **Player** signing up, I want to see whether a **Username** is free while I type, so that I don't submit the form only to be refused. 3. As a **Player** signing up, I want a **Username** that breaks the rules (length, characters, reserved, offensive) refused with the reason, so that I know what to change. 4. As a **Player** signing up with an email or a **Username** already used, in any letter case, I want to be told which one is taken, so that I can sign in instead or pick another name. 5. As a **Player**, I want to be signed in as soon as my **Account** exists, so that I can play straight away. 6. As a **Player**, I want to sign in with my email in any letter case and my password, so that I reach my **Account** from any device. 7. As a **Player** who mistypes, I want one message for a wrong email or a wrong password, so that a stranger cannot learn which emails have an **Account** from the sign-in form. 8. As a **Player**, I want to stay signed in on a browser for 30 days after I last used it, so that I don't sign in every visit. 9. As a **Player** with two tabs open, I want both to keep working when my **Session** refreshes, so that one tab never signs the other out. 10. As a **Player**, I want to sign out of this browser only, so that my other devices stay signed in. 11. As a **Player**, I want the site's navigation to show my **Username** when I'm signed in and a way to sign in when I'm not, so that I always know who I'm playing as. **Password reset** 12. As a **Player** who forgot their password, I want to ask for a reset link by email, so that I can get back into my **Account**. 13. As anyone asking for a reset link, I want the same answer whether or not the email has an **Account**, so that the form reveals nothing. 14. As a **Player**, I want the link to work once and only for a limited time, so that an old email cannot be used against me. 15. As a **Player** opening an expired, used or unknown link, I want to be told so and offered a new one, so that I'm not stuck. 16. As a **Player** who has reset their password, I want every other browser of my **Account** signed out and this one signed in, so that whoever had my old password loses access. **Rate limiting** 17. As a **Player** whose **Account** is being guessed at, I want sign-in attempts on my email limited, so that my password cannot be brute-forced. 18. As a **Player**, I want reset emails to my address limited, so that nobody can flood my inbox. 19. As the owner, I want sign-ups from one address limited, so that a script cannot fill the database or squat **Usernames**. 20. As a **Player** who hits a limit, I want to be told when I can try again, so that I don't keep failing. **Account page** 21. As a signed-in **Player**, I want an account page showing my **Username**, email and join date, so that I can see what the site knows about me. 22. As a signed-in **Player**, I want to download a file with my **Account**, my **Sessions** and my **Games**, so that I can keep or move my data. 23. As a signed-in **Player**, I want to delete my **Account** after confirming with my password, so that my email and password are gone for good. 24. As a **Player** whose opponent deleted their **Account**, I want our **Game** to stay in my "My games", so that their choice doesn't erase my history. **Seats and Games as an Account** 25. As a signed-in **Player**, I want a **Room** I create or a **Seat** I take to be held by my **Account**, so that it follows me to my other devices. 26. As a signed-in **Player**, I want to open a **Room** on another signed-in device and be back in my **Seat**, with my **Color** and my turn, so that I can switch devices mid-**Game**. 27. As a signed-in **Player** with my **Seat** open on two devices, I want to stay one **Player**, never take the other **Seat**, so that I cannot play myself. 28. As a **Player** who took a **Seat** as a **Guest** and then signed in, I want to keep playing that **Seat** in this browser, so that signing in never kicks me out of a **Game** (ADR 0034). 29. As a **Player** who signs out mid-**Game**, I want my **Seat** to stay taken and disconnected, and to resume it when I sign in again, so that signing out is not leaving. 30. As a signed-in **Player**, I want a **Game** decided while my **Account** held a **Seat** stored as mine, so that it shows in "My games" on every device. 31. As a signed-in **Player**, I want "My games" to list my **Account**'s **Games**, newest first, so that my history is the same everywhere. 32. As a signed-in **Player** opening one of my **Games**, I want it oriented to my **Color** with my side labelled "You", so that it reads as I played it. 33. As a **Player**, I want each **Seat** in a **Room** labelled with its occupant's **Username**, or "Guest", so that I know who I'm playing. 34. As a **Player**, I want "My games", the **Game** page and the exported PGN to name each side by **Username**, or "Guest", so that a record says who played. **Owner and operations** 35. As the owner, I want the API to refuse to start without its signing secret, mail URL and public URL, so that a missing setting fails loudly. 36. As the owner, I want password reset emails sent through Resend in production and caught by Mailpit everywhere else, so that previews, dev and the browser suite never email a real address. 37. As the owner, I want expired **Sessions** and reset links removed on their own, so that the tables don't grow forever. 38. As the owner, I want only hashes stored for passwords, refresh tokens and reset tokens, so that a dump or the Gateway shows nothing that signs anyone in. ## 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. - **The RPC wire**, through the real app in process with `rpc.fixtures.ts` on 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: - sign-up, sign-in and sign-out set and clear the two cookies with their attributes; - refresh rotates, honours the 30-second grace, and revokes the **Session** on a later reuse; - an expired access token is UNAUTHORIZED on a public procedure, never an anonymous call; - **Username** rules, case-insensitive uniqueness of email and **Username**, the availability read; - reset: the email carries a working link, the link is single-use and expires, success deletes every **Session**; - each rate limit at its boundary, with a retry-after, and its window rolling on the fake clock; - export contents; deletion cascades to **Sessions** and leaves the **Account**'s side of each **Game** with no owner; - **Seats** taken and proven as an **Account**, one **Seat** per occupant per **Room**, a **Guest**-held **Seat** still proven after sign-in; - a decided **Game** stored with the **Account** as owner, `game.list` and `game.get` as the **Account**; - a sweep removes expired **Sessions** and reset links. - **The front views**, each beside its view with its vertical's harness and MSW. Covers 2, 11, 15, 21, 31, 32, 33, 34: - the sign-up, sign-in, forgot-password and reset-password views, with every refusal, a rate-limit message and the live **Username** check; - the account page, its export download and its delete confirmation; - the site navigation signed in and signed out; - one refresh and one retry after an UNAUTHORIZED, and a failed refresh showing the **Player** signed out; - the **Room** view's **Seat** labels, "My games" rows and the **Game** page names, orientation and PGN tags as an **Account**. - **e2e** (`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: - one **Player** signs up and creates a **Room**; a second browser joins as a **Guest** and sees the first **Player**'s **Username** beside their **Seat**; a third browser signs in to the same **Account**, opens the **Room** link, is seated with the same **Color** and plays a **Move** that the **Guest** sees; - a **Player** asks for a reset link, reads it from Mailpit, sets a new password and is signed in; signing in with the old password then fails. ## 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): - **A Username is unique ignoring case, chosen at sign-up and fixed.** It is the **Account**'s public name and the key of its profile URL. It covers the format, the reserved and blocked lists, case-insensitive uniqueness through a lowercase unique index, and renaming only by hand through the Gateway. Revisit when renaming is offered. - **Rate limits are in-memory counters in the API.** One API instance per stack, counters lost on redeploy, keyed by IP and by email, IP read from the proxy's forwarded header. Revisit when a stack runs a second API instance. **Glossary** (lands in `GLOSSARY.md` with this spec): - **Account**: reworded to "an email, a password, a **Username**, and the **Games** they own". - New term **Username**: an **Account**'s public name, unique ignoring case, chosen at sign-up and fixed. Aliases to avoid: display name, handle, nickname, login. **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 a `lower` index 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, referencing `accounts` with `ON 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 answers `UNAUTHORIZED` and 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_INVALID` for 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's `Game.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 a `Retry-After` header and in its error data, for the front's message. `UNAUTHORIZED` is 401. Both join the refusal status table. - The **Username** input schema holds the format: 3 to 20 characters, ASCII letters, digits, `_` and `-`. The reserved and blocked lists stay server-side. Passwords are at least 8 characters and at most 72 bytes, bcrypt's limit. - The **Seat** view in the **Room** snapshot and the side shape in `game.list` and `game.get` gain 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 `auth` feature 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. - The JWT is signed with `jose` (HS256), carrying the **Account** id and the **Session** id per ADR 0030. - Every source of non-determinism (clock, token minting) is a constructor option (API rule 2). - A global guard, reworked from core-project, reads the access cookie. Each procedure declares one of three modes: - required, the default; - optional, for every **Room** and `game` procedure and for `account.me`; - none, for sign-up, sign-in, refresh and the reset procedures. An absent cookie in optional mode is an anonymous call. A present but expired or invalid one is `UNAUTHORIZED` in 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 access cookie's lifetime is the **Session**'s (30 days, rolling), while the JWT inside expires in 15 minutes. Otherwise the browser drops the cookie and the API cannot tell "signed out" from "access expired", because the refresh cookie travels only to the refresh route. - Cookie names and attributes are exactly ADR 0030's. - Set and cleared through the response headers oRPC exposes to a handler. - The **Room**'s occupant gains an **Account** variant (id and **Username**) beside the **Guest** one the Guest history spec gave it. - Create and take record the **Account** when the call has a **Session**, else the **Guest**. - A call is a **Seat**'s occupant when either its **Account** or its **Guest** matches. This is what lets a browser that signed in mid-**Game** keep its **Guest**-held **Seat**, and a second device pick up an **Account**-held one. - A caller who already occupies a **Seat** by either identity cannot take the other. - The `room.changes` stream 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.list` and `game.get` resolve 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: | Procedure | Keyed by | Limit | | ------------------------ | -------- | --------------------------- | | `signIn` (failures only) | email | 5 per 15 minutes | | `signIn` (failures only) | IP | 20 per 15 minutes | | `requestPasswordReset` | email | 3 per hour | | `requestPasswordReset` | IP | 10 per hour | | `signUp` | IP | 10 per hour | - Emails are counted lowercased, whether or not they have an **Account**, so a limit reveals nothing. - Express trusts one proxy hop, so the IP is the client's as Traefik forwards it. - Dev, CI and previews raise the per-IP limits through the environment, since the browser suite signs up from one address. The per-email limits keep production values everywhere, because every test uses a fresh email. - Mail: a `mail` module 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 the `RoomService` today. The reset email is plain text plus minimal HTML, in English, with the link only. - Reset link: - 256 random bits, valid one hour, single use. A successful reset deletes all of the **Account**'s reset tokens. - The token travels in the URL fragment of the front's reset page, so it never reaches a server log or a `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: - required: signing secret, SMTP URL, sender address, public URL (for links); - optional: the rate-limit overrides. - Layer order: the new folders join the API's declared layer order (ADR 0026). - The API `CODING_STANDARDS.md` describes `room/` as the one feature module and the **Seat token** header rule. The PR body proposes the update rather than editing it. **Front** - A new vertical, `auth/`, at the path ADR 0030's Governs column names. It holds: - the sign-up, sign-in, forgot-password, reset-password and account views, with their services and query options; - routes `/sign-up`, `/sign-in`, `/forgot-password`, `/reset-password` and `/account`. - The signed-in **Account** is one Query entry from `account.me`. - Signing in, signing out, resetting and deleting reset the whole Query cache, because every **Room** snapshot and "My games" depends on who asks. - An open **Room** reconnects its change stream, so its **Connection** is identified again. - The refresh-and-retry wrapper lives in `lib/`, since every vertical's client needs it and verticals cannot import each other. On UNAUTHORIZED it calls `auth.refresh` once per tab at a time, then retries the call once. A failed refresh marks the **Account** query signed out. - Create and take mint a **Guest** only without a **Session**. A browser keeps sending its **Guest** header whenever it holds one, so a **Guest**-held **Seat** stays playable after sign-in. What happens to that **Guest** on claim and sign-out is the claim spec's. - The site navigation shows "Sign in" signed out, and the **Username** with links to the account page and "Sign out" signed in. - The sign-up form checks the **Username** format locally with the contract's schema, then its availability with a debounced read. - Every new string goes in the Paraglide catalogue, in English and French (ADR 0025). **Mail and deploy** (the checklist folded in by "Last product decisions before the account specs") - Production sends through Resend over SMTP. The SMTP URL is a GitHub secret on the production deploy; changing provider is a secret change (as resolved in "Which email provider sends password resets?"). - Sender: `no-reply@chess.core-project.space`. Resend's SPF and DKIM records go on the `chess` subdomain, which keeps its reputation apart from the other `core-project.space` apps. Add our own `_dmarc` at `p=none` with a report address. - Checklist for the owner, run once with the `wizard` skill: 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. - Previews: one Mailpit container per preview stack, in a preview-only overlay. It never delivers, holds messages in memory and goes with the stack on teardown. Production runs no Mailpit. - Its UI and API are served on the preview's own host under a path prefix, behind Traefik basic auth whose credentials are a GitHub secret. Same host, so no extra certificate. - The preview's e2e job passes that URL and the credentials to the suite. - Dev and the local browser suite: one Mailpit beside the shared dev Postgres in the dev Compose project from the Postgres spec, shared by every worktree. `env:sync` writes its SMTP and API addresses. Tests tell messages apart by recipient. - CI's e2e job runs Mailpit as a service container next to Postgres. The `test:slow` task passes the Mailpit address through. ## Testing Decisions - Prior art: - `rpc.integration.test.ts` and `origin-guard.integration.test.ts` with `rpc.fixtures.ts` for the wire, extended with a cookie jar; - core-project's authentication and user e2e tests for the auth cases, and its `setupUser` helper for an `accountClient` fixture; - `config.test.ts` for `loadConfig`; - the **Room** view tests with their harness and MSW handlers for views; - `create-and-join.spec.ts` and `room.fixtures.ts` (`openAnotherTab`, `expectBothSeated`) for the e2e. - The e2e suite reads Mailpit over its HTTP API with Playwright's request context. Mailpit is the **Player**'s inbox, not a hook added to the app (e2e rule 3). Each test signs up with a fresh email and **Username** defined in its domain data. The three browsers of the main story are three contexts; the two sharing an **Account** share nothing but the credentials. - Locally the suite runs on plain-HTTP localhost, where Chromium accepts `Secure` and `__Host-` cookies. The first ticket confirms this before anything builds on it. - Edge and unhappy paths: - email and **Username** taken in another letter case; - each **Username** rule: too short, too long, a bad character, reserved, blocked; - password too short, and over 72 bytes; - sign-in with an unknown email and with a wrong password give the same error; - refresh with an unknown token, with the replaced token inside and after the 30 seconds, and after sign-out; - an expired access token on a **Room** read is UNAUTHORIZED, and none at all is a **Spectator**; - a deleted **Account**'s still-valid access token is refused; - a reset link used twice, expired, unknown, and older than a completed reset; - a reset request for an unknown email sends nothing and answers the same; - each limit one under and at the boundary, its retry-after, and the window rolling; - one **Account** on two devices with a free **Seat** never takes it; - sign-in mid-**Game** keeps the **Guest**-held **Seat**; - sign-out leaves the **Seat** taken and disconnected; - a **Game** decided after its **Account** owner was deleted; - "My games" for an **Account** with none; - deletion with the wrong password; - a front call that gets UNAUTHORIZED twice (refresh succeeds, retry fails) shows the **Player** signed out rather than looping. ## Out of Scope - Claiming a **Guest** into an **Account**, and any prompt or merge of **Guest** **Games** into "My games": the claim spec ("A claim rewrites a Guest's Games to the Account", ADR 0035). - The public profile and any public listing of an **Account**'s **Games**: the public profile spec. **Games** stay listed only to their owners (ADR 0034). - Email verification at sign-up, changing the email or the **Username**, and a reporting flow for names (as resolved in "Last product decisions before the account specs"). - Changing the password while signed in: a reset covers it. - A device list and "sign out everywhere" (the columns allow them later). - Translating the reset email; it is English only. - Social or passwordless sign-in. - Game and Guest storage themselves (the Guest history spec) and the database tooling (the Postgres spec). ## Further Notes - Map: [Wayfinder: accounts](https://github.com/amaurycoudr/chess-project/issues/346). - Decisions (resolution comments): - [Which email provider sends password resets?](https://github.com/amaurycoudr/chess-project/issues/349) - [Sessions: JWT in httpOnly cookies, reworked from core-project](https://github.com/amaurycoudr/chess-project/issues/350) - [How does an account relate to the Seat token?](https://github.com/amaurycoudr/chess-project/issues/351) - [Last product decisions before the account specs](https://github.com/amaurycoudr/chess-project/issues/362) - Blocked by: [Postgres in place: a database per stack, migrations, backup and Drizzle Gateway](https://github.com/amaurycoudr/chess-project/issues/354) and [Guest history: a Guest holds the Seat and owns its decided Games, listed on this browser, read and exported by link](https://github.com/amaurycoudr/chess-project/issues/355). - Reused from core-project: the auth contract's shape, the global guard and its opt-out, `@ActiveUser()`, `HashingService`, the `lower` unique index helper, and the auth tests. Dropped: one **Session** per user in Redis, bearer tokens in client storage, open CORS. - Too large for one PR: split with `/to-tickets`. A likely cut: 1. **Accounts** and **Sessions** on the wire: sign-up, sign-in, sign-out, refresh, the guard, the **Username** ADR and glossary. 2. Mail module, Mailpit in dev, CI and previews, password reset, and the rate limiter with its ADR. 3. **Seats** and **Games** as an **Account**, and names in snapshots and **Game** reads. 4. Front: auth pages, navigation, refresh wrapper. 5. Front: account page with export and delete; names in the **Room**, "My games" and the **Game** page. 6. The e2e spec, and the Resend and DNS checklist.
root commented 2026-10-11 16:08:46 +00:00 (Migrated from gitlab.core-project.space)

GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links).

> _GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links)._ <!-- github-relations --> - Sub-issues: #378, #379, #380, #381, #382, #383, #384
root commented 2026-10-13 08:39:46 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #378

mentioned in issue #378
root commented 2026-10-13 08:39:46 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #379

mentioned in issue #379
root commented 2026-10-13 08:39:47 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #380

mentioned in issue #380
root commented 2026-10-13 08:39:48 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #381

mentioned in issue #381
root commented 2026-10-13 08:39:48 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #382

mentioned in issue #382
root commented 2026-10-13 08:39:49 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #383

mentioned in issue #383
root commented 2026-10-13 08:39:49 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #384

mentioned in issue #384
root commented 2026-10-13 08:42:28 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #378

marked this issue as related to #378
root commented 2026-10-13 08:42:29 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #379

marked this issue as related to #379
root commented 2026-10-13 08:42:29 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #380

marked this issue as related to #380
root commented 2026-10-13 08:42:29 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #381

marked this issue as related to #381
root commented 2026-10-13 08:42:29 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #382

marked this issue as related to #382
root commented 2026-10-13 08:42:30 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #383

marked this issue as related to #383
root commented 2026-10-13 08:42:30 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #384

marked this issue as related to #384
root commented 2026-10-14 10:36:16 +00:00 (Migrated from gitlab.core-project.space)

mentioned in merge request !523

mentioned in merge request !523
root commented 2026-10-14 12:19:57 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit 28ec752adf

mentioned in commit 28ec752adf253f70543a9d27c1fb16ca7a34fa23
root commented 2026-10-14 12:19:57 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit 0ab4a52d92

mentioned in commit 0ab4a52d92927e00402f7f2efb9b7de0d175795b
root commented 2026-10-14 12:19:58 +00:00 (Migrated from gitlab.core-project.space)

mentioned in commit 8f876665f2

mentioned in commit 8f876665f2514fb5d02511a220393d2a2bb614ea
root commented 2026-10-14 13:24:41 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #399

mentioned in issue #399
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-14 13:28:23 +00:00
root commented 2026-10-14 14:30:15 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #400

mentioned in issue #400
root commented 2026-10-14 14:37:31 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #402

mentioned in issue #402
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amaurycoudr/chess-project#364
No description provided.