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
75 changed files with 837 additions and 1353 deletions
|
|
@ -9,7 +9,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
|||
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
|
||||
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (anonymous, a password with TOTP and the resets of ADR 0017, email codes, GitHub until it ends), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address, GitHub until it ends; no anonymous accounts since ADR 0020, D25), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
|
||||
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
||||
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
|
||||
|
|
|
|||
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,
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue