halkyon/halkyon_learn_server/lib/server.dart

101 lines
3.7 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:io';
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The starting point of the Serverpod server.
Future<void> run(List<String> args) async {
// Initialize Serverpod. The generated Serverpod class is already connected
// with your project's generated code.
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
final pod = Serverpod(args);
pod
// Token managers validate and issue authentication keys; identity
// providers are the ways people can sign in (see auth_config.dart).
..initializeAuthServices(
tokenManagerBuilders: tokenManagerBuilders,
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
identityProviderBuilders: identityProviderBuilders(pod),
)
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
..addCloudStorage(DatabaseCloudStorage('private'))
..addCloudStorage(DatabaseCloudStorage('public'));
// Start the server.
await pod.start();
// Abandoned anonymous people are pruned daily (decided 2026-09-30).
// Scheduling always adds an entry, so the previous one goes first.
await pod.futureCalls.cancel(_pruning);
await pod.futureCalls
.callRecurring(identifier: _pruning)
.cron('17 3 * * *')
.pruning
.pruneAnonymousPeople();
// The IP addresses of sign-up attempts, hourly, once the rate limit is
// done with them (decided 2026-10-10).
await pod.futureCalls.cancel(_attemptPruning);
await pod.futureCalls
.callRecurring(identifier: _attemptPruning)
.cron('27 * * * *')
.pruning
.forgetSignUpAttempts();
// So are the files nobody can reach any more (ADR 0004, rule 5).
await pod.futureCalls.cancel(_filePruning);
await pod.futureCalls
.callRecurring(identifier: _filePruning)
.cron('47 3 * * *')
.filePruning
.pruneFiles();
// How many people learn each published studyset here (ADR 0005, rule 4).
await pod.futureCalls.cancel(_learnerCount);
await pod.futureCalls
.callRecurring(identifier: _learnerCount)
.cron('37 4 * * *')
.learnerCount
.countLearners();
// The dictionaries in the server's folder, those that changed since; in
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
// the background, as loading takes a minute or two. Then again hourly, as
// the dictionaries service replaces them weekly (and fills its volume
// first after an instance's first start).
2026-10-09 12:15:16 +02:00
unawaited(_loadDictionaries(pod));
await pod.futureCalls.cancel(_dictionaryReload);
await pod.futureCalls
.callRecurring(identifier: _dictionaryReload)
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
.cron('7 * * * *')
.dictionaryReload
.reloadDictionaries();
2026-10-09 12:15:16 +02:00
}
/// Loads the dictionaries in the server's folder ([dictionaryFolder]).
/// [loadDictionaries] throws nothing; a session that can't be had is
/// reported here, as an error in the background would end the server.
2026-10-09 12:15:16 +02:00
Future<void> _loadDictionaries(Serverpod pod) async {
try {
await pod.withSession(
(session) => loadDictionaries(session, dictionaryFolder()),
2026-10-09 12:15:16 +02:00
);
} on Object catch (error) {
stderr.writeln('Loading the dictionaries failed: $error');
}
}
/// The identifier of the daily pruning.
const _pruning = 'prune-abandoned-anonymous-people';
/// The identifier of the hourly forgetting of sign-up attempts.
const _attemptPruning = 'forget-sign-up-attempts';
/// The identifier of the daily pruning of files.
const _filePruning = 'prune-unreferenced-files';
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
/// The identifier of the hourly rereading of the dictionaries.
const _dictionaryReload = 'reload-dictionaries';
/// The identifier of the daily count of each published studyset's learners.
const _learnerCount = 'count-learners';