feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped

The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.

- The module signs nobody in anonymously: `anonymousIdp` and
  `gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
  `firstPassword` code) or who signs in with GitHub alone (a fresh
  sign-in and a `signUp` code); an anonymous account is refused
  `wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
  (`wrongCaller`), the member's endpoint too, so nothing turns an
  anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
  new, never one identified. Learn and Todo schedule it as
  `prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
  entry once.
- Learn: someone without `identified` keeps no files;
  `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
  up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
  development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 16:07:27 +02:00
commit 87671208df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -14,14 +14,12 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `signUp`: an account at the address a mailed code proves, with a password; a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in, SimpleLogin added to the caller's account, who becomes `identified`; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords or SimpleLogin are offered (deprecated) | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account, unless they sign in with a password; none once `GITHUB_SIGN_IN_ENDS` has come | without a session |
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
@ -93,22 +91,23 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`simpleLogin.begin` takes the way back as a `SimpleLoginReturn`), the rest 1 to 512
characters of base64url; anything else gets a page saying the sign-in can't go on,
repeating nothing it was given, in English or German. It keeps and logs nothing.
- **Per-network limits** (new anonymous accounts, codes by mail, password sign-ins,
proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
- **No anonymous accounts** (Halkyon Learn's ADR 0020, D25): every account has a way
in. An anonymous account left from before signs into nothing new and keeps no
password or SimpleLogin; the app deletes its own as it starts (`account.delete`), and
the pruning deletes the rest (`pruneAnonymousPeople`), whatever their age.
- **Per-network limits** (codes by mail, password sign-ins, SimpleLogin's sign-ins,
handle checks, proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
connection's, or, for a connection from a proxy the operator trusts
(`TRUSTED_PROXIES`), the last address of `X-Forwarded-For`, the one the proxy saw
itself. Never the `Forwarded` header, which Caddy passes on as the visitor wrote it,
so never Serverpod's `remoteInfo`. An IPv6 visitor counts by their /64
(`networkKeyOf`), which one line or server holds whole. Serverpod's own limit on
anonymous accounts is off
(it counts by the connection's address, behind a proxy the proxy's for everyone); the
endpoint counts them itself.
(`networkKeyOf`), which one line or server holds whole.
- Refusals are `PasswordRefusedException`, `GitHubSignInRefusedException`,
`EmailSignInRefusedException` and `ProfileRefusedException`, each with a reason;
`ProfileNotFoundException` where there is no profile.
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
`grantIdentified`, `deletePerson`, the pruning (`pruneAbandonedAnonymousPeople`,
`grantIdentified`, `deletePerson`, the pruning (`pruneAnonymousPeople`,
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`, `forgetUnconfirmedAuthenticators`,
`forgetGitHubAccounts`, `forgetLapsedResets`), short codes, `sourceCodeUrl`,
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
@ -125,9 +124,9 @@ One operator's services share one account per person: the same address, GitHub a
and handle reach the same person in each, under one account ID (each service's
`AuthUser` has it), and deleting the account deletes it everywhere. One service holds
the register, its database keeping the ways in (`email_identity`, GitHub's IDs), the
handles and profiles; the others are its members and ask it. Anonymous people stay each
service's own until they add a way in, which makes them an account of the register under
the same ID. Sessions stay each service's own: every app signs in itself.
handles and profiles; the others are its members and ask it. An anonymous account left
from before stays its service's own. Sessions stay each service's own: every app signs
in itself.
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
@ -151,19 +150,19 @@ the same ID. Sessions stay each service's own: every app signs in itself.
asks the register to mail a code (the device's network passed on, as the register
counts codes per network) and to check it, or to say whose GitHub ID it is, and opens a
session of its own for that account (`issueSession`, which makes them a person there
under the account's ID the first time). Linking a way in makes the person identified on
under the account's ID the first time). Adding a way in makes the person identified on
the register and then on the member; a switch token signs in as the account the
address is already. Every refusal is the register's, as on the register itself.
- **Deleting:** a deletion on a member asks the register first whose account it is
(`knownAccounts`), and is refused while it can't be reached, deleting nothing, a
person still anonymous on the member too: the register may have linked them while
their becoming identified there failed (the app then gives an anonymous account up
all the same, leaving it to the pruning). An account of the register's goes there,
their becoming identified there failed (the app then deletes an anonymous account
left from before all the same, leaving it to the pruning). An account of the register's goes there,
which deletes the account and
keeps a notice for each member in the same transaction (`account_deletion_notice`),
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
confirms; notices to a member no longer named are dropped. An anonymous person goes
from their service alone. Each member also deletes, daily, the identified people the
confirms; notices to a member no longer named are dropped. An anonymous account goes
from its service alone. Each member also deletes, daily, the identified people the
register no longer knows (`forgetPeopleTheRegisterForgot`): it asks about everyone
before deleting anyone, deletes nobody while the register knows none of them (a
register misnamed or emptied), and fails, deleting nobody, while it can't reach it.
@ -238,12 +237,12 @@ Built on the three parts above, on the register (members forward every step thro
3–16 passes, 64 MiB–1 GiB), verifier (1 < v < N, as long as N), public keys (32 bytes
each) and both wrappings (24-byte nonce, 112 bytes). It needs an address, and a
session alone sets none (ADR 0017's fourth reading): an account with an address proves
it anew by a code mailed to it for this (`firstPassword`); an anonymous person by a
mailed code (`signUp`) for the address they add, which becomes theirs as email sign-in
makes it (anonymous → `identified`, same ID); an account that signs in with GitHub
alone does that within 10 minutes of a GitHub sign-in on the session asking
it anew by a code mailed to it for this (`firstPassword`); an account that signs in
with GitHub alone does that within 10 minutes of a GitHub sign-in on the session asking
(`gitHubSignInFreshFor`, the session row's method and creation time, a member telling
the register; else `signInAgain`, before any code is used). It signs in once an
the register; else `signInAgain`, before any code is used), the address it adds proved
by a mailed code (`signUp`). Anyone else, an anonymous account left from before among
them, sets none (`wrongCredentials`, before any code is used). It signs in once an
authenticator is confirmed; until then a new setup replaces it, never once one was,
even while a reset leaves the account without one (a new password comes through the
recovery phrase or the account's reset alone).
@ -390,17 +389,14 @@ Built on the three parts above, on the register (members forward every step thro
(`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed
authenticator, which a reset may have taken since: `passwordOnly` in its ways in) no
longer signs in with GitHub (`usePassword`); where passwords are offered, no new GitHub
account is linked (`deprecated`; one someone else has is still `takenByAnotherPerson`,
so an anonymous person's app switches to it); from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
longer signs in with GitHub (`usePassword`); no account gets GitHub any more (D25),
so it signs in only those who have it; from `GITHUB_SIGN_IN_ENDS` on GitHub signs nobody in
(`ended`), the basics stop naming the day (`gitHubSignInEnds`, which apps show until
then) and, 30 days later (`gitHubAccountsKeptAfterEnd`, so a date set wrong can be put
right first), `forgetGitHubAccounts` deletes every GitHub ID on the register, first
deleting the accounts that sign in with GitHub alone (no address, never a password),
each on its own (one failing is tried again the next day; one deleted whose host step
after failed counts as deleted), the members told once after all of them. `password.set` adds an
address without GitHub only for someone anonymous where the session lives
(`anonymousHere`, which a member tells the register).
after failed counts as deleted), the members told once after all of them.
## Taking it into a host
@ -455,7 +451,7 @@ Built on the three parts above, on the register (members forward every step thro
after any literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople`, `forgetGitHubAccounts` (the register's),
`pruneAnonymousPeople`, `forgetGitHubAccounts` (the register's),
`forgetLapsedResets` and `forgetPeopleTheRegisterForgot` (a member's), hourly
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `forgetUnfinishedSimpleLogins` (the