feat(deploy): Docker Compose stack for the instance
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>
2026-09-27 17:18:31 +02:00
|
|
|
# Copy to deploy/.env, restrict it (chmod 600), and fill in. deploy/.env is git-ignored
|
2026-10-13 23:11:40 +02:00
|
|
|
# and never goes into an image. How it reaches the server is decided when the server is
|
feat(deploy): Docker Compose stack for the instance
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>
2026-09-27 17:18:31 +02:00
|
|
|
# set up (e.g. rendered from Proton Pass on the deploying machine and copied over SSH,
|
|
|
|
|
# so the server itself never holds Proton credentials).
|
|
|
|
|
|
2026-10-13 23:11:40 +02:00
|
|
|
# `http://localhost` for the local rehearsal; `learn.halkyon.eu` on its server.
|
feat(deploy): Docker Compose stack for the instance
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>
2026-09-27 17:18:31 +02:00
|
|
|
DOMAIN=http://localhost
|
|
|
|
|
|
feat(deploy): sidecars beside the instance, Jellyfin the first, behind an edge network
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>
2026-10-14 00:16:53 +02:00
|
|
|
# Sidecars switched on beside the instance (sidecars/README.md), each by its compose file
|
|
|
|
|
# after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to
|
|
|
|
|
# learn from. Unset: the instance alone.
|
|
|
|
|
#COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml
|
|
|
|
|
|
|
|
|
|
# With the Jellyfin sidecar: the domain it answers at, which the instance then suggests
|
|
|
|
|
# to its people (the app's Jellyfin sign-in fills it in).
|
|
|
|
|
#JELLYFIN_DOMAIN=jellyfin.example.org
|
|
|
|
|
|
feat(deploy): Docker Compose stack for the instance
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>
2026-09-27 17:18:31 +02:00
|
|
|
# Postgres password, shared by the postgres and server services.
|
|
|
|
|
# Generate: openssl rand -base64 32
|
|
|
|
|
POSTGRES_PASSWORD=
|
|
|
|
|
|
|
|
|
|
# Pepper for hashing session keys (server-side sessions). NEVER change it once
|
|
|
|
|
# sessions exist: everyone would be signed out, and anonymous accounts can't sign
|
|
|
|
|
# back in. Rotation only via fallbackSessionKeyHashPeppers.
|
|
|
|
|
# Generate: openssl rand -base64 32
|
|
|
|
|
SESSION_KEY_HASH_PEPPER=
|
feat(server): GitHub sign-in that keeps only the GitHub ID
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>
2026-09-30 13:35:51 +02:00
|
|
|
|
|
|
|
|
# GitHub sign-in (optional): the client ID and secret of this instance's own GitHub
|
|
|
|
|
# OAuth app (GitHub → Settings → Developer settings → OAuth Apps), registered with the
|
|
|
|
|
# callback URLs app.halkyon.learn://github-callback and http://127.0.0.1/github-callback.
|
|
|
|
|
# Empty: GitHub sign-in is off, and the app doesn't offer it.
|
|
|
|
|
GITHUB_CLIENT_ID=
|
|
|
|
|
GITHUB_CLIENT_SECRET=
|
2026-09-30 16:19:46 +02:00
|
|
|
|
feat(auth): codes go by mail, through the SMTP service an instance sets up
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>
2026-10-15 20:00:52 +02:00
|
|
|
# Email sign-in (optional, ADR 0010): people sign in with a code mailed to their address,
|
|
|
|
|
# of which the instance keeps only a keyed hash. Off unless the pepper and all of the SMTP
|
|
|
|
|
# service's settings are set; the app then doesn't offer it.
|
|
|
|
|
# The pepper: NEVER change it once anybody signs in with an address, as every address's
|
|
|
|
|
# hash would be lost, and with it those people's way in.
|
|
|
|
|
# Generate: openssl rand -hex 32
|
|
|
|
|
EMAIL_SIGN_IN_PEPPER=
|
|
|
|
|
# The SMTP service codes go through, over TLS only (STARTTLS, or TLS directly on 465).
|
|
|
|
|
# Proton (a paid plan with an address on your own domain): smtp.protonmail.ch, 587, the
|
|
|
|
|
# address as the user, an SMTP token made for it in Proton Mail's settings (IMAP/SMTP) as
|
|
|
|
|
# the password, and the same address as EMAIL_FROM. Some hosts block outgoing 25 and 465;
|
|
|
|
|
# 587 gets through.
|
|
|
|
|
EMAIL_SMTP_HOST=
|
|
|
|
|
EMAIL_SMTP_PORT=587
|
|
|
|
|
EMAIL_SMTP_USER=
|
|
|
|
|
EMAIL_SMTP_PASSWORD=
|
|
|
|
|
# The address codes come from (shown as "Halkyon Learn"); its domain needs SPF, DKIM and
|
|
|
|
|
# DMARC records for the service, or mail lands in spam.
|
|
|
|
|
EMAIL_FROM=
|
|
|
|
|
|
2026-09-30 16:19:46 +02:00
|
|
|
# Where the source of what this instance runs is, shown in the app's About dialog: both
|
|
|
|
|
# licences (AGPL for the app, SSPL for the server) require offering it to the people who
|
|
|
|
|
# use the instance. Empty: the project's repository, which is right for unmodified code.
|
|
|
|
|
# Running modified code, set it to where your modified source is published.
|
|
|
|
|
SOURCE_URL=
|
Limit how much room people's files take
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>
2026-10-02 05:57:57 +02:00
|
|
|
|
feat(dictionary): German, Greek, English, Chinese and Korean words
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>
2026-10-14 07:45:15 +02:00
|
|
|
# The languages besides Japanese whose dictionaries this instance keeps (the dictionaries
|
|
|
|
|
# service): Chinese from CC-CEDICT, weekly (some 4 MB), in simplified and traditional
|
docs: ADR 0009, feature parity for the other languages
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>
2026-10-14 12:46:34 +02:00
|
|
|
# characters; Korean from KRDict, about monthly (some 84 MB); their characters from
|
|
|
|
|
# Unihan, weekly (some 8 MB); the rest from Wiktionary (German and Greek with English
|
|
|
|
|
# meanings, English with German ones; for Korean the forms of its words and the
|
|
|
|
|
# readings of its Hanja), whose whole dump (some 3 GB) it downloads whenever kaikki.org
|
|
|
|
|
# makes a new one, about monthly, and converts in ten minutes. Unset: de,el,en,ko,zh.
|
|
|
|
|
# Empty: none. Files of a language taken out are removed, and the server unloads them.
|
feat(dictionary): German, Greek, English, Chinese and Korean words
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>
2026-10-14 07:45:15 +02:00
|
|
|
#DICTIONARY_LANGUAGES=de,el,en,ko,zh
|
|
|
|
|
|
feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
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>
2026-10-13 23:25:33 +02:00
|
|
|
# The Jellyfin server the app suggests to this instance's people, who watch and listen
|
|
|
|
|
# to learn from it (Halkyon Learn's Watch screen). Empty: `https://jellyfin.` on the
|
|
|
|
|
# domain registered for this instance (learn.example.org → jellyfin.example.org), if a
|
|
|
|
|
# Jellyfin server answers there; `none`: no suggestion.
|
|
|
|
|
JELLYFIN_URL=
|
|
|
|
|
|
Limit how much room people's files take
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>
2026-10-02 05:57:57 +02:00
|
|
|
# How much room people's images and sounds may take on this instance, in MiB. Empty: the
|
|
|
|
|
# defaults, 100 for an anonymous person (anonymous accounts are meant to become identified
|
|
|
|
|
# ones), 500 for one signed in with GitHub, and 15000 for everyone together (0: no limit
|
|
|
|
|
# for everyone together). Every file is in the database, so also in each of the 3 dumps
|
|
|
|
|
# this server keeps (and a fourth while a deploy writes its own): files take five times
|
|
|
|
|
# their size on the disk. One person may get more than the default; as the operator,
|
|
|
|
|
# in deploy/ (their id is their account's, "authUserId"):
|
|
|
|
|
# docker compose exec postgres psql -U halkyon_learn -d halkyon_learn -c "INSERT INTO
|
|
|
|
|
# file_allowance (\"authUserId\", \"maxBytes\", note) VALUES ('<id>', 2147483648, 'asked by mail')"
|
|
|
|
|
FILE_LIMIT_ANONYMOUS_MB=
|
|
|
|
|
FILE_LIMIT_IDENTIFIED_MB=
|
|
|
|
|
FILE_LIMIT_INSTANCE_MB=
|