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
2 changed files with 981 additions and 3339 deletions

View file

@ -3,85 +3,79 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
group('AuthRepository.ensureSignedIn', () {
test('signs in anonymously when nobody is signed in', () async {
final gateway = FakeAuthGateway();
/// An anonymous account's session, left from before they went (ADR 0020,
/// D25).
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 1);
group('AuthRepository.isAnonymous', () {
test('tells a session without an identity, left from before', () {
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
AuthRepository(
FakeAuthGateway(status: anonymous),
MemorySignInAddresses(),
).isAnonymous,
isTrue,
);
});
test('keeps the person already signed in', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
final gateway = FakeAuthGateway(status: stored);
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 0);
expect(gateway.status, stored);
test("not an account's, nor nobody signed in", () {
expect(
AuthRepository(FakeAuthGateway(), MemorySignInAddresses()).isAnonymous,
isFalse,
);
expect(
AuthRepository(
FakeAuthGateway(status: const SignedOut()),
MemorySignInAddresses(),
).isAnonymous,
isFalse,
);
});
});
group('AuthRepository.signOut', () {
test(
'deletes an anonymous account, which nobody could sign into again',
() async {
final gateway = FakeAuthGateway(
status: const SignedIn(
authUserId: 'anonymous-1',
isIdentified: false,
),
);
group('AuthRepository.endAnonymousSession', () {
test('deletes the anonymous account, which nobody could sign into '
'again', () async {
final gateway = FakeAuthGateway(status: anonymous);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
await AuthRepository(
gateway,
MemorySignInAddresses(),
).endAnonymousSession();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 0);
expect(gateway.status, const SignedOut());
},
);
test(
'gives an anonymous account up all the same when deleting it fails, '
'as while the register is out of reach: the device signs out, the '
'account left to the pruning (the short reading, L2)',
() async {
final gateway = FakeAuthGateway(
status: const SignedIn(
authUserId: 'anonymous-1',
isIdentified: false,
),
)..deletionError = Exception('Internal server error');
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 1);
expect(gateway.status, const SignedOut());
},
);
test('only signs an identified person out on this device', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 0);
expect(gateway.status, const SignedOut());
});
test('gives it up all the same when deleting it fails, as while the '
'instance is out of reach: the device signs out, the account left '
'to the pruning', () async {
final gateway = FakeAuthGateway(status: anonymous)
..deletionError = Exception('Internal server error');
await AuthRepository(
gateway,
MemorySignInAddresses(),
).endAnonymousSession();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 1);
expect(gateway.status, const SignedOut());
});
});
test('signing out only signs out on this device, deleting nothing', () async {
final gateway = FakeAuthGateway();
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
});
test('deletes the account of whoever is signed in', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
final gateway = FakeAuthGateway();
await AuthRepository(gateway, MemorySignInAddresses()).deleteAccount();

View file

@ -19,14 +19,12 @@ final class _MemoryStorage() implements ClientAuthSuccessStorage {
Future<void> set(AuthSuccess? data) async => _session = data;
}
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub (and passwords while
/// [passwords], SimpleLogin while [simpleLogin]), and a sign-up makes Ben,
/// signed in with `ben-token`.
/// An instance's halkyon_base as the network shows it: its basics offer
/// GitHub while it has [gitHubClientId], and a sign-up makes Ben, signed
/// in with `ben-token`.
final class _Instance() {
/// Whether its basics offer password sign-in, and SimpleLogin.
bool passwords = false;
bool simpleLogin = false;
/// The client ID of its GitHub OAuth app; none without.
String? gitHubClientId = 'github-client';
static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
@ -43,20 +41,16 @@ final class _Instance() {
calls.add((call: call, session: request.headers['authorization']));
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.anonymousIdp/login' => http.Response(
sent(_session(anonymous, 'anonymous-token', identified: false)),
200,
),
'halkyon_base.instance/basics' => http.Response(
sent(
InstanceBasics(
app: 'learn',
gitHubClientId: 'github-client',
gitHubClientId: gitHubClientId,
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1,
passwordSignIn: passwords,
simpleLogin: simpleLogin,
passwordSignIn: true,
simpleLogin: true,
).toJson(),
),
200,
@ -65,7 +59,6 @@ final class _Instance() {
sent(_session(ben, 'ben-token', identified: true)),
200,
),
'halkyon_base.account/delete' => http.Response('null', 200),
_ => http.Response('', 404),
};
});
@ -111,46 +104,42 @@ void main() {
});
});
test(
'signs in anonymously through halkyon_base, nobody identified',
() async {
await gateway.signInAnonymously();
test("tells who is signed in from the session it holds: an account's "
"has its identity; one without is an anonymous account's, left from "
'before they went (ADR 0020, D25)', () async {
expect(gateway.status, const SignedOut());
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false),
);
},
);
test('tells what the instance offers from its basics: GitHub sign-in, '
'and linking GitHub to people only where neither passwords nor '
'SimpleLogin are (ADR 0020, D23)', () async {
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: true),
);
instance.passwords = true;
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: false),
);
expect(
instance.calls.where(
(each) => each.call == 'halkyon_base.instance/basics',
await app.auth.updateSignedInUser(
AuthSuccess.fromJson(
_Instance._session(_Instance.ben, 'ben-token', identified: true),
),
hasLength(2),
reason: 'one read of the basics each',
);
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
);
instance
..passwords = false
..simpleLogin = true;
expect(
await gateway.gitHubOffer(),
const GitHubOffer(signIn: true, linking: false),
await app.auth.updateSignedInUser(
AuthSuccess.fromJson(
_Instance._session(
_Instance.anonymous,
'anonymous-token',
identified: false,
),
),
);
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false),
);
});
test('tells from the basics whether the instance signs in with GitHub '
'those who have it', () async {
expect(await gateway.gitHubSignInOffered(), isTrue);
instance.gitHubClientId = null;
expect(await gateway.gitHubSignInOffered(), isFalse);
});
test("puts the instance's refusals of GitHub as the app's problems", () {
@ -172,10 +161,8 @@ void main() {
);
});
test('a session granted elsewhere (a sign-up with a password, without '
"this device's session) takes its place, deleting the anonymous "
'account left behind as itself', () async {
await gateway.signInAnonymously();
test('a session granted elsewhere (a sign-up with a password, signed '
'out) takes its place', () async {
final passwords = ServerpodPasswordGateway(
app,
base: app.modules,
@ -216,9 +203,11 @@ void main() {
(call) => call.call == 'halkyon_base.passwordSignIn/signUp',
);
expect(signUp.session, isNull, reason: 'no session goes with it');
final deletion = instance.calls.singleWhere(
(call) => call.call == 'halkyon_base.account/delete',
expect(
instance.calls.where(
(call) => call.call == 'halkyon_base.account/delete',
),
isEmpty,
);
expect(deletion.session, contains('anonymous-token'));
});
}