feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
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:
parent
5b9df73756
commit
87671208df
19 changed files with 837 additions and 1353 deletions
File diff suppressed because one or more lines are too long
|
|
@ -12,8 +12,11 @@ instance are published, so anyone can see how an instance works and run their ow
|
|||
card content is stored as append-only revisions, and a side changed differently on two
|
||||
devices is shown as a conflict until the person picks a version
|
||||
([ADR 0001](../../docs/adr/0001-offline-sync.md), [ADR 0003](../../docs/adr/0003-card-revisions.md)).
|
||||
- **No sign-up needed.** The app starts with an anonymous account; signing in with GitHub
|
||||
later keeps everything. A GitHub sign-in stores only the numeric GitHub ID.
|
||||
- **Every account has a way in**: SimpleLogin, whose ID alone the instance keeps, or an
|
||||
email address with a password and an authenticator, the password never leaving the
|
||||
device ([ADR 0020](../../docs/adr/0020-sign-in-with-simplelogin.md),
|
||||
[ADR 0017](../../docs/adr/0017-password-totp-and-keys.md)). GitHub still signs in
|
||||
those who have it, until the instance ends it.
|
||||
- **No end-to-end encryption.** Content is readable by the operator of the instance, as on
|
||||
Mastodon; whoever trusts no operator can run their own
|
||||
([ADR 0002](../../docs/adr/0002-no-end-to-end-encryption.md)).
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// on top, and syncs and uploads only when a test says so.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour). That nobody else gets a
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`). That nobody else gets a
|
||||
// person's file is the server's tests' (asset_test.dart).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
|
@ -26,7 +26,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
alice = await signInAnonymously();
|
||||
alice = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// 0001, rule 9), their revisions merged per field (ADR 0003, amended).
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
person = await signInAnonymously();
|
||||
person = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// merged per field (ADR 0003), the answers as a log.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
person = await signInAnonymously();
|
||||
person = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// 0001, rule 9), their revisions merged title and body apart (ADR 0003).
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
person = await signInAnonymously();
|
||||
person = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// its reading a log.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
person = await signInAnonymously();
|
||||
person = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@
|
|||
// repositories on top, and syncs only when a test says so.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in two
|
||||
// new anonymous people (at most 30 per IP and hour) and gives the author the
|
||||
// `identified` scope by SQL on the development database, on the person and
|
||||
// their sessions as linking GitHub does (`grantIdentified`), since a test
|
||||
// can't sign in to GitHub. Their studyset stays in the development
|
||||
// server's git repositories, withdrawn after its third release. Who may
|
||||
// this package with `flutter test -t requires-server`. Each run makes two new
|
||||
// people on the development database (`signInNewPerson`). Their studyset
|
||||
// stays in the development server's git repositories, withdrawn after its
|
||||
// third release. Who may
|
||||
// publish what, and what a release leaves out, are the server's tests'
|
||||
// (publish_test.dart).
|
||||
@Tags(['requires-server'])
|
||||
|
|
@ -50,22 +48,8 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
author = await signInAnonymously();
|
||||
learner = await signInAnonymously();
|
||||
// As `grantIdentified` does once a GitHub account is linked: the scope
|
||||
// on the person, and on each of their sessions, which keep a copy.
|
||||
for (final table in [
|
||||
'serverpod_auth_core_user',
|
||||
'serverpod_auth_core_session',
|
||||
]) {
|
||||
await queryServer(
|
||||
'UPDATE "$table" '
|
||||
'SET "scopeNames" = ("scopeNames"::jsonb || \'["identified"]\')::json '
|
||||
'WHERE "${table == 'serverpod_auth_core_user' ? 'id' : 'authUserId'}" '
|
||||
'= @id:uuid',
|
||||
{'id': author.authUserId.uuid},
|
||||
);
|
||||
}
|
||||
author = await signInNewPerson();
|
||||
learner = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// and repository on top, and syncs only when a test says so.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in two new
|
||||
// anonymous people (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes two new
|
||||
// people on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -25,8 +25,8 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
alice = await signInAnonymously();
|
||||
bob = await signInAnonymously();
|
||||
alice = await signInNewPerson();
|
||||
bob = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
|
@ -41,14 +46,72 @@ import 'package:yaml/yaml.dart';
|
|||
|
||||
const _serverUrl = 'http://localhost:8080/';
|
||||
|
||||
/// A new anonymous person, as the app signs one in on first launch.
|
||||
Future<AuthSuccess> signInAnonymously() async {
|
||||
final client = protocol.Client(_serverUrl);
|
||||
try {
|
||||
return await client.modules.base.anonymousIdp.login();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
/// A new person, `identified`, signed in on a device of their own: made by
|
||||
/// SQL on the development database, as every account has a way in since
|
||||
/// ADR 0020, D25, and these tests drive no sign-in. The session is made as
|
||||
/// Serverpod's server-side sessions make one (serverpod_auth_core v4.0.4):
|
||||
/// the token is `sas`, the session's ID and a secret, in URL-safe base64;
|
||||
/// the row keeps the scheme's version (1) before the SHA-512 of the secret,
|
||||
/// a salt and the server's pepper (`serverSideSessionKeyHashPepper` in its
|
||||
/// development passwords), and goes unused for 90 days at most, as the
|
||||
/// server's own do.
|
||||
Future<AuthSuccess> signInNewPerson() async {
|
||||
final random = Random.secure();
|
||||
Uint8List randomBytes(int length) => Uint8List.fromList([
|
||||
for (var i = 0; i < length; i++) random.nextInt(256),
|
||||
]);
|
||||
final passwords = loadYaml(
|
||||
File('${_serverDir()}/config/passwords.yaml').readAsStringSync(),
|
||||
) as YamlMap;
|
||||
final pepper =
|
||||
(passwords['development'] as YamlMap)['serverSideSessionKeyHashPepper']
|
||||
as String;
|
||||
final secret = randomBytes(32);
|
||||
final salt = randomBytes(16);
|
||||
final hash = Uint8List.fromList([
|
||||
1,
|
||||
...sha512.convert([...secret, ...salt, ...utf8.encode(pepper)]).bytes,
|
||||
]);
|
||||
|
||||
// INSERT INTO "serverpod_auth_core_user" … RETURNING "id"
|
||||
final person =
|
||||
(await queryServer(
|
||||
'INSERT INTO "serverpod_auth_core_user" '
|
||||
'("createdAt", "scopeNames", "blocked") '
|
||||
"VALUES (now() AT TIME ZONE 'UTC', '[\"identified\"]', false) "
|
||||
'RETURNING "id"',
|
||||
{},
|
||||
)).single.single!
|
||||
as String;
|
||||
// INSERT INTO "serverpod_auth_core_session" … RETURNING "id"
|
||||
final sessionId =
|
||||
(await queryServer(
|
||||
'INSERT INTO "serverpod_auth_core_session" '
|
||||
'("authUserId", "scopeNames", "createdAt", "lastUsedAt", '
|
||||
'"expireAfterUnusedFor", "sessionKeyHash", "sessionKeySalt", '
|
||||
'"method") '
|
||||
"VALUES (@person:uuid, '[\"identified\"]', "
|
||||
"now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', "
|
||||
"@unused:int8, @hash:bytea, @salt:bytea, 'sync-test') "
|
||||
'RETURNING "id"',
|
||||
{
|
||||
'person': person,
|
||||
'unused': const Duration(days: 90).inMilliseconds,
|
||||
'hash': hash,
|
||||
'salt': salt,
|
||||
},
|
||||
)).single.single!
|
||||
as String;
|
||||
return AuthSuccess(
|
||||
authStrategy: 'session',
|
||||
token: base64Url.encode([
|
||||
...utf8.encode('sas'),
|
||||
...protocol.UuidValue.withValidation(sessionId).toBytes(),
|
||||
...secret,
|
||||
]),
|
||||
authUserId: protocol.UuidValue.withValidation(person),
|
||||
scopeNames: {'identified'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs [sql] on the running development server's database, as its operator
|
||||
|
|
@ -71,11 +134,13 @@ Future<pg.Result> queryServer(
|
|||
}
|
||||
}
|
||||
|
||||
/// The folder of the server `serverpod start` runs, which a worktree's tests
|
||||
/// may take from another checkout: SYNC_TEST_SERVER_DIR names it.
|
||||
String _serverDir() =>
|
||||
Platform.environment['SYNC_TEST_SERVER_DIR'] ?? '../halkyon_learn_server';
|
||||
|
||||
Future<pg.Connection> _openServerDatabase() async {
|
||||
// A worktree's tests run against the server `serverpod start` runs, which
|
||||
// may be another checkout's: SYNC_TEST_SERVER_DIR names its server folder.
|
||||
final server =
|
||||
Platform.environment['SYNC_TEST_SERVER_DIR'] ?? '../halkyon_learn_server';
|
||||
final server = _serverDir();
|
||||
const settings = pg.ConnectionSettings(sslMode: pg.SslMode.disable);
|
||||
final development = loadYaml(
|
||||
File('$server/config/development.yaml').readAsStringSync(),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// test says so.
|
||||
//
|
||||
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
|
||||
// this package with `flutter test -t requires-server`. Each run signs in one
|
||||
// new anonymous person (at most 30 per IP and hour).
|
||||
// this package with `flutter test -t requires-server`. Each run makes one new
|
||||
// person on the development database (`signInNewPerson`).
|
||||
@Tags(['requires-server'])
|
||||
library;
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ void main() {
|
|||
final devices = <SyncDevice>[];
|
||||
|
||||
setUpAll(() async {
|
||||
alice = await signInAnonymously();
|
||||
alice = await signInNewPerson();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue