feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped

The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.

- The module signs nobody in anonymously: `anonymousIdp` and
  `gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
  `firstPassword` code) or who signs in with GitHub alone (a fresh
  sign-in and a `signUp` code); an anonymous account is refused
  `wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
  (`wrongCaller`), the member's endpoint too, so nothing turns an
  anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
  new, never one identified. Learn and Todo schedule it as
  `prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
  entry once.
- Learn: someone without `identified` keeps no files;
  `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
  up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
  development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.

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 16:07:27 +02:00
commit 87671208df
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 837 additions and 1353 deletions

View file

@ -184,10 +184,13 @@ void main() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
device = await PasswordDevice.make();
anna = await newAnonymousPerson(session);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(session, anna);
annasLaptop = await signInNewDevice(session, anna);
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
onPhone(),
setup: device.setup(),
@ -296,7 +299,7 @@ void main() {
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
change(
@ -539,7 +542,10 @@ void main() {
test('which nobody else reads or dismisses, nor anyone signed '
'out', () async {
final ended = await endPhone();
final bert = await newAnonymousPerson(session);
final bert = await newPersonWithAddress(
session,
'bert@example.org',
);
final bertsPhone = await signInNewDevice(session, bert);
final asBert = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -615,7 +621,7 @@ void main() {
});
test('is begun only by someone with a password', () async {
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
@ -844,7 +850,7 @@ void main() {
newPhrase(purpose: PasswordProofPurpose.newPassword),
refused(PasswordRefusal.wrongCredentials),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
newPhrase(
@ -969,7 +975,7 @@ void main() {
'code by mail asked for deleting, nothing less, nor one asked for '
'anything else',
() async {
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final bens = await PasswordDevice.make(seed: 7);
TestSessionBuilder asBen() => sessionBuilder.copyWith(
@ -979,7 +985,10 @@ void main() {
authId: bensLaptop.uuid,
),
);
final setUp = await mailedCode('ben@example.org');
final setUp = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen(),
setup: bens.setup(),
@ -1132,7 +1141,8 @@ void main() {
});
test(
'of an anonymous person takes its session alone, as before',
'of an anonymous account left from before takes its session '
'alone, as the app deletes it (ADR 0020, D25)',
() async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);

View file

@ -34,7 +34,8 @@ void main() {
/// The tests' time, moved on by hand.
late DateTime now;
/// Anonymous, signed in on her phone.
/// Signs in with a code mailed to anna@example.org, as accounts did
/// before passwords (ADR 0017), on her phone.
late UuidValue anna;
late UuidValue annasPhone;
@ -47,8 +48,12 @@ void main() {
EmailCodes.of(pod).mailer = mailbox;
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(
session,
anna,
scopes: {identified},
);
device = await PasswordDevice.make();
});
tearDown(() async {
@ -126,9 +131,13 @@ void main() {
return (request: request, code: mailbox.codeFor(address));
}
/// Anna's password, with anna@example.org made hers by a code.
/// Anna's password, anna@example.org proved hers by a code mailed
/// there (the fourth reading: a session alone sets none).
Future<void> annaSetsPassword({PasswordDevice? with_}) async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asAnna(),
setup: (with_ ?? device).setup(),
@ -209,24 +218,14 @@ void main() {
(await AuthUser.db.findById(session, person))!.scopeNames;
group('setting a password', () {
test('makes an anonymous person identified with the address a code '
'proved, keeping their ID, sessions and data', () async {
test('gives someone whose address is theirs a password, keeping '
'their ID, sessions and data', () async {
await annaSetsPassword();
expect(await scopesOf(anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))!.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(
(await EmailIdentity.db.findFirstRow(
@ -317,13 +316,58 @@ void main() {
);
});
test('is refused to an anonymous account left from before '
'(ADR 0020, D25): a code to any address sets nothing, and stays '
'unused', () async {
final ben = await newAnonymousPerson(session);
final bensPhone = await signInNewDevice(session, ben);
final code = await mailedCode('ben@example.org');
await expectLater(
endpoints.password.set(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensPhone.uuid,
),
),
setup: device.setup(),
request: code.request,
code: code.code,
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(await AccountPassword.db.count(session), 0);
expect(await scopesOf(ben), isNot(contains(identified.name)));
// The code still signs up whoever asked for it.
final signedUp = await endpoints.passwordSignIn.signUp(
notSignedIn(),
request: code.request,
code: code.code,
setup: device.setup(),
);
expect(signedUp.authUserId, isNot(ben));
});
group('for someone who signs in with GitHub alone', () {
setUp(() async {
// Anna has no address here: GitHub is her only way in.
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
await grantIdentified(session, anna);
});
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
@ -386,15 +430,58 @@ void main() {
1,
);
});
test('is refused without an address, keeping nothing', () async {
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await expectLater(
atNow(
() =>
endpoints.password.set(from(fresh), setup: device.setup()),
),
refused(PasswordRefusal.noAddress),
);
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for an address another account has, keeping '
'nothing', () async {
await newPersonWithAddress(session, 'ben@example.org');
final code = await mailedCode('ben@example.org');
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await expectLater(
atNow(
() => endpoints.password.set(
from(fresh),
setup: device.setup(),
request: code.request,
code: code.code,
),
),
refused(PasswordRefusal.addressTaken),
);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await AccountPassword.db.count(session), 0);
});
});
test('is refused to someone neither anonymous nor with a way in (a '
'GitHub ID forgotten): a session alone and a code to any address '
'set nothing', () async {
// Anna, identified, her GitHub ID forgotten (the full reading's
// M1): a session of hers, stolen, outlived it.
await grantIdentified(session, anna);
await AnonymousAccount.db.deleteWhere(
test('is refused to someone without a way in (a GitHub ID '
'forgotten): a session alone and a code to any address set '
'nothing', () async {
// Anna, identified, without an address, her GitHub ID forgotten
// (the full reading's M1): a session of hers, stolen, outlived it.
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
@ -425,45 +512,8 @@ void main() {
);
});
test('is refused without an address, keeping nothing', () async {
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
refused(PasswordRefusal.noAddress),
);
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for an address another account has, the person '
'staying anonymous', () async {
final ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: clock.now(),
),
);
final code = await mailedCode('ben@example.org');
await expectLater(
endpoints.password.set(
asAnna(),
setup: device.setup(),
request: code.request,
code: code.code,
),
refused(PasswordRefusal.addressTaken),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
expect(await AccountPassword.db.count(session), 0);
});
test('is refused for a wrong code, keeping nothing', () async {
final code = await mailedCode('anna@example.org');
final code = await ownAddressCode();
await expectLater(
endpoints.password.set(
@ -475,7 +525,7 @@ void main() {
refusedAsEmail(EmailSignInRefusal.wrongCode),
);
expect(await AccountPassword.db.count(session), 0);
expect(await EmailIdentity.db.count(session), 0);
expect(await EmailIdentity.db.count(session), 1);
});
test('is refused for a setup out of range, part by part', () async {
@ -524,7 +574,7 @@ void main() {
),
),
};
final code = await mailedCode('anna@example.org');
final code = await ownAddressCode();
for (final MapEntry(key: what, value: setup) in wrong.entries) {
await expectLater(
endpoints.password.set(
@ -538,7 +588,14 @@ void main() {
);
}
expect(await AccountPassword.db.count(session), 0);
expect(await scopesOf(anna), isNot(contains(identified.name)));
// Refused before the code was used: it still serves.
await endpoints.password.set(
asAnna(),
setup: setup,
request: code.request,
code: code.code,
);
expect(await AccountPassword.db.count(session), 1);
});
test('replaced before an authenticator is confirmed, ends every '
@ -1384,7 +1441,7 @@ void main() {
proof: proof,
),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -1394,7 +1451,10 @@ void main() {
),
);
final bensDevice = await PasswordDevice.make(seed: 7);
final bensCode = await mailedCode('ben@example.org');
final bensCode = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen,
setup: bensDevice.setup(),
@ -1753,7 +1813,7 @@ void main() {
refused(PasswordRefusal.unknownAuthenticator),
);
final ben = await newAnonymousPerson(session);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
@ -1762,7 +1822,10 @@ void main() {
authId: bensLaptop.uuid,
),
);
final code = await mailedCode('ben@example.org');
final code = await mailedCode(
'ben@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asBen,
setup: (await PasswordDevice.make(seed: 7)).setup(),

View file

@ -36,7 +36,8 @@ void main() {
/// The tests' time, moved on by hand.
late DateTime now;
/// Anonymous, signed in on her phone.
/// Signs in with a code mailed to anna@example.org, as accounts did
/// before passwords (ADR 0017), on her phone.
late UuidValue anna;
late UuidValue annasPhone;
@ -49,8 +50,12 @@ void main() {
EmailCodes.of(pod).mailer = mailbox;
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
anna = await newPersonWithAddress(session, 'anna@example.org');
annasPhone = await signInNewDevice(
session,
anna,
scopes: {identified},
);
device = await PasswordDevice.make();
});
tearDown(() async {
@ -103,7 +108,10 @@ void main() {
/// Anna: a password and a confirmed authenticator.
Future<AuthenticatorEnrolment> annaComplete() => atNow(() async {
final code = await mailedCode('anna@example.org');
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await endpoints.password.set(
asAnna(),
setup: device.setup(),
@ -787,7 +795,7 @@ void main() {
resetLapsesAfter + const Duration(minutes: 1),
);
// Bob's authenticators' reset, carried out long ago.
final bob = await newAnonymousPerson(session);
final bob = await newPersonWithAddress(session, 'bob@example.org');
await AccountReset.db.insertRow(
session,
AccountReset(
@ -799,7 +807,10 @@ void main() {
);
// Carl's account reset, asked 31 days ago and moved on by
// hold-outs past now: lapsed all the same.
final carl = await newAnonymousPerson(session);
final carl = await newPersonWithAddress(
session,
'carl@example.org',
);
await AccountReset.db.insertRow(
session,
AccountReset(

View file

@ -173,15 +173,7 @@ void main() {
test('gives an account with the address and no password (codes by '
'mail before) the password, keeping its ID', () async {
final ben = await newAnonymousPerson(session);
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: now,
),
);
final ben = await newPersonWithAddress(session, 'ben@example.org');
final signedUp = await signUp('ben@example.org');