halkyon/packages/halkyon_base_server/lib/base.dart

123 lines
4.3 KiB
Dart
Raw Normal View History

/// halkyon_base for a host's own code: its configuration
/// (`HalkyonBase.configure`), how people authenticate, the account's
/// deletion and the pruning, email sign-in's helpers, the instance's
/// basics, people's public profiles and their pages, the instance's register
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
/// (ADR 0016), signing in with a password and TOTP and getting back into an
/// account (ADR 0017), signing in with SimpleLogin and its forwarding page
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
/// (ADR 0020), the ways in an operator offers, the operator's assistants
/// (Halkyon Learn's ADR 0021), and the module's models.
library;
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
export 'src/admission/admission.dart'
show
forgetUnapprovedSignUps,
maxWaitingPasses,
maxWaitingSignUps,
unapprovedSignUpLifetime,
waitingPassLifetime;
export 'src/admission/sign_up_config.dart';
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
export 'src/assistant/assistant_conversation.dart'
show
assistantImageMaxBytes,
assistantQuestionMaxCharacters,
assistantQuestionMaxImages;
export 'src/assistant/assistant_forwarding.dart'
show assistantUsageCounted, maxQuestionsAtOnce, useLlmClient;
export 'src/assistant/assistant_grants.dart'
show assistantFiguresMonths, forgetOldAssistantFigures;
export 'src/assistant/assistants_config.dart';
export 'src/auth/account.dart';
export 'src/auth/auth_config.dart';
export 'src/auth/code_mail.dart';
export 'src/auth/email.dart';
export 'src/auth/identified.dart';
export 'src/auth/offered_ways.dart';
export 'src/auth/scopes.dart';
export 'src/common/network.dart';
export 'src/common/short_code.dart';
export 'src/generated/protocol.dart' hide Protocol;
export 'src/halkyon_base.dart';
export 'src/instance/data_location.dart';
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
export 'src/instance/privacy_page.dart' show addPrivacyPageRoute, privacyPage;
export 'src/instance/source_code.dart';
export 'src/password/account_resets.dart'
show forgetLapsedResets, resetDelay, resetLapsesAfter;
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
export 'src/password/authenticators.dart'
show
forgetUnconfirmedAuthenticators,
maxAuthenticators,
unconfirmedAuthenticatorLifetime;
export 'src/password/key_proof.dart'
show
KeyProofPurpose,
PasswordProofPurpose,
authenticatorDigest,
canonicalInstanceUrl,
isAccountSignature,
keyProofMessage,
feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7) Someone whose SimpleLogin identity is new to the register but who has an account ("I already have one") names their handle and proves the account's recovery phrase; the new identity takes the old one's place. - `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the phrase's key, the public keys and a nonce, for the account with that handle (whatever its case). Only an account with keys and a SimpleLogin identity is mended, a link it had, as nobody adds SimpleLogin signed out (D10); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`, the same each time. At most 10 an hour per handle and 30 per network. - `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`, binding the attempt (`mendLinkDigest`, on both sides), so a proof relayed from another sign-in links nobody's identity but the device's own; checked for the waiting identity first, which leaves the proof unspent. Every session of the account ends, in every service, and the sign-in stands ten minutes for a change, such as a lost password. - SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a SimpleLogin app and without it (`checkSimpleLoginMending`). - Members ask the register for both; `RemoteRegister` and the instance API carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:26:58 +02:00
mendLinkDigest,
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
newKeysPasswordDigest,
newPasswordDigest,
newPhraseDigest,
passwordIdentity,
proofKeyDigest,
publicInstanceUrlOf;
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
export 'src/password/password_config.dart';
export 'src/password/passwords.dart'
show
isKeysPassword,
isKeysSetup,
isNewPassword,
isPasswordSetup,
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
newPasswordCosts,
passwordGroup,
passwordMethod,
signInChallengeLifetime,
signsInWithPassword,
wrappedKeysLength;
export 'src/profile/handle_checks.dart';
export 'src/profile/link_verification.dart';
export 'src/profile/profile_page.dart' show addProfilePageRoute, profilePage;
export 'src/profile/profile_rules.dart'
show
accountDomain,
avatarPath,
avatarStorage,
isHandle,
maxAvatarBytes,
operatorAccountDomain,
profilePageUrl,
reservedHandles;
export 'src/profile/profiles.dart';
export 'src/register/account_register.dart';
export 'src/register/accounts_mode.dart';
export 'src/register/deletion_notices.dart'
show MemberNotifier, deliverDeletionNotices;
export 'src/register/instance_routes.dart';
export 'src/register/member_pruning.dart';
export 'src/register/people.dart' show issueSession;
export 'src/register/sessions_ended.dart'
show AccountSessionsEnded, deliverSessionNotices;
export 'src/simplelogin/handle_suggestion.dart'
show handleFromName, handlesToTry, suggestable;
export 'src/simplelogin/simplelogin.dart' show forgetUnfinishedSimpleLogins;
feat(base): SimpleLogin's forwarding page and Learn's App Links statement (ADR 0020, step 1) SimpleLogin takes only https redirect addresses, each registered exactly, so the instance's SimpleLogin app registers one page on the register's web server, /simplelogin-callback, which sends the browser on to the app that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port per app) or the phone's scheme, chosen by the marker the state begins with (`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url), from a fixed table, so it sends nobody anywhere else. It forwards the code or SimpleLogin's error (`deny`) with the state and nothing else, refuses anything malformed, repeated or not a GET with a page that repeats nothing it was given, and keeps and logs nothing (no-store, no referrer). It is built ahead of the sign-in itself so development can sign in through learn.halkyon.eu's page, which the development app registers too. Android's assetlinks.json for Halkyon Learn signed with the release key is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the project's builds name, and the server knows no app's key. A root test holds its fingerprint to tool/release/android-certificate.sha256. The app's intent filter comes with the sign-in; iOS's association waits for an Apple team ID. The profile page's language choice and look move to common/web_page.dart, shared by both pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 15:27:56 +02:00
export 'src/simplelogin/simplelogin_callback.dart'
show
addSimpleLoginCallbackRoute,
simpleLoginCallbackPath,
simpleLoginReturns;
export 'src/simplelogin/simplelogin_config.dart';
export 'src/srp/srp_group.dart';
export 'src/srp/srp_math.dart' show numberOf, padded;
export 'src/srp/srp_server.dart'
show
SrpAccepted,
SrpChallenge,
SrpRefusal,
SrpRefused,
SrpVerification,
srpChallenge,
srpVerify;
export 'src/totp/base32.dart';
export 'src/totp/recovery_codes.dart';
export 'src/totp/totp.dart';