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

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