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>
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.
The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
TOTP_KEY, RECOVERY_CODE_KEY and SIGN_IN_DECOY_KEY switch password
sign-in on (each at least 32 characters, or the server stops), and
GITHUB_SIGN_IN_ENDS names the day GitHub stops signing anyone in. CI
gives the test runs throwaway keys as it does the other passwords.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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>
halkyon_shared's 96f2d14: an operator's DATA_LOCATION and
BACKUP_LOCATION may name a subdivision (ISO 3166-2, US-CA, DE-BY)
besides the EU or a country, and the app adds "(EU)" to a member
country and the country to a subdivision ("Germany (EU)", "California,
United States") under the most specific flag there is. deploy/.env and
the backups' guide say so; a Storage Box is named by its country now.
The package registers its notices (the flags, CLDR's subdivision
names); Learn's own CLDR notice for the world map stays, its
registration renamed to say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Its operator sets DATA_LOCATION and BACKUP_LOCATION in deploy/.env (a
country's code or EU, the backups also none), and instance.info() hands
them on. The first start's tile and Switch account show the flag of
where the data is and the words, as labels ("Data: European Union",
"Backups: Switzerland"), the countries by the world map's CLDR names:
where the disks are, never which law governs. The flags come from the
flag package, which draws flag-icons; its MIT notice joins the licence
list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A profile now has a page on the web, at the root of the account domain
(https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN,
else on the instance's web server), so the address belongs to the
identity rather than to Learn and stays when other services share it
(ADR 0011). The page shows what the app's profile sheet shows, in English
or German, without JavaScript or cookies, asks search engines not to list
it, marks every link rel="me" so other sites can verify links to it, and
sends a visitor who asked on another host to its own address. Caddy
forwards /@… to the web server, and serves nothing else on the account
domain; compose now passes ACCOUNT_DOMAIN on, which it never did.
A link is verified as Mastodon verifies one: its page links back to the
profile's with rel="me". profile.checkLinks fetches each link's page, at
most ten times an hour, over HTTPS on port 443 and to public addresses
only, connecting to the address it checked and verifying the certificate
for the host, with three redirects, 1 MiB and ten seconds at most, and
keeps what links back on the profile (verifiedLinks, a column of a table
no device syncs). A changed link loses its check, a new handle all of
them. The app shows the page's address to copy, checks a link saved by
itself and on Check links, and marks verified links in the form and in
others' profiles; a check answered after a newer save only adds its
checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Email sign-in's codes are mailed over SMTP (pub.dev's mailer), through
whichever service the passwords name: emailSmtpHost, emailSmtpPort,
emailSmtpUser, emailSmtpPassword and emailFrom (EMAIL_SMTP_* and
EMAIL_FROM in deploy/.env, with EMAIL_SIGN_IN_PEPPER). TLS only:
STARTTLS, or TLS directly on 465. learn.halkyon.eu sends through Proton's
SMTP submission. Without a service, codes still go to the log in
development, and elsewhere there is no email sign-in.
The mail is plain text, in English or German: the code first and in the
subject, so it shows in the notification and the system can offer it,
the instance it signs in to, its 15 minutes, and that it can be ignored;
no links, no tracking, no HTML. A mail that couldn't be sent is a refusal
of its own (mailFailed): nothing of the request is kept, and the log
names the error's type only, as the service's answer may name the
address. A fake SMTP server on loopback drives the real mailer in the
tests: it delivers, and nothing goes to a server without TLS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
What applies of Japanese's features to Chinese, Korean, Greek, German and
English, with the developer's choices and each decision's reason: Unihan's
characters, KRDict's words and levels, Make Me a Hanzi's and the curator's
own strokes, typed pinyin, radicals and readings by language; what doesn't
apply or has no open data. AGENTS.md, the server's README and the
instance's example environment follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A studyset in German, Greek, English, Chinese or Korean finds its
dictionary as one in Japanese does (ADR 0008), and its subtitles are cut
into words by its language (`WordCutting`): Chinese by its longest
words as they stand, the longer words over each cut offered as for
Japanese; Korean at its spaces, a word the dictionary lacks as it stands
taken without the particle it ends with (집에서 is 집, 아이 stays a
child); every other language at its spaces, a word written exactly so
first (the noun Essen before the verb essen). A studyset finds the
dictionary of its language tag, else of its language alone (zh for
zh-Hans).
A Wiktionary word shows its genders, principal forms, IPA, romanisation
and Hanja; a German or Greek noun of one gender goes onto its card with
its article (das Haus, το σπίτι); its tag is its word class, named in
full (Noun, Adjective, Preposition), as Wiktionary's adj and adv would
read as JMdict's codes. Articles, pronouns, prepositions and
conjunctions are left out of a video's words. Chinese words show their
pinyin over each character on the Watch screen, carry their HSK level
as a tag (HSK-1 … HSK-7-9), and the dictionary adds a level whole; the
Watch screen filters by HSK levels for Chinese, JLPT's for Japanese, and
by none for a language without. A reading is typed in a session only
when it is kana, so pinyin isn't. Words are looked up on the web on
Jisho in Japanese, on Wiktionary at the language's section otherwise,
and a search's hint speaks of kana and romaji only in Japanese.
`DICTIONARY_LANGUAGES` in deploy/.env names the languages an instance
keeps (unset: de,el,en,ko,zh; empty: none).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An instance may now run services beside itself, each a compose file of
its own switched on in deploy/.env (COMPOSE_FILE=compose.yaml:compose.
jellyfin.yaml), so the deploy runs them with the instance. The first is
Jellyfin, which people watch and listen to learn from: the instance then
suggests it (JELLYFIN_URL from JELLYFIN_DOMAIN), and Caddy serves its
site, refusing every change to a user from outside, so a passwordless
demo account stays everyone's; its admin signs in through an SSH tunnel.
The instance's compose gets a second network, `edge`, for Caddy (at a
fixed address, which sidecars trust as their proxy) and the server;
Postgres stays on the internal one. Caddy loses its needless way to
Postgres, and a sidecar never gains one. A sidecar publishes no port,
as the CI runner may reach 127.0.0.1, and runs as a user ID of its own.
Caddy imports the sidecars' sites from sidecars/, mounted by their
compose files (none without), and redeploy.sh recreates it when one
changes. sidecars/README.md has the steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An instance now names a Jellyfin server for its people to learn from
(instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none),
else https://jellyfin. on the domain registered for its public host, as
learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a
Jellyfin server. The registered domain comes from Mozilla's Public Suffix
List, kept in the server's data folder, so an instance at example.co.uk
never names someone else's jellyfin.co.uk. The instance looks as it
starts and again hourly when asked; the app never reaches for a guessed
address.
In the app, Add material offers From Jellyfin as soon as the instance
suggests a server, before anyone signed in to one. The sign-in fills the
server in and finds it by itself, and lists the people the server shows
on its login screen: one without a password, as a demo's, signs in with
a tap; one with a password is named in the form, which asks for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Files called the servers by their own host names, which are the
operator's and say nothing a reader needs. They now say which domain a
server serves: learn.halkyon.eu's server (the instance and its CI
runner), the halkyon.app server (Forgejo and the F-Droid repository).
Two names are settings rather than words. CI's runner label becomes
`instance` (`runs-on: instance`, `labels: ["instance:host"]`): the
runner reads its installed copy of deploy/runner/config.yml, so jobs
wait in the queue until that copy is updated and the runner restarted.
The SSH aliases the scripts and docs use become `learn-deploy` and
`fdroid-upload`. AGENTS.md says to keep it so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An anonymous person may keep 100 MB of images and sounds, an identified one
500 MB, and one person more by a file_allowance row the operator adds; all
of them together 15 GB, since every file is in the database and in each dump
the server keeps (decided 2026-10-02). Each comes from the environment
(FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example.
The server checks when an upload starts, counting unfinished uploads so they
can't fill the disk unnoticed, and again when it finishes, counting what is
kept, so uploads started side by side can't overshoot; a refusal is a
FileLimitExceededException, and an upload refused as it finishes is deleted.
deletePerson deletes the allowance with the person. Migration: the new
table file_allowance, only added.
The app keeps a refused file waiting on the device and stops that round of
uploads; the Anki import warns when a deck's files may not fit in the room
left (asset.usage()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Both licences (AGPL for the app, SSPL for the server) require offering the source of what an instance runs to the people who use it. instance.info() now carries sourceCodeUrl: the operator's SOURCE_URL, which they must set when running modified code, and otherwise this project's repository. Serverpod validates its configuration files strictly, so the value comes from the environment; deploy/compose.yaml passes it on from deploy/.env, whose example explains it. A test covers the default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
As decided, a GitHub sign-in keeps nothing but the numeric GitHub ID, so the login is our own, on Serverpod's GitHub helpers (lib/src/auth/github.dart): Serverpod's stock login also stores the name and email in a profile and downloads the avatar, and its endpoint is never exposed. The server exchanges the authorization code with the client secret, reads the ID, and drops the access token.
GitHubUpgradeEndpoint links the GitHub ID to the signed-in anonymous person, grants identified (to their existing sessions too) and removes the anonymous identity, in one transaction: the person, their spaces and their studysets stay the same. GitHubSignInEndpoint gives a session for a person who has the GitHub account already; it's called without a session, since Serverpod issues no session for another person inside a signed-in call, and it never creates people. InstanceEndpoint.info() tells the app the instance's GitHub client ID. Refusals are GitHubSignInRefusedException with a reason: GitHub sign-in off here, the account taken by another person, another GitHub account linked already, an unknown account.
GitHub sign-in is on only where the passwords hold githubClientId and githubClientSecret (empty counts as missing, as Compose passes unset variables on as empty strings); production takes them from GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in deploy/.env. grantIdentified can now join a caller's transaction. No migration: the GitHub accounts table comes with the auth module.
Tests, refusals first: upgrading when not signed in (checked by switching the guard off: the test fails), both ways without a GitHub app, an account taken by someone else with nothing changed, a second GitHub account, an unknown account with nobody created; then linking keeps the person and reaches their sessions and stores no email, linking twice changes nothing, signing in reaches the right person.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Caddy, the Serverpod server and PostgreSQL 16 in deploy/. Only Caddy publishes ports; the server and Postgres are reachable only inside the Compose network (Docker bypasses ufw, and the server listens on every interface). Secrets come from deploy/.env as environment variables; there is no production passwords.yaml, and .dockerignore keeps local secrets out of the build context.
The server image builds on Serverpod's template with an Ubuntu 24.04 runtime: complete glibc incl. libgcc_s for the SQLite libraries Serverpod 4 bundles, CA certificates, a non-root user, and exec so SIGTERM reaches the server. The scaffold's dev-only docker-compose.yaml is removed.
Verified locally: image builds, all native libraries resolve, no passwords.yaml in the image, migrations apply at start, Caddy serves 200, port 8080 unreachable from the host, Postgres in UTC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>