feat(accounts): one account across an instance's services (ADR 0016)

halkyon_base runs as the instance's register or as one of its members.
The register keeps the ways in, handles and profiles; members ask it
over HTTP with the instance's shared secret (instanceSecret), and open
sessions of their own for its accounts, under the same account ID.

- AccountsMode from the environment: INSTANCE_MEMBERS on the register,
  ACCOUNT_REGISTER_URL on a member. Learn always holds the register
  (Explore joins the profiles); Todo is a member where it is set.
- AccountRegister: LocalRegister, RemoteRegister, an in-memory one in
  tests. Calls between services on the web server, refused without the
  secret (constant-time compare); a server needing it doesn't start
  without it.
- Deleting an account deletes it everywhere: notices per member kept in
  the deletion's transaction (account_deletion_notice), told at once and
  hourly until confirmed; members prune daily whom the register no longer
  knows, never while it knows none of them or can't be reached.
- Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env.

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-19 23:10:08 +02:00
commit 65c5cd740d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,682 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
const _secret = 'a shared secret only the instance knows';
final _todo = Uri.parse('http://todo.test:8082');
final _train = Uri.parse('http://train.test:8082');
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code})>[];
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async => sent.add((to: to, code: code));
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
/// The members as the register tells them, each answering as [answers]
/// says (confirming by default).
final class _Members implements MemberNotifier {
final told = <(Uri, UuidValue)>[];
bool Function(Uri member) answers = (_) => true;
@override
Future<bool> accountGone(Uri member, UuidValue account) async {
told.add((member, account));
return answers(member);
}
}
void main() {
withServerpod(
'The register answering its members',
// Mailing codes counts attempts in transactions of their own
// (Serverpod's rate limiter), which the test's rollback can't wrap.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Mailbox mailbox;
late _Members members;
/// The register as a member's server asks it, over HTTP into this
/// server's answers.
late RemoteRegister remote;
/// Identified, signing in with ben@example.org and GitHub 1001.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
session = sessionBuilder.build();
pod = session.serverpod;
await _deleteAll(session);
useInstanceSecret(pod, _secret);
mailbox = _Mailbox();
EmailCodes.of(pod).mailer = mailbox;
members = _Members();
MemberNotifier.use(pod, members);
remote = RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient((request) async {
final answer = await answerMember(
session,
request.url.pathSegments.last,
request.body,
authorization: request.headers['authorization'],
);
return http.Response.bytes(
utf8.encode(jsonEncode(answer.body)),
answer.status,
);
}),
);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: clock.now(),
),
);
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: ben),
);
});
tearDown(() async {
EmailCodes.of(pod).mailer = null;
MemberNotifier.use(pod, null);
useInstanceSecret(pod, null);
await _deleteAll(sessionBuilder.build());
});
Future<InstanceAnswer> ask(
String operation,
Map<String, Object?> arguments, {
String? authorization = 'Bearer $_secret',
}) => answerMember(
session,
operation,
jsonEncode(arguments),
authorization: authorization,
);
Matcher refused(Object reason) => throwsA(
isA<Exception>().having(
(error) => refusalJson(error)?['reason'],
'reason',
(reason as Enum).name,
),
);
group('refuses', () {
test('a call without the instance secret, with a wrong one or in '
'another scheme, doing nothing', () async {
for (final authorization in [
null,
'',
'Bearer',
'Bearer ',
'Bearer not the secret',
'Bearer $_secret ',
'Basic $_secret',
_secret,
]) {
final answer = await ask(RegisterOperation.deleteAccount, {
'person': ben.uuid,
}, authorization: authorization);
expect(answer.status, 401, reason: authorization);
}
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
test(
'every call on a register without a secret, whatever is sent',
() async {
useInstanceSecret(pod, null);
for (final authorization in [null, 'Bearer ', 'Bearer null']) {
final answer = await ask(RegisterOperation.deleteAccount, {
'person': ben.uuid,
}, authorization: authorization);
expect(answer.status, 401, reason: authorization);
}
expect(await AuthUser.db.findById(session, ben), isNotNull);
},
);
test('register calls on a member, which holds no register', () async {
useBase(
sessionBuilder,
accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')),
);
final answer = await ask(RegisterOperation.knownAccounts, {
'people': [ben.uuid],
});
expect(answer.status, 404);
});
test('what it cannot read, and operations it does not know', () async {
for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) {
final answer = await answerMember(
session,
RegisterOperation.knownAccounts,
body,
authorization: 'Bearer $_secret',
);
expect(answer.status, 400, reason: body);
}
for (final arguments in <Map<String, Object?>>[
{},
{'people': 'not a list'},
{
'people': [1, 2],
},
{
'people': ['not an id'],
},
]) {
final answer = await ask(
RegisterOperation.knownAccounts,
arguments,
);
expect(answer.status, 400, reason: '$arguments');
}
expect(
(await ask(RegisterOperation.deleteAccount, {
'person': 'not an id',
})).status,
400,
);
expect((await ask('drop-everything', {})).status, 404);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
});
group('profiles', () {
test('are saved, read and fixed for a member as for this server, '
'the same handle never twice', () async {
final anna = const Uuid().v7obj();
final saved = await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: ' Anna ',
bio: 'Learning Greek.',
links: ['https://anna.example.org'],
);
expect(saved.handle, 'Anna');
expect(saved.displayName, 'Anna');
expect(
(await remote.profileOf(session, anna))!.toJson(),
saved.toJson(),
);
expect((await profileOf(session, anna))!.toJson(), saved.toJson());
expect(
(await remote.profileByHandle(session, 'anna'))!.toJson(),
saved.toJson(),
);
expect(await remote.personWithHandle(session, 'ANNA'), anna);
expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]);
expect(await remote.checkHandle(session, anna, 'Anna'), isNull);
expect(
await remote.checkHandle(session, ben, 'anna'),
ProfileRefusal.handleTaken,
);
await expectLater(
remote.saveProfile(
session,
ben,
handle: 'anna',
displayName: '',
bio: '',
links: const [],
),
refused(ProfileRefusal.handleTaken),
);
await remote.fixHandle(session, anna);
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
await expectLater(
remote.removeProfile(session, anna),
refused(ProfileRefusal.published),
);
});
test('missing are said to be so', () async {
final nobody = const Uuid().v7obj();
expect(await remote.profileOf(session, nobody), isNull);
expect(await remote.profileByHandle(session, 'nobody'), isNull);
expect(await remote.personWithHandle(session, 'nobody'), isNull);
expect(await remote.profilesOf(session, {nobody}), isEmpty);
await expectLater(
remote.removeAvatar(session, nobody),
throwsA(isA<ProfileNotFoundException>()),
);
await expectLater(
remote.checkLinks(session, nobody),
throwsA(isA<ProfileNotFoundException>()),
);
});
});
group('email sign-in', () {
test('is offered as the register offers it', () async {
expect(await remote.emailSignInOffered(session), isTrue);
EmailCodes.of(pod).mailer = null;
expect(await remote.emailSignInOffered(session), isFalse);
});
test("counts the codes a member asks for by its device's network, "
"not the member's", () async {
for (var i = 0; i < 10; i++) {
await remote.sendSignInCode(
session,
address: 'person$i@example.org',
language: 'en',
network: '203.0.113.7',
);
}
await expectLater(
remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '203.0.113.7',
),
refused(EmailSignInRefusal.tooManyCodes),
);
await remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '198.51.100.4',
);
expect(mailbox.sent, hasLength(11));
});
test("makes a member's anonymous person an account under the same "
'ID, identified, with the address', () async {
final annaOnTodo = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'Anna@example.org',
language: 'de',
network: '203.0.113.7',
);
final accepted = await remote.verifySignInCode(
session,
person: annaOnTodo,
request: request,
code: mailbox.codeFor('anna@example.org'),
);
expect(accepted.switchToken, isNull);
final account = await AuthUser.db.findById(session, annaOnTodo);
expect(account!.scopeNames, contains(identified.name));
expect(
(await remote.signInWaysOf(session, annaOnTodo)).toJson(),
SignInWays(gitHub: false, email: true).toJson(),
);
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
annaOnTodo,
ben,
});
});
test('switches to the account that has the address, once, and '
'refuses a wrong code', () async {
final annaOnTodo = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'ben@example.org',
language: 'en',
network: '203.0.113.7',
);
await expectLater(
remote.verifySignInCode(
session,
person: annaOnTodo,
request: request,
code: 'AAAA-AAAA',
),
refused(EmailSignInRefusal.wrongCode),
);
final accepted = await remote.verifySignInCode(
session,
person: annaOnTodo,
request: request,
code: mailbox.codeFor('ben@example.org'),
);
expect(accepted.switchToken, isNotNull);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
expect(
await remote.completeEmailSwitch(
session,
switchToken: accepted.switchToken!,
),
ben,
);
await expectLater(
remote.completeEmailSwitch(
session,
switchToken: accepted.switchToken!,
),
refused(EmailSignInRefusal.codeExpired),
);
});
});
group('GitHub', () {
test("links a member's person, who becomes an account under the "
'same ID, found by it afterwards', () async {
final annaOnTodo = const Uuid().v7obj();
await remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '2002',
);
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
expect(await remote.accountWithGitHub(session, '1001'), ben);
expect(await remote.accountWithGitHub(session, '3003'), isNull);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
});
test(
"refuses another person's GitHub account, keeping nothing",
() async {
final annaOnTodo = const Uuid().v7obj();
await expectLater(
remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '1001',
),
refused(GitHubSignInRefusal.takenByAnotherPerson),
);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
},
);
});
group('deleting an account', () {
test('deletes it here and tells every member, keeping no notice '
'once they confirmed', () async {
await remote.deleteAccount(session, ben);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(await EmailIdentity.db.count(session), 0);
expect(await GitHubAccount.db.count(session), 0);
expect(members.told, [(_todo, ben)]);
expect(await AccountDeletionNotice.db.count(session), 0);
expect(await remote.knownAccounts(session, {ben}), isEmpty);
});
test(
'tells a member that does not answer again until it does',
() async {
members.answers = (member) => member != _todo;
await remote.deleteAccount(session, ben);
expect(await AuthUser.db.findById(session, ben), isNull);
final kept = await AccountDeletionNotice.db.find(session);
expect(kept.single.account, ben);
expect(kept.single.member, '$_todo');
expect(kept.single.attempts, 1);
expect(kept.single.lastAttemptAt, isNotNull);
expect(await deliverDeletionNotices(session), 0);
expect(
(await AccountDeletionNotice.db.find(session)).single.attempts,
2,
);
members.answers = (_) => true;
expect(await deliverDeletionNotices(session), 1);
expect(await AccountDeletionNotice.db.count(session), 0);
expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]);
},
);
test('tells the members of an account this server no longer has, '
'as one may still hold its data', () async {
final gone = const Uuid().v7obj();
await remote.deleteAccount(session, gone);
expect(members.told, [(_todo, gone)]);
});
test('tells nobody of an anonymous person, a member never having '
'had them', () async {
final anna = await newAnonymousPerson(session);
await remote.deleteAccount(session, anna);
expect(await AuthUser.db.findById(session, anna), isNull);
expect(members.told, isEmpty);
expect(await AccountDeletionNotice.db.count(session), 0);
});
test(
'drops the notices of a member the operator no longer names',
() async {
members.answers = (_) => false;
useBase(
sessionBuilder,
accounts: HoldsRegister(members: [_todo, _train]),
);
await remote.deleteAccount(session, ben);
expect(await AccountDeletionNotice.db.count(session), 2);
useBase(sessionBuilder, accounts: HoldsRegister(members: [_train]));
await deliverDeletionNotices(session);
expect(
(await AccountDeletionNotice.db.find(session)).single.member,
'$_train',
);
useBase(sessionBuilder);
await deliverDeletionNotices(session);
expect(await AccountDeletionNotice.db.count(session), 0);
},
);
});
},
);
group('A member asking the register', () {
late List<http.Request> requests;
/// A register answering every request with [status] and [body].
RemoteRegister answering(int status, String body) {
requests = [];
return RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient((request) async {
requests.add(request);
return http.Response(body, status);
}),
);
}
test(
'posts JSON to the operation with the secret as its bearer token',
() async {
final account = const Uuid().v7obj();
final register = answering(200, '{"account": "${account.uuid}"}');
expect(await register.accountWithGitHub(_noSession, '1001'), account);
final request = requests.single;
expect(request.method, 'POST');
expect(
'${request.url}',
'http://learn.test:8082/halkyon-base/register/account-with-github',
);
expect(request.headers['authorization'], 'Bearer $_secret');
expect(jsonDecode(request.body), {'gitHubUserId': '1001'});
},
);
test(
'fails as unavailable for an answer it cannot take, never guessing',
() async {
for (final (status, body) in [
(500, '{}'),
(401, '{"error": "secret"}'),
(200, 'not JSON'),
(200, '[]'),
(409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'),
(409, '{"refusal": "no"}'),
(404, '{"error": "not here"}'),
]) {
await expectLater(
answering(status, body).accountWithGitHub(_noSession, '1001'),
throwsA(isA<RegisterUnavailableException>()),
reason: '$status $body',
);
}
await expectLater(
RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient(
(_) async => throw http.ClientException('Connection refused'),
),
).accountWithGitHub(_noSession, '1001'),
throwsA(isA<RegisterUnavailableException>()),
);
},
);
});
group('The register telling a member', () {
test("posts the account to the member's path with the secret, and "
'counts only a 200 as confirmed', () async {
final account = const Uuid().v7obj();
final requests = <http.Request>[];
var status = 200;
final notifier = HttpMemberNotifier(
secret: _secret,
client: MockClient((request) async {
requests.add(request);
return http.Response('{}', status);
}),
);
expect(await notifier.accountGone(_todo, account), isTrue);
final request = requests.single;
expect(request.method, 'POST');
expect('${request.url}', 'http://todo.test:8082$accountGonePath');
expect(request.headers['authorization'], 'Bearer $_secret');
expect(jsonDecode(request.body), {'account': account.uuid});
status = 401;
expect(await notifier.accountGone(_todo, account), isFalse);
});
test('counts a failed request as not confirmed, and sends nothing '
'without a secret', () async {
final account = const Uuid().v7obj();
var sent = 0;
expect(
await HttpMemberNotifier(
secret: _secret,
client: MockClient((_) {
sent++;
return Future.error(http.ClientException('Connection refused'));
}),
).accountGone(_todo, account),
isFalse,
);
expect(
await HttpMemberNotifier(
secret: null,
client: MockClient((_) async {
sent++;
return http.Response('{}', 200);
}),
).accountGone(_todo, account),
isFalse,
);
expect(sent, 1);
});
});
}
/// No session: [RemoteRegister] only passes it on.
final Session _noSession = _NoSession();
final class _NoSession implements Session {
@override
Object? noSuchMethod(Invocation invocation) =>
throw UnsupportedError('no session in this test');
}
Future<void> _deleteAll(Session session) async {
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailCodeRequest.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}