Claiming an account

Player identity gives every browser a device credential and puts it on an account. The account is the player: every device on one account gets the same player id in a given project, so a score posted from a phone and a score posted from a laptop land on the same row.

Claiming is how a player gets onto that account from a device it has never seen, without carrying a code across the room. They give an address, open the link that arrives, and that device is on the account. Nothing is renamed when they do: player ids derive from the account, and a claimed account keeps its id, so every board row and every saved game stays exactly where it was.

There is still no password anywhere in this. An address is a way of reaching an account, not a secret that opens it.

Adding an email address

Two calls. The first asks for a link:

await fetch(`https://irt.io/v1/account/claim/email?project=${project}`, {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'x-irt-identity': deviceCredential },
  body: JSON.stringify({ email, returnUrl: 'https://mygame.example/account' }),
});
// always { ok: true, expiresInMs }

The answer is the same whatever the address is. Already claimed by this player, claimed by somebody else, never seen: one answer, because an answer that varied would tell a stranger holding an address whether that person plays your game.

The second call finishes it, and it is the page in returnUrl that makes it, with the token from the link’s fragment:

await fetch(`https://irt.io/v1/account/claim/verify?project=${project}`, {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'x-irt-identity': deviceCredential },
  body: JSON.stringify({ token }),
});
// { account, email, linked, retired }

linked: false means the address was free and is now this account’s. linked: true means the address already belonged to an account and this device has joined it — from the next join on, this browser is that player, with that player’s scores.

A device credential lives in one origin’s localStorage. A page on irt.io cannot read the credential your game stored on mygame.example, so it could not finish a claim for it.

That is what returnUrl is for: name the page in your own game that should handle the link, and it will be the one that opens. Two rules decide whether it is honoured, and both matter:

  • It has to be on an origin you have registered for the project (the same list the room join checks), or the request is refused by name. A project that has registered no origins may name a localhost URL, which is what local development is.
  • If the address already belongs to somebody else’s account, it is only honoured when that account plays this project. Otherwise the link quietly opens irt.io’s page instead. Without that rule, anyone could register an origin on a project of their own and have the platform mail a stranger a link that opens a page they wrote.

The second rule is invisible from the outside: the request answers the same way either way. It costs one real case — a player adding a device on a game they have never opened before, when their account was claimed elsewhere — and the link code covers that.

The emitted link carries ?project= in the query and the token in the fragment. Your page needs both: the project goes on the verify request, and the fragment is where the token is because a fragment is never sent to a server.

Leave returnUrl out and the link opens irt.io’s own page, which works when the player’s credential is on irt.io and tells them plainly what to do when it is not.

What the message says

The email names the host the link opens on and the project id it was asked from, and tells the reader not to open it if they do not recognise both. Anyone can ask for a link to any address, so for an address nobody has claimed yet that sentence is the mitigation: the person who can tell whether it was expected is the one holding the mailbox.

  • It works once and lasts twenty minutes.
  • It has to be opened on the device that asked for it. The link alone is not a credential: the page that finishes the claim presents the device credential too, so a forwarded or intercepted link attaches nobody’s device to anything. A link presented by the wrong device is spent on the attempt and refused.
  • Every failure is one failure. Expired, already used, never issued, wrong device: one status, one message. The difference between them is exactly what somebody holding a guessed link wants to know.
  • Only its hash is stored. A dump of the platform’s database is not a set of working links.

The limits

The claim route is limited four ways, and the one that matters is per address: three in a burst and six an hour to any one mailbox, no matter how many accounts, machines or projects the requests come from. Being over that limit produces the same answer as being under it, and no mail.

The other three are per account (three in a burst, ten an hour), per project, and per address of the machine asking (thirty in a burst, sixty an hour, because one address is a classroom as often as it is one browser). Those three answer 429 and say which one they were.

Adding a sign-in provider

Google and Discord, when the platform has them configured. The shape is three legs, and the reason is that a browser navigating to Google cannot carry a header:

  1. POST /v1/account/oauth/google/start with the device credential answers a URL.
  2. Send the player there. They come back to your completion page with a token in the fragment.
  3. POST /v1/account/oauth/complete with that token and the device credential applies the link.

The third leg is the interesting one. The provider’s callback does not link anything by itself: it leaves a pending link that only a device on the account that started the flow can claim. A sign-in completed in somebody else’s browser is useless to them.

The semantics match the email: an unlinked provider identity binds to this account, and a known one brings this device onto the account that holds it.

When no provider is configured, all four routes answer E_OAUTH_DISABLED by name rather than failing somewhere further in.

What the platform keeps, and what your game sees

Your game sees a player id and nothing else. That has not changed and it is the point:

  • The assertion your room receives carries an issuer, an audience, a subject and two timestamps. There is no email address in it, no provider, no display name and no account id. A test greps for all of those on every surface a room can see.
  • No provider token is stored anywhere. The access and refresh tokens exist for the length of one callback, are used to read the profile once, and are dropped. There is no column for one.
  • The profile is normalised down to almost nothing: the provider’s stable id for the person, and a display name at most. No email from the provider, no avatar, no locale. An address the platform has not verified itself is not a claim about anybody.
  • Two games holding assertions for one player still cannot tell that they do. Subjects are derived per project, and claiming does not change that.

Reading and removing

// GET /v1/account?project=… with the device credential
// { account, self, email, providers: [{ provider, displayName, linkedAt }], devices }

DELETE /v1/account/email and DELETE /v1/account/providers/:provider remove them. Both are allowed even when they are the only claim on the account: the account is still reachable from the devices it already holds, and the answer says so. Refusing would leave a player with an address they can never take back.

DELETE /v1/account still deletes everything — the devices, the address, the providers, and every board row and saved value in every project the player played.

When a device joins another account

A device that claims an address or a provider belonging to another account leaves the account it was on. If that account had no other devices, it is deleted along with everything saved under it, and the answer says retired: true.

That is the same rule the link code follows and for the same reason: an account whose last credential walked away is one the player can never reach and nobody can delete. Warn the player before you start a claim on a device that has progress on it, the way the link code section does:

Adding this address will replace the progress saved in this browser with the progress from your other device. The progress here cannot be recovered afterwards.

An account with other devices on it is not deleted; it just loses this one.

Next steps