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

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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(),

View file

@ -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 {