The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40).
An invitation by mail (sharedSpace.inviteByEmail) goes out through the
register (send-space-invitation), naming who invites by their handle
and nothing of what is shared, with a link to /join/<token>. The
instance keeps only the address's keyed hash, as for sign-in, and the
SHA-256 of the token. Whoever opens it joins with a code mailed to that
address (EmailCodePurpose.joinSpace, proven through the register,
prove-address-code), so a forwarded mail admits nobody else; a code
proving another address is refused. It lapses after 14 days, and the
hourly pruning forgets it; inviting the address again replaces it.
A space's own link (setJoinLink, the Owner's) admits whoever opens it
as a Viewer (joinByLink) while it is on; switched off and on, it gets a
new token. Its token is kept as it is, so that whoever may invite copies
the link again (joinLink). The page at /join/<token>, in English and
German, says who invites and as whom; preview tells the app the same.
A membership someone else made is new to its member until they mark it
seen (isNew, addedBy; mine names who shared it), for the Inbox.
halkyon_app learns the two new refusals (handleNeeded, otherAddress),
which Learn reports as not allowed until its screens offer these ways
in. The ADR records D37 to D40 and how the instance's half is built.
The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):
ALTER TABLE "shared_space" ADD COLUMN "joinToken" text;
CREATE UNIQUE INDEX "shared_space_join_token_idx"
ON "shared_space" ("joinToken");
CREATE TABLE "shared_space_email_invitation" (…), with its unique
indexes on the space and address hash, and on the token's hash,
and its foreign key to "shared_space" ON DELETE CASCADE;
ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid;
ALTER TABLE "shared_space_member"
ADD COLUMN "isNew" boolean NOT NULL DEFAULT false;
Every space gets no token, and the unique index allows any number of
nulls; every membership gets no one who added it and isn't new, so
nothing already shared shows in an Inbox. The new table starts empty.
None of it rewrites a table, and the server before the switch names its
own columns, so it keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
164 lines
6.7 KiB
Dart
164 lines
6.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
|
|
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
|
|
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
|
|
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
|
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
|
|
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.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.
|
|
final pod = Serverpod(args);
|
|
// What every Halkyon server has (ADR 0014): sign-in, the account and its
|
|
// deletion, the pruning, the instance's basics, set up for Learn.
|
|
// Learn holds the instance's register of accounts (ADR 0016), telling the
|
|
// instance's other services when one goes.
|
|
HalkyonBase.configure(pod, learnBase(accounts: learnAccounts()));
|
|
pod
|
|
// Token managers validate and issue authentication keys; identity
|
|
// providers are the ways people can sign in (halkyon_base's
|
|
// auth_config.dart).
|
|
..initializeAuthServices(
|
|
tokenManagerBuilders: tokenManagerBuilders,
|
|
identityProviderBuilders: identityProviderBuilders(pod),
|
|
)
|
|
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
|
|
..addCloudStorage(DatabaseCloudStorage('private'))
|
|
..addCloudStorage(DatabaseCloudStorage('public'));
|
|
|
|
// The calls of the instance's other services, on the web server
|
|
// (ADR 0016); Caddy never forwards them.
|
|
addInstanceRoutes(pod);
|
|
// Where SimpleLogin sends a sign-in back, which forwards it to the app
|
|
// (ADR 0020): the register's page, as its SimpleLogin app is.
|
|
addSimpleLoginCallbackRoute(pod);
|
|
// The instance's privacy page, `/privacy`, made from its settings (ADR
|
|
// 0021, D39), before the profile pages' route, whose one segment would
|
|
// take it.
|
|
addPrivacyPageRoute(pod);
|
|
// The pages of links to join a shared studyset, `/join/<token>` (Halkyon's
|
|
// ADR 0044, D39).
|
|
addJoinPageRoute(pod);
|
|
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
|
|
// halkyon_base's since profiles are the instance's register's (ADR 0016).
|
|
addProfilePageRoute(pod);
|
|
// What devices download to look Japanese up by themselves (ADR 0012).
|
|
addDeviceDictionaryRoute(pod);
|
|
|
|
// Start the server.
|
|
await pod.start();
|
|
|
|
// Whether a Jellyfin server answers where the instance would suggest one
|
|
// (decided 2026-10-13), so instance.info() can name it from the first ask.
|
|
unawaited(JellyfinSuggestion.of(pod).look());
|
|
// Which languages the instance's LanguageTool checks (ADR 0024), likewise.
|
|
unawaited(GrammarChecking.of(pod).look());
|
|
|
|
// The accounts nobody signs into any more, daily (decided 2026-09-30;
|
|
// every anonymous account left since ADR 0020, D25). Scheduling always
|
|
// adds an entry, so the previous one goes first, and the one scheduled
|
|
// under the earlier name, which would call nothing.
|
|
await pod.futureCalls.cancel(_anonymousPruning);
|
|
await pod.futureCalls.cancel(_pruning);
|
|
await pod.futureCalls
|
|
.callRecurring(identifier: _pruning)
|
|
.cron('17 3 * * *')
|
|
.pruning
|
|
.pruneAccounts();
|
|
|
|
// 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();
|
|
|
|
// The deletions the instance's members haven't confirmed yet, hourly
|
|
// (ADR 0016).
|
|
await pod.futureCalls.cancel(_deletionNotices);
|
|
await pod.futureCalls
|
|
.callRecurring(identifier: _deletionNotices)
|
|
.cron('52 * * * *')
|
|
.pruning
|
|
.tellMembersOfDeletions();
|
|
|
|
// The assistant connector went with its codes and tokens (ADR 0022): what
|
|
// an earlier server scheduled to forget them would call nothing.
|
|
await pod.futureCalls.cancel(_connectorPruning);
|
|
|
|
// The files nobody can reach any more, daily (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
|
|
// 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).
|
|
unawaited(_loadDictionaries(pod));
|
|
await pod.futureCalls.cancel(_dictionaryReload);
|
|
await pod.futureCalls
|
|
.callRecurring(identifier: _dictionaryReload)
|
|
.cron('7 * * * *')
|
|
.dictionaryReload
|
|
.reloadDictionaries();
|
|
}
|
|
|
|
/// 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.
|
|
Future<void> _loadDictionaries(Serverpod pod) async {
|
|
try {
|
|
await pod.withSession((session) async {
|
|
await loadDictionaries(session, dictionaryFolder());
|
|
// What devices download, ready before the first asks (ADR 0012).
|
|
await DeviceDictionaries.of(pod).prepare(session);
|
|
});
|
|
} on Object catch (error) {
|
|
stderr.writeln('Loading the dictionaries failed: $error');
|
|
}
|
|
}
|
|
|
|
/// The identifier of the daily pruning.
|
|
const _pruning = 'prune-accounts';
|
|
|
|
/// The identifier the daily pruning had until ADR 0020, D25.
|
|
const _anonymousPruning = 'prune-abandoned-anonymous-people';
|
|
|
|
/// The identifier of the hourly forgetting of sign-up attempts.
|
|
const _attemptPruning = 'forget-sign-up-attempts';
|
|
|
|
/// The identifier of the hourly telling of the members of deleted accounts.
|
|
const _deletionNotices = 'tell-members-of-deletions';
|
|
|
|
/// The identifier of the daily forgetting of the connector's expired secrets,
|
|
/// scheduled until ADR 0022.
|
|
const _connectorPruning = 'forget-connector-secrets';
|
|
|
|
/// The identifier of the daily pruning of files.
|
|
const _filePruning = 'prune-unreferenced-files';
|
|
|
|
/// 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';
|