feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)

Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 14:50:55 +02:00
commit 5b9df73756
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 981 additions and 3339 deletions

View file

@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages** (decided 2026-10-19): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Todo's first model (the developer decides what it keeps and syncs). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Todo's first table). Authentication through the module's builders: anonymous, GitHub (with the instance's own OAuth app) and email codes, server-side sessions. **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone (GitHub's are Todo's own OAuth app's), and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts: anonymous people daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`) and `deletion-notices` (`account_deletion_notice`), nothing of Todo's own. Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own three sentences) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first) in Todo's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first) in Todo's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-checks` (lucide-static 1.50.0) in the brand's blues on white: `apps/todo/tool/app_icons.py` (run from `apps/todo`) fetches it, draws its strokes with Pillow (no SVG renderer needed; other path commands fail loudly), writes `assets/icon/halkyon_todo_icon.png` and Lucide's ISC licence beside it, then every platform's launcher icon through flutter_launcher_icons as Learn's tool does. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.

View file

@ -5,14 +5,11 @@
"launchProduct": "Todo",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Aufgaben liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"emailSignInSwitchMessage": "Diese Adresse hat bereits ein Konto. Die Aufgaben auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"startOtherWays": "Starte ohne Konto oder fahre mit deiner E-Mail-Adresse fort.",
"homeEmptyTitle": "Hier ist noch nichts",
"homeEmptyHint": "Aufgaben kommen mit einer späteren Version von Halkyon Todo.",
"accountTitle": "Konto",
"accountSignOutAnonymousMessage": "Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über"

View file

@ -7,15 +7,11 @@
"@launchProduct": {"description": "The app's word in its wordmark, under \"Halkyon\"."},
"instanceFirstStartSubtitle": "Choose the instance to keep your tasks on. Someone runs each one; you can add others later.",
"emailSignInSwitchMessage": "This address already has an account. The tasks on this device can't be combined with it yet: if you switch, they are deleted.",
"startOtherWays": "Start without an account, or continue with your email address.",
"@startOtherWays": {"description": "Said after a GitHub account nobody on the instance signs in with: the ways in the start screen offers instead."},
"homeEmptyTitle": "Nothing here yet",
"homeEmptyHint": "Tasks come with a later version of Halkyon Todo.",
"accountTitle": "Account",
"accountSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it.",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About"

View file

@ -118,18 +118,6 @@ abstract class AppLocalizations {
/// **'Choose the instance to keep your tasks on. Someone runs each one; you can add others later.'**
String get instanceFirstStartSubtitle;
/// No description provided for @emailSignInSwitchMessage.
///
/// In en, this message translates to:
/// **'This address already has an account. The tasks on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get emailSignInSwitchMessage;
/// Said after a GitHub account nobody on the instance signs in with: the ways in the start screen offers instead.
///
/// In en, this message translates to:
/// **'Start without an account, or continue with your email address.'**
String get startOtherWays;
/// No description provided for @homeEmptyTitle.
///
/// In en, this message translates to:
@ -148,12 +136,6 @@ abstract class AppLocalizations {
/// **'Account'**
String get accountTitle;
/// No description provided for @accountSignOutAnonymousMessage.
///
/// In en, this message translates to:
/// **'This account can\'t be signed into again, so signing out deletes it.'**
String get accountSignOutAnonymousMessage;
/// No description provided for @accountDeleteMessage.
///
/// In en, this message translates to:

View file

@ -19,14 +19,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get instanceFirstStartSubtitle =>
'Wähle die Instanz, auf der deine Aufgaben liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
@override
String get emailSignInSwitchMessage =>
'Diese Adresse hat bereits ein Konto. Die Aufgaben auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.';
@override
String get startOtherWays =>
'Starte ohne Konto oder fahre mit deiner E-Mail-Adresse fort.';
@override
String get homeEmptyTitle => 'Hier ist noch nichts';
@ -37,10 +29,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get accountTitle => 'Konto';
@override
String get accountSignOutAnonymousMessage =>
'Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.';
@override
String get accountDeleteMessage =>
'Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.';

View file

@ -19,14 +19,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get instanceFirstStartSubtitle =>
'Choose the instance to keep your tasks on. Someone runs each one; you can add others later.';
@override
String get emailSignInSwitchMessage =>
'This address already has an account. The tasks on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String get startOtherWays =>
'Start without an account, or continue with your email address.';
@override
String get homeEmptyTitle => 'Nothing here yet';
@ -37,10 +29,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get accountTitle => 'Account';
@override
String get accountSignOutAnonymousMessage =>
'This account can\'t be signed into again, so signing out deletes it.';
@override
String get accountDeleteMessage =>
'Your account is deleted on the server and on all your devices. This can\'t be undone.';

View file

@ -27,10 +27,7 @@ class const _AccountSheet() extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AccountTiles(
signOutAnonymousMessage: l10n.accountSignOutAnonymousMessage,
deleteMessage: l10n.accountDeleteMessage,
),
AccountTiles(deleteMessage: l10n.accountDeleteMessage),
// SimpleLogin keeps the account where the instance offers it
// (ADR 0020, D21), and a password and an authenticator, signing
// in on other devices and in the instance's other apps (ADR 0017).

View file

@ -33,9 +33,6 @@ class TodoDependencies({
/// Who is signed in, and signing in and out.
@override required final AuthRepository auth,
/// Whether the person here signed out, so the app asks how to start.
@override required final SignedOutMark signedOut,
/// Signing in with a password and TOTP, and what keeps an account (ADR
/// 0017).
@override required final PasswordRepository passwords,
@ -82,8 +79,8 @@ class TodoDependencies({
authSuccessStorageKey: device.storage.sessionKeyOf(instance),
),
);
// Restores a stored session before anything asks who is signed in;
// otherwise the first launch logic would sign in a second anonymous person.
// Restores a stored session before anything asks who is signed in, so
// the app doesn't ask how to start while someone is.
await client.auth.initialize();
final authGateway = ServerpodAuthGateway(
@ -121,7 +118,6 @@ class TodoDependencies({
return TodoDependencies(
serverName: instance.name,
auth: AuthRepository(authGateway, addresses),
signedOut: FileSignedOutMark(people),
passwords: passwords,
simpleLogin: simpleLogin,
fileSaver: PlatformFileSaver(),

View file

@ -9,15 +9,13 @@ const todoIcon = AssetImage('assets/icon/halkyon_todo_icon.png');
/// Halkyon Todo as halkyon_app's own screens know it (the first start, the
/// start screen, the account switcher, signing in): its name and word, its
/// icon, its look and languages, and the three sentences only it can say.
/// icon, its look and languages, and the sentence only it can say.
const todoFrame = AppFrame(
name: _name,
product: _product,
icon: todoIcon,
exampleAddress: 'todo.example.org',
firstStartSubtitle: _firstStartSubtitle,
switchLeavesBehind: _switchLeavesBehind,
thenAddGitHub: _startOtherWays,
theme: appTheme,
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
@ -33,12 +31,6 @@ String _product(BuildContext context) =>
String _firstStartSubtitle(BuildContext context) =>
AppLocalizations.of(context).instanceFirstStartSubtitle;
String _switchLeavesBehind(BuildContext context) =>
AppLocalizations.of(context).emailSignInSwitchMessage;
String _startOtherWays(BuildContext context) =>
AppLocalizations.of(context).startOtherWays;
/// What shows while the app opens: halkyon_ui's [LaunchScreen] with Todo's
/// icon, "Todo" beneath "Halkyon", read out as the app's name.
class const TodoLaunchScreen({super.key}) extends StatelessWidget {

View file

@ -10,7 +10,6 @@ void main() {
final builtIn = Instance.parse('todo.example.org')!;
late MemoryInstanceStore store;
late FakeAuthGateway gateway;
late MemorySignedOutMark signedOut;
late List<SignedIn> opened;
late MemorySignInAddresses addresses;
@ -18,16 +17,16 @@ void main() {
store = MemoryInstanceStore();
gateway = FakeAuthGateway();
addresses = MemorySignInAddresses();
signedOut = MemorySignedOutMark();
opened = [];
});
/// Starts Todo on a new install ([usedBefore] false) or on one that chose
/// its instance before, signed out as [wasSignedOut] says.
/// Starts Todo on a new install ([usedBefore] false), where nobody is
/// signed in, or on one that chose its instance before, signed in unless
/// [signedOut].
Future<void> start(
WidgetTester tester, {
bool usedBefore = false,
bool wasSignedOut = false,
bool signedOut = false,
bool passwords = true,
FakeSimpleLoginGateway? simpleLogin,
}) async {
@ -41,7 +40,9 @@ void main() {
sharesStorage: (a, b) => false,
);
await choice.load(usedBefore: usedBefore);
await signedOut.write(signedOut: wasSignedOut);
if (!usedBefore || signedOut) {
gateway.revokeSession();
}
await tester.pumpWidget(
TodoApp(
choice: choice,
@ -50,7 +51,6 @@ void main() {
open: (instance) async => TodoDependencies(
serverName: instance.name,
auth: AuthRepository(gateway, addresses),
signedOut: signedOut,
passwords: PasswordRepository(
FakePasswordGateway(gateway)..offered = passwords,
FakeAccountCrypto(),
@ -110,14 +110,22 @@ void main() {
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
});
testWidgets("the build's instance in one tap: an account without a name, "
'and an empty home', (tester) async {
testWidgets("the build's instance in one tap, then how to start on it, "
'making no account by itself (ADR 0020, D25)', (tester) async {
await start(tester);
await tester.tap(find.text('todo.example.org'));
await tester.pumpAndSettle();
expect(opened.single.isIdentified, isFalse);
expect(find.text('Start on todo.example.org:'), findsOneWidget);
expect(find.text('Create an account'), findsOneWidget);
expect(opened, isEmpty);
});
testWidgets("an account's home is empty until tasks come", (tester) async {
await start(tester, usedBefore: true);
expect(opened.single, FakeAuthGateway.person);
expect(find.text('Nothing here yet'), findsOneWidget);
expect(
find.text('Tasks come with a later version of Halkyon Todo.'),
@ -128,7 +136,7 @@ void main() {
testWidgets('signed out, it asks how to start; a GitHub account nobody here '
'signs in with is said so, with an account to create in its place '
'where passwords are offered (ADR 0017)', (tester) async {
await start(tester, usedBefore: true, wasSignedOut: true);
await start(tester, usedBefore: true, signedOut: true);
expect(find.text('Sign in with GitHub'), findsOneWidget);
gateway.gitHubError = const GitHubSignInFailed(
@ -139,18 +147,20 @@ void main() {
expect(
find.text(
'No account here signs in with this GitHub account yet. Create an '
'No account here signs in with this GitHub account. Create an '
'account with your email address and a password instead.',
),
findsOneWidget,
);
});
testWidgets("where no passwords are offered, with Todo's ways in", (
tester,
) async {
await start(tester, usedBefore: true, wasSignedOut: true, passwords: false);
testWidgets('where no account can be made, it says so', (tester) async {
await start(tester, usedBefore: true, signedOut: true, passwords: false);
expect(
find.text('todo.example.org offers no way to create an account.'),
findsOneWidget,
);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
@ -158,30 +168,24 @@ void main() {
await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. Start '
'without an account, or continue with your email address.',
),
find.text('No account here signs in with this GitHub account.'),
findsOneWidget,
);
});
testWidgets('the account says whose it is, on which instance, and signing '
'out of an anonymous one asks first, as it deletes it', (tester) async {
testWidgets('the account says on which instance it is signed in; signing '
'out deletes nothing and asks how to start', (tester) async {
await start(tester, usedBefore: true);
await openAccount(tester);
expect(find.text('Anonymous on todo.example.org'), findsOneWidget);
expect(find.text('Signed in on todo.example.org'), findsOneWidget);
expect(find.text('Switch account'), findsOneWidget);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(find.text('Sign out?'), findsOneWidget);
await tester.tap(find.text('Sign out').last);
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
expect(gateway.status, const SignedOut());
expect(find.text('Sign in with GitHub'), findsOneWidget);
});