halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
### AUTOMATICALLY GENERATED DO NOT MODIFY
|
|
|
|
|
###
|
|
|
|
|
### This file is generated by Serverpod when creating a migration, do not modify it
|
|
|
|
|
### manually. If a collision is detected in this file when doing a code merge, resolve
|
|
|
|
|
### the conflict by removing and recreating the conflicting migration.
|
|
|
|
|
|
|
|
|
|
20261019115750898
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
20261019200558685
|
2026-10-19 23:10:08 +02:00
|
|
|
20261019202835333-deletion-notices
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
20261020111123780-passwords
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
20261020124836165-sessions-ended
|
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
|
|
|
20261020185619505-recovery-proof-key
|
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
|
|
|
20261020194005842-second-factor-waits
|
fix(auth): a session's wrong codes end the session, not the account's codes
Wrong codes for removing an authenticator, new recovery codes or confirming
one counted toward the account's waits (the re-review's third finding): a
stolen session alone, without the password, could keep the owner's codes
waiting for up to a day at a time, renewed without end, holding them out
of signing in and of changing the password, the way to end that session,
and nobody was mailed. Those codes now count per session, where the session
is (the register's own, or a member's, which forwards the code): each try
counted before its code is checked (one statement, so tries sent at once
count each), a code accepted clearing the count, and the tenth wrong in a
row ending the session (sessionEnded; the device says it was signed out).
A try that checked no code (a refusal of something else) is taken back.
The account's waits now count only codes after a right password, and the
per-account ten tries an hour, which a session could fill as well, go.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "session_code_failures" ("id" bigserial PRIMARY KEY,
"sessionId" uuid NOT NULL, "authUserId" uuid NOT NULL, "failures"
bigint NOT NULL, "updatedAt" timestamp NOT NULL) with a unique index on
"sessionId": a new table, nothing touched. Its rows go with a code
accepted, with the person, and hourly once their session is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 22:53:20 +02:00
|
|
|
20261020204618337-session-code-tries
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
20261020211224310-email-code-purpose
|
2026-10-21 12:05:18 +02:00
|
|
|
20261021100319502-session-ended-warning
|
2026-10-21 13:06:26 +02:00
|
|
|
20261021110159448-second-factor-paths
|
2026-10-21 15:50:29 +02:00
|
|
|
20261021134802955-reset-hold-outs
|
2026-10-23 20:28:37 +02:00
|
|
|
20261023181809816-oidc-module
|
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.
- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
waits under the attempt gets, in one transaction, an account
(identified), the link, a profile with the handle and the keys; then a
session (D18). Refused, the identity still waiting, for a setup out of
range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
its handle, and keeps the sign-in ten minutes under its attempt, the
account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
password is proven with its proof key, for a nonce, bound to the base
URL the device addressed; wrong proofs counted per session as ADR
0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
instance API carry the four new operations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
|
|
|
20261023192912859-simplelogin-keys
|
feat(base)!: sign-ups as the operator says, waiting accounts, operators (ADR 0021, step 1)
SIGN_UP takes open (unset), approval or closed; anything else stops the
server as it starts. Closed refuses a SimpleLogin sign-up before its
handle is counted, an address sign-up only once the code has proved the
address, so nothing tells whether an address has an account. Approval
lets a new account wait, at most ten at once: no session is issued for a
waiting or suspended account, only a pass (32 random bytes, kept as its
SHA-256, 30 days, five per account) and four BIP-39 words the operator
reads out to match it. The pass looks, forgets, and deletes an account
that was never approved; nothing else.
Operators hold the scope operator, given first by SQL (D32), then named
in the app: InstanceOperatorEndpoint lists, approves and refuses waiting
accounts, finds accounts, suspends them (ending their sessions), and
names and takes back operators, never the last. account.operating()
counts the waiting for the badge. Members ask the register (five new
operations, a refusal kind signUp), and say closed while it is out of
reach. Never-approved accounts go after 14 days in the daily pruning.
The migration only adds the tables waiting_account and waiting_pass, so
the server before it keeps working on the new schema.
BREAKING CHANGE: a register and its members must run this version
together: a member asks the register operations an older one doesn't
know.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 15:04:21 +01:00
|
|
|
20261025135744270-waiting-accounts
|
feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)
The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.
- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
_PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
_KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
Serverpod stream (D35): the text as the model writes it, then the
whole turn, sent once the request is counted; figures, the caller's
own. Checked in order: at most 2 questions on their way per person
(tooManyAtOnce), the grant, the agreement, limits in the pricing's
unit now (limitOutdated), the month's and the day's limits (with when
they reset, UTC), the question (malformed, imagesNotRead, tooLarge
past 400,000 characters, an image over 3,932,160 bytes or more than
20 images); output capped at 16,384 tokens. Each request carries the
person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
sent, the provider's reply is read to its end even if the app
cancels, and counted on a session of its own; a provider's failure is
logged by kind and status, told as providerKey, providerBusy (with
its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
register): setUp, grants, grant (assistantNotSet, invalidLimit,
notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
and UTC day), in the module's, Learn's and Todo's migrations. The
daily pruning keeps this month and the two before
(forgetOldAssistantFigures, D41); deletePerson deletes both.
A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
before the profile pages' route): made from the instance's settings,
English or German, no JavaScript: who may sign up, where the data and
backups are, what the module keeps (the account, sessions, IP
addresses, the server's log), the host's privacyPagePoints (Learn's:
studysets synced, private ones encrypted on the devices with what
stays readable, what is published, dictionary lookups, the learner
count), each assistant with its model and host, what goes and what is
counted, and whether personal keys are allowed. Never a key.
Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 18:07:01 +01:00
|
|
|
20261025165102522-assistants
|
feat(base)!: one budget per person for all their assistants, the effort each takes, the operator's lists by page
A grant now says only which assistant a person may use, and keeps their
agreement; the limits are the person's (assistant_budget): one per UTC
day and month in money and in tokens, each optional, money counting the
assistants with prices, tokens every one (Halkyon Learn's ADR 0021, D58,
D59). assistantOperator.grant gives several people several assistants
with one budget, change sets one person's exactly, revoke takes back
everything; grantees and granteesOf list people with their assistants,
budget and use, 50 at a time. The migration carries each person's grant
limits into a budget: per unit, their sum, a grant without a limit making
it none.
assistants.env gives each assistant a default and a highest effort
(_EFFORT, _MAX_EFFORT; refused at start where the provider takes none);
ask takes one within that range, refusing another (invalidEffort), and
caps the output at its ceiling (D63).
instanceOperator.waiting and accounts come 50 at a time from an offset,
and counts gives the rows of the operator's screen (D62).
BREAKING CHANGE: the offer, the grants and the operator's lists changed
shape; Learn's server now asks for build 25.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 15:07:04 +01:00
|
|
|
20261026140329453-assistant-budgets
|