feat(accounts): one account across an instance's services (ADR 0016)

halkyon_base runs as the instance's register or as one of its members.
The register keeps the ways in, handles and profiles; members ask it
over HTTP with the instance's shared secret (instanceSecret), and open
sessions of their own for its accounts, under the same account ID.

- AccountsMode from the environment: INSTANCE_MEMBERS on the register,
  ACCOUNT_REGISTER_URL on a member. Learn always holds the register
  (Explore joins the profiles); Todo is a member where it is set.
- AccountRegister: LocalRegister, RemoteRegister, an in-memory one in
  tests. Calls between services on the web server, refused without the
  secret (constant-time compare); a server needing it doesn't start
  without it.
- Deleting an account deletes it everywhere: notices per member kept in
  the deletion's transaction (account_deletion_notice), told at once and
  hourly until confirmed; members prune daily whom the register no longer
  knows, never while it knows none of them or can't be reached.
- Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env.

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-19 23:10:08 +02:00
commit 65c5cd740d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -46,7 +46,60 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
(`profileOf`, `profilesOf`, `personWithHandle`, `fixHandle`; `saveProfile` for its
tests), `accountDomain`, `profilePageUrl`, `addProfilePageRoute`, the register
(`AccountRegister`), and the models. A host never reads the profiles' table itself.
(`AccountRegister`, `AccountsMode`, `addInstanceRoutes`, `deliverDeletionNotices`,
`forgetPeopleTheRegisterForgot`, `issueSession`), and the models. A host never reads
the profiles' table itself.
## The instance's register (ADR 0016)
One operator's services share one account per person: the same address, GitHub account
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.
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
one service, telling the members `INSTANCE_MEMBERS` names (web servers, separated by
commas or spaces) when an account goes; `MemberOfRegister` where
`ACCOUNT_REGISTER_URL` names the register's web server. Empty values count as unset.
- **Trust:** the instance's shared secret (`instanceSecret` in the passwords), sent as a
bearer token with every call between the services and compared in constant time. A
server without one takes no such call and makes none (a member without one fails to
ask anything). A member is trusted as the register's own code: whoever holds the secret
can act for any account, so it never leaves the operator's servers.
- **The interface:** `AccountRegister`, which every endpoint and host function asks:
`LocalRegister` on the register (its own tables), `RemoteRegister` on a member (a POST
of JSON to `/halkyon-base/register/<operation>` on the register's web server, 30 s;
refusals come back as the exceptions the register threw, anything else as
`RegisterUnavailableException`); tests put an in-memory one in its place
(`AccountRegister.use`). The register answers on its web server (`addInstanceRoutes`),
a member on `/halkyon-base/member/account-gone`; neither is reachable from outside (the
host's Caddyfile never forwards them).
- **Signing in on a member:** the app calls the same endpoints in both modes. The member
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
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:** an identified person's deletion on a member goes to the register first
(refused while it can't be reached, deleting nothing), 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
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.
After restoring the register's database from a backup, accounts made since are
unknown to it: stop the members' pruning until those people are dealt with.
- **Privacy:** the register learns when someone signs in to which service; the
instance's privacy page has to say so.
- **The register being down:** members can't sign anyone in, change profiles or delete
accounts; their open sessions go on working.
## Taking it into a host
@ -85,6 +138,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
german: 'Auf Halkyon Todo, einer Aufgabenliste:',
link: Uri.parse('https://halkyon.app/todo'),
),
// The register, or a member of the one the operator names (ADR 0016).
accounts: AccountsMode.fromEnvironment(),
),
);
pod.initializeAuthServices(
@ -93,12 +148,14 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
);
```
Serve the profile pages on its web server: `addProfilePageRoute(pod)`, after any
Serve the calls between the instance's services on its web server:
`addInstanceRoutes(pod)`; and the profile pages: `addProfilePageRoute(pod)`, after any
literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople`, hourly `forgetOldSignUpAttempts` and
`forgetExpiredEmailCodes`.
`pruneAbandonedAnonymousPeople` and `forgetPeopleTheRegisterForgot` (a member's),
hourly `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes` and
`deliverDeletionNotices` (the register's). Each does nothing in the other mode.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
after the person's sessions ended and before their identities and the person go, in
@ -109,12 +166,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
5. Create the host's migration. A host taking the tables over from its own models (as
Halkyon Learn does) finds no change once its copies are gone: Serverpod matches tables
by name, and these are identical. `create-migration --empty` then records the module's
version, with no SQL besides.
version, with no SQL besides. Every service has the module's tables, whichever its
mode: a member's stay empty but for its people's sessions.
The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClientId` and
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`. Never change either
pepper once people sign in: every session or address hash would be lost. The environment:
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; between the
instance's services `instanceSecret`. Never change either pepper once people sign in:
every session or address hash would be lost. A member needs no email settings of its
own (the register mails the codes), but GitHub's (its own OAuth app's, whose callback is
its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
one; `SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,