fix(auth): members learn of ended sessions in the background

The full reading's L5: every change or reset of a password told the
members inside the request, up to 500 notices one after another with a
timeout of 10 seconds each, so a member out of reach held the change up
for minutes. Now announceSessionsEnded tells the members of this account's
notices alone, in the background on a session of its own; those not
confirmed wait for the hourly run as before.

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-21 13:28:03 +02:00
commit 76291e6ab4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,8 +1,11 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:meta/meta.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -80,21 +83,55 @@ Future<DateTime> endSessionsEverywhere(
/// What follows [endSessionsEverywhere] once its transaction holds: the
/// account's devices here learn that their sessions ended, and the members
/// are told.
/// are told, in the background (the full reading's L5: told inside the
/// request, a member out of reach held a change of password up for its
/// timeout, and the other notices waiting for minutes), this account's
/// notices first; those not confirmed are told again hourly
/// ([deliverSessionNotices], with the pruning).
Future<void> announceSessionsEnded(Session session, UuidValue account) async {
await session.messages.authenticationRevoked(
account.uuid,
RevokedAuthenticationUser(),
);
await deliverSessionNotices(session);
final delivery = _deliverInBackground(session.serverpod, account);
_deliveries.add(delivery);
unawaited(delivery.whenComplete(() => _deliveries.remove(delivery)));
}
/// The deliveries [announceSessionsEnded] began, until they end.
final _deliveries = <Future<void>>{};
/// Tells the members of [account]'s ended sessions, on a session of its
/// own: the request's may close first.
Future<void> _deliverInBackground(Serverpod pod, UuidValue account) async {
final session = await pod.createSession(enableLogging: false);
try {
await deliverSessionNotices(session, account: account);
} on Object catch (error) {
// Its type only: what a member answered tells nobody here more.
session.log(
'Telling members of ended sessions failed: ${error.runtimeType}',
level: LogLevel.warning,
);
} finally {
await session.close();
}
}
/// Waits for the deliveries [announceSessionsEnded] began: tests do.
@visibleForTesting
Future<void> sessionNoticesDelivered() => Future.wait([..._deliveries]);
/// Tells the members the sessions ended that they haven't confirmed yet, and
/// returns how many confirmed; the others stay, counted, for the next time
/// (the register's hourly pruning, and at once after each change), those
/// tried least first, 500 at a time. Notices to a member the operator no
/// longer names are dropped.
Future<int> deliverSessionNotices(Session session, {DateTime? now}) async {
/// (the register's hourly pruning, and after each change [account]'s alone,
/// in the background), those tried least first, 500 at a time. Notices to a
/// member the operator no longer names are dropped.
Future<int> deliverSessionNotices(
Session session, {
DateTime? now,
UuidValue? account,
}) async {
final members = switch (HalkyonBase.maybeOf(session.serverpod)?.accounts) {
HoldsRegister(:final members) => {for (final member in members) '$member'},
_ => const <String>{},
@ -105,10 +142,11 @@ Future<int> deliverSessionNotices(Session session, {DateTime? now}) async {
where: (t) => t.member.notInSet(members),
);
final notifier = MemberNotifier.of(session.serverpod);
// SELECT … FROM "sessions_ended_notice" ORDER BY "attempts", "id"
// LIMIT 500
// SELECT … FROM "sessions_ended_notice" [WHERE "account" = $1]
// ORDER BY "attempts", "id" LIMIT 500
final notices = await SessionsEndedNotice.db.find(
session,
where: account == null ? null : (t) => t.account.equals(account),
orderByList: (t) => [t.attempts.asc(), t.id.asc()],
limit: 500,
);