halkyon/deploy/compose.learn.yaml
Tizian 「ティツィアーン」 7aee78bd87
feat(base,app,learn,todo,train,meditate): agent accounts with tokens
ADR 0044's step 6a (D42, D43, D45): accounts for programs, such as AI
assistants, that an identified person makes from the Profile.

halkyon_base keeps them on the instance's register, which its members
ask: an agent is an account (agent_account: its owner, its name, its
public keys, set by itself once) with one token (agent_token: 32
random bytes, kept as their SHA-256 alone, shown once). agents.create,
mine, newToken, revoke, delete and setKeys serve its owner;
agentSignIn.signIn exchanges the token for an ordinary server-side
session of a day, with the scopes identified and agent, at most 60
sign-ins an hour from a network. Revoking a token or making a new one
ends the agent's sessions; a token unused for 90 days lapses in every
service's pruning. Up to 5 agents a person, where the operator didn't
switch them off (AGENTS=off; said on the privacy page and in
instance.basics). An agent makes no agents, creates or owns no shared
space, is invited and raised as a Viewer alone, is never handed a
space on, is named no operator and granted no assistants; each refusal
has its test. Deleting a person deletes their agents first; an agent's
public profile names whose agent it is.

In the apps, every Profile has an Agents card (halkyon_app's
AgentsSection): made with a name and a handle, its token shown once to
copy, a new token, the token taken back, deleted. Learn's Share sheet
marks an agent among the members and offers it no role above Viewer.

The migrations, the module's and each host's (Learn, Meditate, Todo,
Train), are additive alone: CREATE TABLE "agent_account" with a unique
index on "agentId" (one row an agent account) and one on "ownerId" (a
person's agents, and deleting them with the person), and CREATE TABLE
"agent_token" with unique indexes on "agentId" (one token an agent)
and "tokenHash" (the sign-in finds the token by its hash). No existing
table is touched, so the server before the deploy keeps working on the
migrated database.

Whats-New: Make agents in your Profile: accounts for programs such as AI assistants, which sign in with a token and see only what you share with them.
Whats-New-De: Leg in deinem Profil Agenten an: Konten für Programme wie KI-Assistenten, die sich mit einem Token anmelden und nur sehen, was du mit ihnen teilst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 19:56:13 +01:00

235 lines
11 KiB
YAML

# The Halkyon Learn instance: Caddy in front, the Serverpod server, PostgreSQL, and
# the dictionaries kept current beside them.
#
# cd deploy
# cp .env.example .env && chmod 600 .env # then fill in the secrets
# docker compose up -d --build
#
# This file was compose.yaml until 2 Nov 2026, so .env's COMPOSE_FILE names it (Compose
# finds no file by itself).
#
# Only Caddy publishes ports. Docker opens published ports directly in the kernel's
# packet filter, past ufw, and the Serverpod server listens on every interface, so the
# server and Postgres publish nothing: they're reachable only inside this project's
# networks, where Caddy finds the server by its service name.
#
# Two networks (decided 2026-10-14): `edge`, Caddy's, which the server shares with it,
# and the internal default one, where the server reaches Postgres. Caddy never reaches
# Postgres, nor does a sidecar behind Caddy.
#
# Sidecars (decided 2026-10-14) are each a compose file of their own beside this one,
# switched on in .env (COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml): each adds its
# service and mounts its site into Caddy's sidecars/ folder (sidecars/README.md).
# Never renamed, though the checkout's folder is ~deploy/halkyon since 2 Nov 2026: Docker
# names the volumes (the database, people's files, the releases' repositories) and the
# edge network after the project, and under a new name would start them empty.
name: halkyon-learn
# Logs go to the host's journal, whose size and age limits apply (journald.conf).
x-logging: &logging
driver: journald
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
# `learn.halkyon.eu` on its server (automatic Let's Encrypt certificate);
# `http://localhost` for the local rehearsal (plain HTTP, no certificate).
LEARN_DOMAIN: ${LEARN_DOMAIN:?set LEARN_DOMAIN in deploy/.env}
# The account domain whose profile pages this instance serves, where it isn't
# LEARN_DOMAIN; empty for none.
ACCOUNT_SITE: ${ACCOUNT_DOMAIN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates: must survive restarts (rate limits)
- caddy_config:/config
networks:
edge:
# Fixed, so containers behind Caddy can trust it as their proxy (the
# visitor's own address comes in X-Forwarded-For): the server's
# TRUSTED_PROXIES, Jellyfin's known proxies. Both, as Caddy may reach
# the server by either.
ipv4_address: 172.30.80.2
ipv6_address: fd4a:6c6b:7e80::2
depends_on:
- server
logging: *logging
server:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_server/Dockerfile
image: halkyon-learn-server
restart: unless-stopped
# Applies pending migrations at every start, then serves. One instance only, so
# no two servers ever migrate at once. (On learn.halkyon.eu, deploy/redeploy.sh has
# applied them in a run of their own before, so nothing is left to do here.)
command: ["--apply-migrations"]
# The instance's assistants with their keys (Halkyon Learn's ADR 0021, D37), passed
# whole, as their variables' names are the IDs the operator chooses; optional, so an
# instance without assistants starts as before (assistants.env.example).
env_file:
- path: assistants.env
required: false
environment:
SERVERPOD_DATABASE_PASSWORD: ${LEARN_POSTGRES_PASSWORD:?set LEARN_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the host of
# LEARN_DOMAIN where the instance isn't learn.halkyon.eu (halkyon.li's
# learn.halkyon.li). A host alone, never a URL; unset: learn.halkyon.eu, as the
# file has it, which the local rehearsal keeps too.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
# Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off.
SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-}
SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-}
SERVERPOD_PASSWORD_emailSmtpPort: ${EMAIL_SMTP_PORT:-}
SERVERPOD_PASSWORD_emailSmtpUser: ${EMAIL_SMTP_USER:-}
SERVERPOD_PASSWORD_emailSmtpPassword: ${EMAIL_SMTP_PASSWORD:-}
SERVERPOD_PASSWORD_emailFrom: ${EMAIL_FROM:-}
# Password sign-in with TOTP (ADR 0017), only with these keys and email
# sign-in; empty: off. Each at least 32 characters, or the server stops.
SERVERPOD_PASSWORD_totpKey: ${TOTP_KEY:-}
SERVERPOD_PASSWORD_recoveryCodeKey: ${RECOVERY_CODE_KEY:-}
SERVERPOD_PASSWORD_signInDecoyKey: ${SIGN_IN_DECOY_KEY:-}
# SimpleLogin (ADR 0020), only with the instance's SimpleLogin app; empty: off.
# Its callback; empty: this server's own page, on its web server's public address
# (https://learn.halkyon.eu/simplelogin-callback).
SERVERPOD_PASSWORD_simpleLoginClientId: ${SIMPLELOGIN_CLIENT_ID:-}
SERVERPOD_PASSWORD_simpleLoginClientSecret: ${SIMPLELOGIN_CLIENT_SECRET:-}
SIMPLELOGIN_CALLBACK_URL: ${SIMPLELOGIN_CALLBACK_URL:-}
# The ways in offered, of those set up (`simplelogin password`); empty: all.
SIGN_IN_WAYS: ${SIGN_IN_WAYS:-}
# Who may make an account: open, approval or closed; empty: open.
SIGN_UP: ${SIGN_UP:-}
# Whether people may make agents: on or off; empty: on.
AGENTS: ${AGENTS:-}
# Caddy, whose X-Forwarded-For names the visitor the per-network limits count
# (codes by mail, password and SimpleLogin sign-ins): without it, every
# visitor would share each limit with everyone. Its address on `edge`, below.
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
# The domain people's handles are on; empty: LEARN_DOMAIN's host.
ACCOUNT_DOMAIN: ${ACCOUNT_DOMAIN:-}
# The instance's other services (ADR 0016), whose accounts this server's
# register keeps: the secret every call between them carries, and their web
# servers, told when an account goes (beside those the services' own compose
# files name, INSTANCE_MEMBER_TRAIN and INSTANCE_MEMBER_TODO); empty: this is
# the instance's only service.
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:-}
INSTANCE_MEMBERS: ${INSTANCE_MEMBERS:-}
# The Jellyfin server suggested to people; empty: jellyfin. on the
# instance's own domain, if one answers there; `none`: none.
JELLYFIN_URL: ${JELLYFIN_URL:-}
# The LanguageTool server checking spelling and grammar, reached by the server
# alone; empty: none (compose.languagetool.yaml sets its own).
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL:-}
# Where people's data and the backups are, which the app shows: `EU`, a
# country (`DE`) or a subdivision (`US-CA`); empty: it says nothing.
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
# How much room people's files may take, in MiB; empty: the defaults.
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
# The git repositories of published studysets (ADR 0005, rule 3).
REPOSITORIES_DIR: /app/repositories
# The dictionary files it loads at start and rereads hourly, those that changed
# (see the server's README): those the dictionaries service keeps current, read
# only. To supply files yourself instead, remove that service and mount
# ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`).
volumes:
- dictionaries:/app/dictionaries:ro
# Every release of every published studyset: backed up beside the database
# (backup.sh), never deleted with `down -v`.
- repositories:/app/repositories
networks:
- default
- edge
depends_on:
postgres:
condition: service_healthy
logging: *logging
# Keeps the dictionary files current from EDRDG's files of the day, once they are a
# week old (EDRDG's licence asks it of servers showing its dictionaries at least
# monthly; the curator's README), and the languages of DICTIONARY_LANGUAGES: Chinese
# from MDBG's CC-CEDICT as often, the rest from Wiktionary's dump whenever kaikki.org
# makes a new one (about monthly, some 3 GB, converted as it downloads): in a
# container of its own, the curator never in the server's image or process. It only
# downloads and writes into the volume; the files are never backed up, as they can be
# had again.
dictionaries:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_curator/Dockerfile
image: halkyon-learn-dictionaries
restart: unless-stopped
# Docker's stop signal reaches the curator through a small init.
init: true
# JMdict's conversion takes some 400 MB for a minute a week, Wiktionary's some
# 300 MB for ten minutes a month, KRDict's some 600 MB for half a minute.
mem_limit: 1g
# Unset: German, Greek, English, Korean and Chinese; empty: none.
command: ["--every", "7", "--languages", "${DICTIONARY_LANGUAGES-de,el,en,ko,zh}"]
volumes:
- dictionaries:/dictionaries
logging: *logging
postgres:
image: postgres:16
restart: unless-stopped
# The server's session is UTC anyway (the driver sets it); this makes the
# database's own default UTC too, for psql, COPY loads and future sidecars.
command: ["postgres", "-c", "timezone=UTC", "-c", "log_timezone=UTC"]
environment:
POSTGRES_USER: halkyon_learn
POSTGRES_DB: halkyon_learn
POSTGRES_PASSWORD: ${LEARN_POSTGRES_PASSWORD:?set LEARN_POSTGRES_PASSWORD in deploy/.env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U halkyon_learn -d halkyon_learn"]
interval: 5s
timeout: 5s
retries: 12
logging: *logging
networks:
edge:
name: halkyon-learn-edge
# IPv6 too (the second reading's M5), so Docker forwards a visitor's IPv6
# connection to Caddy as it is, rather than through its userland proxy,
# which would show every IPv6 visitor as the network's gateway and so make
# them share every per-network limit. Needs Docker 27 or later with ip6tables
# (its default; not switched off in /etc/docker/daemon.json), and the network
# made anew: `docker compose down` (never -v) and `up -d` once, as Compose
# refuses a network whose settings changed. A unique local /64 (RFC 4193), the
# host's own; the CI runner's unit denies fd00::/8.
enable_ipv6: true
ipam:
config:
- subnet: 172.30.80.0/24
gateway: 172.30.80.1
# Containers without an address of their own (the server) get one from the
# upper half, so the fixed ones below it (Caddy .2, sidecars from .10) are
# never taken: the server started first once and took Caddy's (2026-10-14).
# (With a range alone, Docker would move the gateway to its first address.)
ip_range: 172.30.80.128/25
- subnet: fd4a:6c6b:7e80::/64
gateway: fd4a:6c6b:7e80::1
# As above: Caddy's ::2 and the sidecars' below it stay free.
ip_range: fd4a:6c6b:7e80::8000:0/97
volumes:
caddy_data:
caddy_config:
dictionaries:
postgres_data:
repositories: