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
8 changed files with 837 additions and 1353 deletions

View file

@ -50,14 +50,17 @@ Future<void> run(List<String> args) async {
// (decided 2026-10-13), so instance.info() can name it from the first ask.
unawaited(JellyfinSuggestion.of(pod).look());
// Abandoned anonymous people are pruned daily (decided 2026-09-30).
// Scheduling always adds an entry, so the previous one goes first.
// 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
.pruneAnonymousPeople();
.pruneAccounts();
// The IP addresses of sign-up attempts, hourly, once the rate limit is
// done with them (decided 2026-10-10).
@ -126,7 +129,10 @@ Future<void> _loadDictionaries(Serverpod pod) async {
}
/// The identifier of the daily pruning.
const _pruning = 'prune-abandoned-anonymous-people';
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';

View file

@ -8,21 +8,19 @@ import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// How much room people's files may take (decided 2026-10-02): an anonymous
/// person little, since anonymous accounts are meant to become identified
/// ones; an identified person more, and more again by a `FileAllowance` the
/// operator grants; and all of them together no more than the instance has
/// room for. Each comes from the environment, in MiB, with a default:
/// `FILE_LIMIT_ANONYMOUS_MB` (100), `FILE_LIMIT_IDENTIFIED_MB` (500) and
/// `FILE_LIMIT_INSTANCE_MB` (15000; 0 for none). Every file is in the
/// How much room people's files may take (decided 2026-10-02): a person
/// some, and more by a `FileAllowance` the operator grants; and all of them
/// together no more than the instance has room for. Each comes from the
/// environment, in MiB, with a default: `FILE_LIMIT_IDENTIFIED_MB` (500) and
/// `FILE_LIMIT_INSTANCE_MB` (15000; 0 for none). Someone signed in without
/// `identified`, an anonymous account left from before (ADR 0020, D25),
/// keeps nothing. Every file is in the
/// database and in each dump the server keeps: with three dumps, and a
/// fourth while a deploy writes its own, files take five times their size on
/// the disk.
class FileLimits {
/// Limits of [anonymousBytes], [identifiedBytes] and [instanceBytes] (null
/// for none).
/// Limits of [identifiedBytes] and [instanceBytes] (null for none).
const FileLimits({
required this.anonymousBytes,
required this.identifiedBytes,
required this.instanceBytes,
});
@ -34,15 +32,11 @@ class FileLimits {
(int.tryParse(env[name] ?? '') ?? fallback) * _mib;
final instance = mib('FILE_LIMIT_INSTANCE_MB', 15000);
return FileLimits(
anonymousBytes: mib('FILE_LIMIT_ANONYMOUS_MB', 100),
identifiedBytes: mib('FILE_LIMIT_IDENTIFIED_MB', 500),
instanceBytes: instance > 0 ? instance : null,
);
}
/// What an anonymous person may keep, in bytes.
final int anonymousBytes;
/// What an identified person may keep without an allowance, in bytes.
final int identifiedBytes;
@ -88,7 +82,7 @@ Future<Room> roomOf(
allowance?.maxBytes ??
(session.authenticated!.scopes.contains(identified)
? limits.identifiedBytes
: limits.anonymousBytes),
: 0),
instanceUsed: await _storedBytes(
session,
assetPathPrefix,

View file

@ -2,25 +2,25 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
/// Deletes the anonymous people nobody can return to, daily, the IP
/// addresses of sign-up attempts the rate limit is done with and the email
/// Deletes the anonymous accounts left from before (ADR 0020, D25), daily,
/// the IP addresses of attempts the rate limits are done with and the email
/// sign-in codes past their time, hourly, and the deletions the instance's
/// members haven't confirmed, hourly; the server schedules them when it
/// starts (`server.dart`). They are halkyon_base's (ADR 0014, ADR 0016),
/// and people go through its `deletePerson`, so Learn's own data of theirs
/// goes too.
class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went; 30 days after GitHub
/// sign-in ended (`GITHUB_SIGN_IN_ENDS`), deletes the accounts that had
/// no other way in and forgets every GitHub ID too, on the register; and
/// forgets the resets that lapsed (ADR 0017).
Future<void> pruneAnonymousPeople(Session session) async {
/// Prunes one batch of anonymous accounts and logs how many people went;
/// 30 days after GitHub sign-in ended (`GITHUB_SIGN_IN_ENDS`), deletes the
/// accounts that had no other way in and forgets every GitHub ID too, on
/// the register; and forgets the resets that lapsed (ADR 0017).
Future<void> pruneAccounts(Session session) async {
final now = clock.now();
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
final pruned = await pruneAnonymousPeople(session);
final gitHub = await forgetGitHubAccounts(session, now: now);
final resets = await forgetLapsedResets(session, now: now);
session.log(
'Pruned $pruned abandoned anonymous people, $gitHub GitHub IDs and '
'Pruned $pruned anonymous people, $gitHub GitHub IDs and '
'$resets lapsed resets.',
);
}

View file

@ -62,8 +62,7 @@ class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
) {
var registeredFutureCalls = <String, _is.InvokableFutureCall>{
'FilePruningPruneFilesFutureCall': FilePruningPruneFilesFutureCall(),
'PruningPruneAnonymousPeopleFutureCall':
PruningPruneAnonymousPeopleFutureCall(),
'PruningPruneAccountsFutureCall': PruningPruneAccountsFutureCall(),
'PruningForgetSignUpAttemptsFutureCall':
PruningForgetSignUpAttemptsFutureCall(),
'PruningTellMembersOfDeletionsFutureCall':
@ -223,9 +222,9 @@ class _PruningFutureCallDispatcher {
final _InvokeFutureCall _invokeFutureCall;
Future<void> pruneAnonymousPeople() {
Future<void> pruneAccounts() {
return _invokeFutureCall(
'PruningPruneAnonymousPeopleFutureCall',
'PruningPruneAccountsFutureCall',
null,
);
}
@ -283,18 +282,18 @@ class FilePruningPruneFilesFutureCall extends _is.FutureCall
}
}
/// Prunes one batch and logs how many people went; 30 days after GitHub
/// sign-in ended (`GITHUB_SIGN_IN_ENDS`), deletes the accounts that had
/// no other way in and forgets every GitHub ID too, on the register; and
/// forgets the resets that lapsed (ADR 0017).
class PruningPruneAnonymousPeopleFutureCall extends _is.FutureCall
/// Prunes one batch of anonymous accounts and logs how many people went;
/// 30 days after GitHub sign-in ended (`GITHUB_SIGN_IN_ENDS`), deletes the
/// accounts that had no other way in and forgets every GitHub ID too, on
/// the register; and forgets the resets that lapsed (ADR 0017).
class PruningPruneAccountsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {
@override
_ida.Future<void> invoke(
_is.Session session,
_is.SerializableModel? object,
) async {
await _igevg04y.PruningFutureCall().pruneAnonymousPeople(session);
await _igevg04y.PruningFutureCall().pruneAccounts(session);
}
}

View file

@ -17,4 +17,9 @@
/// builds up to 21 sign in with `emailCode.verifyCode` and
/// `emailSignIn.signIn`, which are gone; 22 signs in with a password and
/// TOTP.
const minimumAppBuild = 22;
///
/// 23 since no account is anonymous any more (ADR 0020, D25, decided
/// 2026-10-24): builds up to 22 (v0.19.0) start with an anonymous account
/// (`anonymousIdp.login`) and add GitHub to it (`gitHubUpgrade.upgrade`),
/// both gone; 23 starts with SimpleLogin or a password.
const minimumAppBuild = 23;

View file

@ -58,15 +58,14 @@ void main() {
spaces.clear();
});
TestSessionBuilder signedIn(
UuidValue person, {
Set<Scope> scopes = const {},
}) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
scopes,
),
);
/// [person]'s session, `identified` unless [scopes] say otherwise.
TestSessionBuilder signedIn(UuidValue person, {Set<Scope>? scopes}) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
scopes ?? {identified},
),
);
/// Adds [bytes] as [person], as the app does: asks how to upload them,
/// uploads them unless the server has them, and finishes the upload.
@ -74,7 +73,7 @@ void main() {
UuidValue person,
List<int> bytes, {
String? as,
Set<Scope> scopes = const {},
Set<Scope>? scopes,
bool? encrypted,
}) async {
final hash = as ?? sha256.convert(bytes).toString();
@ -387,8 +386,7 @@ void main() {
group('room', () {
setUp(() {
fileLimits = const FileLimits(
anonymousBytes: 10,
identifiedBytes: 20,
identifiedBytes: 10,
instanceBytes: 1000,
);
});
@ -400,7 +398,7 @@ void main() {
.having((e) => e.usedBytes, 'usedBytes', used),
);
test('an anonymous person keeps no more than their room', () async {
test('a person keeps no more than their room', () async {
expect(await add(alice, List.filled(8, 1)), isTrue);
await expectLater(
@ -413,15 +411,18 @@ void main() {
);
});
test('an identified person keeps more', () async {
expect(
await add(alice, List.filled(15, 1), scopes: {identified}),
isTrue,
);
test('an anonymous account left from before keeps nothing '
'(ADR 0020, D25)', () async {
await expectLater(
add(bob, List.filled(15, 1)),
add(bob, [1], scopes: const {}),
refused(instanceFull: false, used: 0),
);
expect(
(await endpoints.asset.usage(
signedIn(bob, scopes: const {}),
)).limitBytes,
0,
);
});
test('an allowance gives one person more, and nobody else', () async {
@ -441,8 +442,7 @@ void main() {
// Whatever other tests left behind counts too: the room is set
// from what the instance holds now.
fileLimits = FileLimits(
anonymousBytes: 10,
identifiedBytes: 20,
identifiedBytes: 10,
instanceBytes: await _instanceBytes(session) + 12,
);
expect(await add(alice, List.filled(8, 1)), isTrue);
@ -550,8 +550,7 @@ void main() {
expect((usage.usedBytes, usage.limitBytes), (4, 10));
fileLimits = FileLimits(
anonymousBytes: 10,
identifiedBytes: 20,
identifiedBytes: 10,
instanceBytes: await _instanceBytes(session) + 2,
);
final tight = await endpoints.asset.usage(signedIn(alice));

View file

@ -68,7 +68,10 @@ void main() {
});
// halkyon_base's pruning, with Learn's deletion as it runs on the server.
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
withServerpod('Pruning anonymous people (ADR 0020, D25)', (
sessionBuilder,
_,
) {
late Session session;
final now = DateTime.now().toUtc();
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
@ -78,47 +81,29 @@ void main() {
session = sessionBuilder.build();
});
test(
'deletes anonymous people unused for 90 days, and only them',
() async {
final unused = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, unused, daysAgo(95));
final neverSignedIn = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
final recentlyUsed = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, recentlyUsed, daysAgo(10));
final newcomer = await _newAnonymousPerson(
session,
createdAt: daysAgo(5),
);
final identifiedPerson = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await _lastUsed(session, identifiedPerson, daysAgo(365));
test('deletes every anonymous account left, however lately used, and '
'nobody identified', () async {
final old = await _newAnonymousPerson(session, createdAt: daysAgo(100));
final recentlyUsed = await _newAnonymousPerson(session);
await _lastUsed(session, recentlyUsed, daysAgo(1));
final identifiedPerson = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await _lastUsed(session, identifiedPerson, daysAgo(365));
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
// Others may have been left by earlier runs.
expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(2));
expect(pruned, 2);
expect(await AuthUser.db.findById(session, unused), isNull);
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
expect(await AuthUser.db.findById(session, kept), isNotNull);
}
},
);
expect(await AuthUser.db.findById(session, old), isNull);
expect(await AuthUser.db.findById(session, recentlyUsed), isNull);
expect(await AuthUser.db.findById(session, identifiedPerson), isNotNull);
});
});
}
/// A person as the app's first launch creates them: anonymous.
/// An anonymous account, as the app made them on its first start until
/// ADR 0020, D25, left from before.
Future<UuidValue> _newAnonymousPerson(
Session session, {
DateTime? createdAt,

View file

@ -2008,13 +2008,11 @@ class _FilePruningFutureCall {
}
class _PruningFutureCall {
Future<void> pruneAnonymousPeople(
_ist.TestSessionBuilder sessionBuilder,
) async {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
try {
await _iip9210p.PruningPruneAnonymousPeopleFutureCall().invoke(
await _iip9210p.PruningPruneAccountsFutureCall().invoke(
_localUniqueSession,
null,
);