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,740 @@
import 'dart:convert';
import 'dart:typed_data';
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: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 _learn = Uri.parse('http://learn.test:8082');
/// The instance's register, in memory: what a member asks it, and what it
/// answers.
final class _Register implements AccountRegister {
final accounts = <UuidValue>{};
final addresses = <String, UuidValue>{};
final gitHub = <String, UuidValue>{};
final profiles = <UuidValue, Profile>{};
final codes = <UuidValue, ({String address, String code})>{};
final switches = <String, UuidValue>{};
/// The calls asked, by name, in their order.
final asked = <String>[];
final networks = <String>[];
final deleted = <UuidValue>[];
/// What [knownAccounts] was asked about, call by call.
final askedAbout = <Set<UuidValue>>[];
bool offered = true;
/// Thrown by every call while set: the register refusing, or down.
Exception? failing;
Future<T> _answer<T>(String call, T Function() answer) async {
asked.add(call);
if (failing case final error?) {
throw error;
}
return answer();
}
/// [person] as an account with the address [address].
void add(UuidValue person, {String? address}) {
accounts.add(person);
if (address != null) {
addresses[address] = person;
}
}
@override
Future<Profile?> profileOf(Session session, UuidValue person) =>
_answer('profileOf', () => profiles[person]);
@override
Future<Map<UuidValue, Profile>> profilesOf(
Session session,
Set<UuidValue> people,
) => _answer(
'profilesOf',
() => {
for (final person in people) person: ?profiles[person],
},
);
@override
Future<Profile?> profileByHandle(Session session, String handle) => _answer(
'profileByHandle',
() => profiles.values
.where((profile) => profile.handle.toLowerCase() == handle)
.firstOrNull,
);
@override
Future<UuidValue?> personWithHandle(Session session, String handle) =>
_answer(
'personWithHandle',
() => profiles.entries
.where((entry) => entry.value.handle.toLowerCase() == handle)
.firstOrNull
?.key,
);
@override
Future<Profile> saveProfile(
Session session,
UuidValue person, {
required String handle,
required String displayName,
required String bio,
required List<String> links,
}) => _answer(
'saveProfile',
() => profiles[person] = Profile(
handle: handle,
address: '$handle@learn.test',
displayName: displayName,
bio: bio,
links: links,
pageUrl: 'https://learn.test/@$handle',
handleFixed: false,
),
);
@override
Future<ProfileRefusal?> checkHandle(
Session session,
UuidValue person,
String handle,
) => _answer('checkHandle', () => null);
@override
Future<Profile> checkLinks(Session session, UuidValue person) =>
_answer('checkLinks', () => profiles[person]!);
@override
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png) =>
_answer('setAvatar', () => profiles[person]!);
@override
Future<Profile> removeAvatar(Session session, UuidValue person) =>
_answer('removeAvatar', () => profiles[person]!);
@override
Future<void> removeProfile(Session session, UuidValue person) =>
_answer('removeProfile', () => profiles.remove(person));
@override
Future<void> fixHandle(Session session, UuidValue person) =>
_answer('fixHandle', () {});
@override
Future<bool> emailSignInOffered(Session session) =>
_answer('emailSignInOffered', () => offered);
@override
Future<UuidValue> sendSignInCode(
Session session, {
required String address,
required String language,
required String network,
}) => _answer('sendSignInCode', () {
networks.add(network);
final request = const Uuid().v7obj();
codes[request] = (address: address, code: 'K7QX-M2PD');
return request;
});
@override
Future<EmailCodeAccepted> verifySignInCode(
Session session, {
required UuidValue person,
required UuidValue request,
required String code,
}) => _answer('verifySignInCode', () {
final sent = codes[request];
if (sent == null || sent.code != code) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode);
}
final owner = addresses[sent.address];
if (owner != null && owner != person) {
final token = 'switch to ${owner.uuid}';
switches[token] = owner;
return EmailCodeAccepted(switchToken: token);
}
add(person, address: sent.address);
return EmailCodeAccepted();
});
@override
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
}) => _answer(
'completeEmailSwitch',
() =>
switches.remove(switchToken) ??
(throw EmailSignInRefusedException(
reason: EmailSignInRefusal.codeExpired,
)),
);
@override
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
}) => _answer('linkGitHub', () {
accounts.add(person);
gitHub[gitHubUserId] = person;
});
@override
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
'signInWaysOf',
() => SignInWays(
gitHub: gitHub.containsValue(person),
email: addresses.containsValue(person),
),
);
@override
Future<void> deleteAccount(Session session, UuidValue person) =>
_answer('deleteAccount', () {
deleted.add(person);
accounts.remove(person);
});
@override
Future<Set<UuidValue>> knownAccounts(
Session session,
Set<UuidValue> people,
) => _answer('knownAccounts', () {
askedAbout.add(people);
return people.intersection(accounts);
});
}
void main() {
withServerpod(
'A member of an instance',
// Sessions issued and revoked, and deletions in transactions of their
// own: these tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Register register;
/// Anonymous here, signed in on her phone.
late UuidValue anna;
late UuidValue annasPhone;
/// An account of the register, identified here, signed in on his
/// laptop.
late UuidValue ben;
late UuidValue bensLaptop;
/// Whose data the host deleted.
late List<UuidValue> hostDeleted;
setUp(() async {
hostDeleted = [];
useBase(
sessionBuilder,
accounts: MemberOfRegister(_learn),
deletePersonData: [
(session, person, transaction) async {
hostDeleted.add(person);
return null;
},
],
);
session = sessionBuilder.build();
pod = session.serverpod;
await _deleteAll(session);
useInstanceSecret(pod, _secret);
register = _Register();
AccountRegister.use(pod, register);
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
register.add(ben, address: 'ben@example.org');
bensLaptop = await signInNewDevice(session, ben, scopes: {identified});
});
tearDown(() async {
AccountRegister.use(pod, null);
useInstanceSecret(pod, null);
await _deleteAll(sessionBuilder.build());
});
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
);
TestSessionBuilder asBen() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{identified},
authId: bensLaptop.uuid,
),
);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
Future<Set<String>> scopesOf(UuidValue person) async =>
(await AuthUser.db.findById(session, person))!.scopeNames;
group('signs in by asking the register', () {
test('which mails the code, its device network passed on', () async {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
);
expect(register.asked, ['sendSignInCode']);
expect(register.codes.values.single.address, 'anna@example.org');
expect(register.networks, hasLength(1));
});
test('and refuses as the register refuses', () async {
register.failing = EmailSignInRefusedException(
reason: EmailSignInRefusal.tooManyCodes,
);
await expectLater(
endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
),
throwsA(
isA<EmailSignInRefusedException>().having(
(error) => error.reason,
'reason',
EmailSignInRefusal.tooManyCodes,
),
),
);
});
test('an address nobody has makes the person here identified, under '
'the same ID, the register making them its account', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
);
final accepted = await endpoints.emailCode.verifyCode(
asAnna(),
request: request,
code: 'K7QX-M2PD',
);
expect(accepted.switchToken, isNull);
expect(register.accounts, contains(anna));
expect(await scopesOf(anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))!.scopeNames,
contains(identified.name),
);
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test('a wrong code changes nothing here', () async {
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'de',
);
await expectLater(
endpoints.emailCode.verifyCode(
asAnna(),
request: request,
code: 'AAAA-AAAA',
),
throwsA(isA<EmailSignInRefusedException>()),
);
expect(await scopesOf(anna), isNot(contains(identified.name)));
});
test("another account's address gives a switch, which signs in as "
'that account here, made a person here under its ID the first '
'time', () async {
final carla = const Uuid().v7obj();
register.add(carla, address: 'carla@example.org');
final request = await endpoints.emailCode.sendCode(
asAnna(),
address: 'carla@example.org',
language: 'en',
);
final accepted = await endpoints.emailCode.verifyCode(
asAnna(),
request: request,
code: 'K7QX-M2PD',
);
expect(accepted.switchToken, isNotNull);
expect(await scopesOf(anna), isNot(contains(identified.name)));
expect(await AuthUser.db.findById(session, carla), isNull);
final signedIn = await endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: accepted.switchToken!,
);
expect(signedIn.authUserId, carla);
expect(await scopesOf(carla), {identified.name});
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
);
expect(sessions.single.scopeNames, {identified.name});
await expectLater(
endpoints.emailSignIn.signIn(
notSignedIn(),
switchToken: accepted.switchToken!,
),
throwsA(isA<EmailSignInRefusedException>()),
);
});
test('an account signing in again is the same person here', () async {
await issueSession(session, ben, method: 'test');
await issueSession(session, ben, method: 'test');
expect(
await AuthUser.db.count(session, where: (t) => t.id.equals(ben)),
1,
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
3,
);
});
test('an account the register proved is identified here even where '
'linking its way in was cut off', () async {
final success = await issueSession(session, anna, method: 'test');
expect(success.authUserId, anna);
expect(await scopesOf(anna), contains(identified.name));
expect(
(await ServerSideSession.db.findById(
session,
annasPhone,
))!.scopeNames,
contains(identified.name),
);
});
test('email sign-in is offered as the register offers it', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isTrue,
);
register.offered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
isFalse,
);
});
test('the basics still answer while the register is out of reach, '
'without email sign-in', () async {
register.failing = RegisterUnavailableException('test');
final basics = await endpoints.instance.basics(notSignedIn());
expect(basics.emailSignIn, isFalse);
expect(basics.app, testApp);
});
test('the ways in are those the register knows', () async {
register.gitHub['1001'] = ben;
final ways = await endpoints.account.signInWays(asBen());
expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson());
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(
asBen(),
'Ben',
'Ben',
'',
const [],
);
expect(register.profiles[ben]!.toJson(), saved.toJson());
expect(
(await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(),
saved.toJson(),
);
expect((await profileOf(session, ben))!.toJson(), saved.toJson());
expect(await PublicProfile.db.count(session), 0);
});
test("have their page at the register's address, where this "
"server's sends its visitors", () async {
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
final page = await profilePage(
session,
'@ben',
host: 'todo.test',
);
expect(page.status, 308);
expect(page.location, 'https://learn.test/@Ben');
});
});
group('deleting the account', () {
test('of an identified person deletes it at the register, which '
'tells every member, and here at once', () async {
await endpoints.account.delete(asBen());
expect(register.deleted, [ben]);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(hostDeleted, [ben]);
expect(await AuthUser.db.findById(session, anna), isNotNull);
});
test('of an anonymous person deletes it here alone', () async {
await endpoints.account.delete(asAnna());
expect(register.asked, isEmpty);
expect(await AuthUser.db.findById(session, anna), isNull);
expect(hostDeleted, [anna]);
});
test('is refused while the register is out of reach, deleting '
'nothing here', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
endpoints.account.delete(asBen()),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
1,
);
expect(hostDeleted, isEmpty);
});
});
group('told by the register that an account is gone', () {
Future<InstanceAnswer> tell(
Object? account, {
String? authorization = 'Bearer $_secret',
}) => answerRegister(
session,
jsonEncode({'account': account}),
authorization: authorization,
);
test('deletes that person here with their sessions, and nobody '
'else', () async {
final answer = await tell(ben.uuid);
expect(answer.status, 200);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(ben),
),
0,
);
expect(hostDeleted, [ben]);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(register.asked, isEmpty);
});
test('confirms an account it never had', () async {
expect((await tell(const Uuid().v7())).status, 200);
expect(hostDeleted, isEmpty);
});
test('refuses without the instance secret, or with a wrong one, '
'deleting nobody', () async {
for (final authorization in [
null,
'Bearer wrong',
'Basic $_secret',
]) {
expect(
(await tell(ben.uuid, authorization: authorization)).status,
401,
);
}
useInstanceSecret(pod, null);
expect((await tell(ben.uuid)).status, 401);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(hostDeleted, isEmpty);
});
test('refuses what it cannot read', () async {
for (final account in [null, 7, 'not an id']) {
expect((await tell(account)).status, 400, reason: '$account');
}
expect(
(await answerRegister(
session,
'not JSON',
authorization: 'Bearer $_secret',
)).status,
400,
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
test('is answered by no register', () async {
useBase(sessionBuilder);
expect((await tell(ben.uuid)).status, 404);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
});
group('pruning', () {
late UuidValue carla;
setUp(() async {
carla = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
});
test('deletes the identified people the register no longer knows, '
'asking only about those', () async {
final forgotten = await forgetPeopleTheRegisterForgot(
session,
batch: 1,
);
expect(forgotten, 1);
expect(await AuthUser.db.findById(session, carla), isNull);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, anna), isNotNull);
expect(register.askedAbout.expand((people) => people), {ben, carla});
});
test('deletes nobody while the register knows none of those asked '
'about, as a register misnamed or emptied would say', () async {
register.accounts.clear();
expect(await forgetPeopleTheRegisterForgot(session), 0);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
test('deletes nobody while the register is out of reach', () async {
register.failing = RegisterUnavailableException('test');
await expectLater(
forgetPeopleTheRegisterForgot(session),
throwsA(isA<RegisterUnavailableException>()),
);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
test('does nothing on the register', () async {
useBase(sessionBuilder);
expect(await forgetPeopleTheRegisterForgot(session), 0);
expect(register.asked, isEmpty);
expect(await AuthUser.db.findById(session, carla), isNotNull);
});
});
test('does not start without the secret where it would need it, and '
'needs none alone', () {
expect(() => checkInstanceSecret(pod), returnsNormally);
useInstanceSecret(pod, null);
expect(() => checkInstanceSecret(pod), throwsStateError);
useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn]));
expect(() => checkInstanceSecret(pod), throwsStateError);
useBase(sessionBuilder);
expect(() => checkInstanceSecret(pod), returnsNormally);
});
test('asks the register at its address with the secret, and asks '
'nothing without one', () {
AccountRegister.use(pod, null);
expect(
AccountRegister.of(pod),
isA<RemoteRegister>()
.having((remote) => remote.register, 'register', _learn)
.having((remote) => remote.secret, 'secret', _secret),
);
useInstanceSecret(pod, null);
expect(() => AccountRegister.of(pod), throwsStateError);
expect(
MemberNotifier.of(pod),
isA<HttpMemberNotifier>().having(
(notifier) => notifier.secret,
'secret',
isNull,
),
);
});
},
);
}
Future<void> _deleteAll(Session session) async {
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}