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
4 changed files with 41906 additions and 167 deletions

View file

@ -62,6 +62,10 @@ class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
PruningPruneAnonymousPeopleFutureCall(),
'PruningForgetSignUpAttemptsFutureCall':
PruningForgetSignUpAttemptsFutureCall(),
'PruningTellMembersOfDeletionsFutureCall':
PruningTellMembersOfDeletionsFutureCall(),
'PruningForgetDeletedAccountsFutureCall':
PruningForgetDeletedAccountsFutureCall(),
};
_futureCallManager = futureCallManager;
_serverId = serverId;
@ -203,6 +207,20 @@ class _PruningFutureCallDispatcher {
null,
);
}
Future<void> tellMembersOfDeletions() {
return _invokeFutureCall(
'PruningTellMembersOfDeletionsFutureCall',
null,
);
}
Future<void> forgetDeletedAccounts() {
return _invokeFutureCall(
'PruningForgetDeletedAccountsFutureCall',
null,
);
}
}
/// Prunes one batch and logs how many people went.
@ -230,3 +248,29 @@ class PruningForgetSignUpAttemptsFutureCall extends _is.FutureCall
await _ibrplims.PruningFutureCall().forgetSignUpAttempts(session);
}
}
/// On the register, tells the instance's members again of the accounts
/// gone that they haven't confirmed yet, and logs how many confirmed.
class PruningTellMembersOfDeletionsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {
@override
_ida.Future<void> invoke(
_is.Session session,
_is.SerializableModel? object,
) async {
await _ibrplims.PruningFutureCall().tellMembersOfDeletions(session);
}
}
/// On a member, deletes the people whose account the register no longer
/// knows, and logs how many.
class PruningForgetDeletedAccountsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {
@override
_ida.Future<void> invoke(
_is.Session session,
_is.SerializableModel? object,
) async {
await _ibrplims.PruningFutureCall().forgetDeletedAccounts(session);
}
}

View file

@ -7,13 +7,27 @@ const _pruning = 'prune-abandoned-anonymous-people';
/// The identifier of the hourly forgetting of sign-up attempts.
const _attemptPruning = 'forget-sign-up-attempts';
/// The identifier of the hourly telling of the members of deleted accounts.
const _deletionNotices = 'tell-members-of-deletions';
/// The identifier of the daily deleting of accounts the register forgot.
const _deletedAccounts = 'forget-deleted-accounts';
/// The future calls [schedulePruning] keeps scheduled, by their identifiers.
const pruningIdentifiers = [_pruning, _attemptPruning];
const pruningIdentifiers = [
_pruning,
_attemptPruning,
_deletionNotices,
_deletedAccounts,
];
/// Schedules the pruning (`PruningFutureCall`): abandoned anonymous people
/// daily, sign-up attempts and expired email codes hourly. Scheduling always
/// adds an entry, so the previous one goes first, and starting the server
/// again leaves one of each.
/// daily, sign-up attempts and expired email codes hourly, and the instance's
/// accounts (ADR 0016): the register's deletions its members haven't
/// confirmed hourly, a member's people the register forgot daily (each does
/// nothing in the other mode). Scheduling always adds an entry, so the
/// previous one goes first, and starting the server again leaves one of
/// each.
Future<void> schedulePruning(Serverpod pod) async {
await pod.futureCalls.cancel(_pruning);
await pod.futureCalls
@ -28,4 +42,18 @@ Future<void> schedulePruning(Serverpod pod) async {
.cron('27 * * * *')
.pruning
.forgetSignUpAttempts();
await pod.futureCalls.cancel(_deletionNotices);
await pod.futureCalls
.callRecurring(identifier: _deletionNotices)
.cron('52 * * * *')
.pruning
.tellMembersOfDeletions();
await pod.futureCalls.cancel(_deletedAccounts);
await pod.futureCalls
.callRecurring(identifier: _deletedAccounts)
.cron('37 3 * * *')
.pruning
.forgetDeletedAccounts();
}

View file

@ -5,7 +5,10 @@ import 'package:serverpod/serverpod.dart';
/// Deletes the anonymous people nobody can return to, daily, and the IP
/// addresses of sign-up attempts the rate limit is done with and the email
/// sign-in codes past their time, hourly: halkyon_base's pruning (ADR 0014),
/// which the server schedules when it starts (`schedulePruning`).
/// which the server schedules when it starts (`schedulePruning`). Then the
/// instance's accounts (ADR 0016): as its register, telling the members
/// again of the accounts gone, hourly; as a member, deleting the people
/// whose account the register no longer knows, daily.
class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went.
Future<void> pruneAnonymousPeople(Session session) async {
@ -25,4 +28,18 @@ class PruningFutureCall extends FutureCall {
final codes = await forgetExpiredEmailCodes(session, now: now);
session.log('Forgot $forgotten sign-up attempts and $codes email codes.');
}
/// On the register, tells the instance's members again of the accounts
/// gone that they haven't confirmed yet, and logs how many confirmed.
Future<void> tellMembersOfDeletions(Session session) async {
final confirmed = await deliverDeletionNotices(session);
session.log('Members confirmed $confirmed deleted accounts.');
}
/// On a member, deletes the people whose account the register no longer
/// knows, and logs how many.
Future<void> forgetDeletedAccounts(Session session) async {
final forgotten = await forgetPeopleTheRegisterForgot(session);
session.log('Deleted $forgotten people the register no longer knows.');
}
}

View file

@ -17,11 +17,16 @@ const minimumAppBuild = 1;
/// the instance's basics: the instance says it serves `todo` (the app accepts
/// only such an instance; never change it), sign-in mails name the app, and
/// the instance names this project's source. Todo keeps nothing of a person's
/// yet, so it deletes nothing of its own with them. The server sets it up
/// before it starts (`server.dart`), and so do its tests.
HalkyonBase todoBase() => HalkyonBase(
app: 'todo',
appName: 'Halkyon Todo',
upstreamSourceCodeUrl: upstreamSourceCodeUrl,
minimumAppBuild: minimumAppBuild,
);
/// yet, so it deletes nothing of its own with them. It holds the instance's
/// register, or is a member of the one [accounts] names (ADR 0016; the
/// server reads it from the operator's environment, `ACCOUNT_REGISTER_URL`).
/// The server sets it up before it starts (`server.dart`), and so do its
/// tests.
HalkyonBase todoBase({AccountsMode accounts = const HoldsRegister()}) =>
HalkyonBase(
app: 'todo',
appName: 'Halkyon Todo',
upstreamSourceCodeUrl: upstreamSourceCodeUrl,
minimumAppBuild: minimumAppBuild,
accounts: accounts,
);