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,99 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:serverpod/serverpod.dart';
/// How this server takes part in its instance's accounts (ADR 0016).
sealed class AccountsMode {
const AccountsMode();
/// The mode the operator's environment names: a member of the register at
/// `ACCOUNT_REGISTER_URL` where it is set, else the register, telling the
/// members at `INSTANCE_MEMBERS` (addresses separated by commas or spaces)
/// when an account goes. Empty values count as unset: Compose passes unset
/// variables on as empty strings.
factory AccountsMode.fromEnvironment([Map<String, String>? environment]) {
final env = environment ?? Platform.environment;
if (_address(env['ACCOUNT_REGISTER_URL'], 'ACCOUNT_REGISTER_URL')
case final register?) {
return MemberOfRegister(register);
}
return HoldsRegister(
members: [
for (final member in (env['INSTANCE_MEMBERS'] ?? '').split(
RegExp(r'[\s,]+'),
))
?_address(member, 'INSTANCE_MEMBERS'),
],
);
}
static Uri? _address(String? value, String variable) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
final uri = Uri.tryParse(trimmed);
if (uri == null ||
!(uri.isScheme('http') || uri.isScheme('https')) ||
uri.host.isEmpty) {
throw ArgumentError.value(trimmed, variable, 'is no HTTP(S) address');
}
return uri;
}
}
/// This server holds the instance's register: its database keeps the
/// accounts, their ways in, handles and profiles. With a single service, it
/// is its own register and has no [members].
final class HoldsRegister extends AccountsMode {
const HoldsRegister({this.members = const []});
/// The web servers of the instance's other services, told when an account
/// goes so they delete that person's data too.
final List<Uri> members;
}
/// This server is a member: it asks the instance's register at [register]
/// (its web server) for everything about accounts, and keeps only its own
/// data of each person, under the register's account ID.
final class MemberOfRegister extends AccountsMode {
const MemberOfRegister(this.register);
/// The register's web server, as this server reaches it (its address on
/// the instance's own network when both run on one host).
final Uri register;
}
/// The instance's shared secret (`instanceSecret` in the passwords;
/// `INSTANCE_SECRET` in a deploy's `.env`), which its services send with
/// every call between them; null without one, and then no such call is made
/// or taken.
String? instanceSecretOf(Serverpod pod) =>
_secretOf[pod] ??
switch (pod.getPassword('instanceSecret')) {
final secret? when secret.isNotEmpty => secret,
_ => null,
};
/// Puts [secret] in place of [pod]'s passwords' `instanceSecret`, or with
/// null the passwords' back: tests do, as the passwords of a test run hold
/// none.
void useInstanceSecret(Serverpod pod, String? secret) =>
_secretOf[pod] = secret;
final _secretOf = Expando<String>('Instance secret');
/// Whether [presented] is [secret], compared in constant time: both hashed,
/// so neither their length nor where they first differ shows in how long it
/// takes.
bool isInstanceSecret(String presented, String secret) {
final a = sha256.convert(utf8.encode(presented)).bytes;
final b = sha256.convert(utf8.encode(secret)).bytes;
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}