feat(server): deleting accounts and pruning abandoned ones

deletePerson deletes a person with everything they have, in one transaction and in ADR 0001 rule 6's order: their sessions first, since a device with a valid session would re-upload everything; then their memberships and personal space, whose foreign keys cascade into every synced table and the sync engine's bookkeeping; then the person, whose identities cascade. AccountEndpoint.delete deletes the caller and tells their devices that their sessions ended.

pruneAbandonedAnonymousPeople deletes, 500 at a time, anonymous people created and unused for 90 days: their sessions have expired and nobody can ever return to them. Identified people are never pruned. PruningFutureCall runs it daily at 03:17; the server schedules it at start, cancelling the previous entry by its identifier first, since scheduling always adds one.

Tests, refusal first: deleting when not signed in; deleting removes the caller, their sessions, their anonymous identity and the content of their space, and nobody else's; pruning takes the unused and the never-signed-in, and spares the recently used, newcomers and identified people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 17:09:47 +02:00
commit abe599d0aa
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 626 additions and 0 deletions

View file

@ -20,4 +20,16 @@ Future<void> run(List<String> args) async {
// Start the server.
await pod.start();
// Abandoned anonymous people are pruned daily (decided 2026-09-30).
// Scheduling always adds an entry, so the previous one goes first.
await pod.futureCalls.cancel(_pruning);
await pod.futureCalls
.callRecurring(identifier: _pruning)
.cron('17 3 * * *')
.pruning
.pruneAnonymousPeople();
}
/// The identifier of the daily pruning.
const _pruning = 'prune-abandoned-anonymous-people';

View file

@ -0,0 +1,69 @@
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// Deletes [authUserId] with everything they have, in one transaction, in
/// the order ADR 0001 (rule 6) fixes: their sessions end first, since a
/// device with a valid session would re-upload everything; then their
/// personal space goes with all its content (the foreign keys cascade into
/// every synced table and the sync engine's bookkeeping); then the person
/// with their identities.
Future<void> deletePerson(Session session, UuidValue authUserId) =>
session.db.transaction((transaction) async {
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
await ServerSideSession.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "offline_sync_space_members" WHERE "userUuid" = $1
await OfflineSyncSpaceMember.db.deleteWhere(
session,
where: (t) => t.userUuid.equals(authUserId),
transaction: transaction,
);
// The personal space is keyed on the person's id.
// DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
await OfflineSyncSpace.db.deleteWhere(
session,
where: (t) => t.uuidSpaceId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
await AuthServices.instance.authUsers.delete(
session,
authUserId: authUserId,
transaction: transaction,
);
});
/// Deletes the anonymous people nobody can return to, at most [limit] per
/// call, and returns how many (decided 2026-09-30): created before and unused
/// since 90 days before [now]. Their sessions have expired by then (the
/// inactivity timeout, `auth_config.dart`), and an anonymous account can't
/// sign in again. Identified people are never pruned.
Future<int> pruneAbandonedAnonymousPeople(
Session session, {
required DateTime now,
int limit = 500,
}) async {
// The columns hold UTC as timestamp without time zone.
final cutoff = now
.toUtc()
.subtract(const Duration(days: 90))
.toIso8601String()
.replaceFirst('Z', '');
final abandoned = await session.db.unsafeQuery(
'SELECT a."authUserId" FROM "serverpod_auth_idp_anonymous_account" a '
'WHERE a."createdAt" < @cutoff::timestamp '
'AND NOT EXISTS (SELECT 1 FROM "serverpod_auth_core_session" s '
'WHERE s."authUserId" = a."authUserId" '
'AND s."lastUsedAt" >= @cutoff::timestamp) '
'ORDER BY a."createdAt" LIMIT @limit',
parameters: QueryParameters.named({'cutoff': cutoff, 'limit': limit}),
);
for (final row in abandoned) {
await deletePerson(session, UuidValue.withValidation(row.first.toString()));
}
return abandoned.length;
}

View file

@ -0,0 +1,22 @@
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The signed-in person's account.
class AccountEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Deletes the caller with everything they have (see [deletePerson]). The
/// app also calls it when an anonymous account is given up (decided
/// 2026-09-30). Their other devices learn at once that their sessions
/// ended.
Future<void> delete(Session session) async {
final person = session.authenticated!.authUserId;
await deletePerson(session, person);
await session.messages.authenticationRevoked(
person.uuid,
RevokedAuthenticationUser(),
);
}
}

View file

@ -0,0 +1,16 @@
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:serverpod/serverpod.dart';
/// Deletes the anonymous people nobody can return to; the server schedules it
/// daily when it starts (`server.dart`).
class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went.
Future<void> pruneAnonymousPeople(Session session) async {
final pruned = await pruneAbandonedAnonymousPeople(
session,
now: clock.now(),
);
session.log('Pruned $pruned abandoned anonymous people.');
}
}

View file

@ -10,6 +10,8 @@
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
as _iip9210p;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -17,16 +19,24 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
as _iais;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
import '../auth/account_endpoint.dart' as _ii6ycf6v;
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
import '../auth/devices_endpoint.dart' as _iduct7ue;
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@override
void initializeEndpoints(_is.Server server) {
var endpoints = <String, _is.Endpoint>{
'account': _ii6ycf6v.AccountEndpoint()
..initialize(
server,
'account',
null,
),
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
..initialize(
server,
@ -58,6 +68,22 @@ class Endpoints extends _is.EndpointDispatch {
null,
),
};
connectors['account'] = _is.EndpointConnector(
name: 'account',
endpoint: endpoints['account']!,
methodConnectors: {
'delete': _is.MethodConnector(
name: 'delete',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['account'] as _ii6ycf6v.AccountEndpoint)
.delete(session),
),
},
);
connectors['anonymousIdp'] = _is.EndpointConnector(
name: 'anonymousIdp',
endpoint: endpoints['anonymousIdp']!,
@ -219,4 +245,9 @@ class Endpoints extends _is.EndpointDispatch {
modules['serverpod_auth_core'] = _iacs.Endpoints()
..initializeEndpoints(server);
}
@override
_is.FutureCallDispatch? get futureCalls {
return _iip9210p.FutureCalls();
}
}

View file

@ -0,0 +1,209 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: depend_on_referenced_packages
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'package:clock/clock.dart' as _io0w16m8;
import 'package:serverpod/serverpod.dart' as _is;
import '../auth/pruning_future_call.dart' as _igevg04y;
/// Invokes a future call.
typedef _InvokeFutureCall =
Future<void> Function(String name, _is.SerializableModel? object);
extension ServerpodFutureCallsGetter on _is.Serverpod {
/// Generated future calls.
FutureCalls get futureCalls => FutureCalls();
}
class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
FutureCalls._();
factory FutureCalls() {
return _instance;
}
static final FutureCalls _instance = FutureCalls._();
_is.FutureCallManager? _futureCallManager;
String? _serverId;
String get _effectiveServerId {
if (_serverId == null) {
throw StateError('FutureCalls is not initialized.');
}
return _serverId!;
}
_is.FutureCallManager get _effectiveFutureCallManager {
if (_futureCallManager == null) {
throw StateError('FutureCalls is not initialized.');
}
return _futureCallManager!;
}
@override
void initialize(
_is.FutureCallManager futureCallManager,
String serverId,
) {
var registeredFutureCalls = <String, _is.InvokableFutureCall>{
'PruningPruneAnonymousPeopleFutureCall':
PruningPruneAnonymousPeopleFutureCall(),
};
_futureCallManager = futureCallManager;
_serverId = serverId;
for (final entry in registeredFutureCalls.entries) {
_futureCallManager?.registerFutureCall(entry.value, entry.key);
}
}
@override
_FutureCallRef callAtTime(
DateTime time, {
String? identifier,
}) {
return _FutureCallRef(
(name, object) {
return _effectiveFutureCallManager.scheduleFutureCall(
name,
object,
time,
_effectiveServerId,
identifier,
);
},
);
}
@override
_FutureCallRef callWithDelay(
Duration delay, {
String? identifier,
}) {
return _FutureCallRef(
(name, object) {
return _effectiveFutureCallManager.scheduleFutureCall(
name,
object,
DateTime.now().toUtc().add(delay),
_effectiveServerId,
identifier,
);
},
);
}
@override
_is.RecurringFutureCallDispatch<_FutureCallRef> callRecurring({
String? identifier,
}) {
return _RecurringFutureCallDispatchImpl(
_effectiveFutureCallManager,
_effectiveServerId,
identifier,
);
}
@override
Future<void> cancel(String identifier) async {
await _effectiveFutureCallManager.cancelFutureCall(identifier);
}
}
class _RecurringFutureCallDispatchImpl
extends _is.RecurringFutureCallDispatch<_FutureCallRef> {
_RecurringFutureCallDispatchImpl(
this._futureCallManager,
this._serverId,
this._identifier,
);
final _is.FutureCallManager _futureCallManager;
final String _serverId;
final String? _identifier;
@override
_FutureCallRef cron(String cronExpression) {
return _FutureCallRef(
(name, object) {
return _futureCallManager.scheduleFutureCall(
name,
object,
_is.Cron.parse(cronExpression).nextTime(),
_serverId,
_identifier,
scheduling: _is.CronFutureCallScheduling(cron: cronExpression),
);
},
);
}
@override
_FutureCallRef every(
Duration interval, {
DateTime? start,
}) {
final now = _io0w16m8.clock.now().toUtc();
return _FutureCallRef(
(name, object) {
return _futureCallManager.scheduleFutureCall(
name,
object,
start ?? now.add(interval),
_serverId,
_identifier,
scheduling: _is.IntervalFutureCallScheduling(
interval: interval,
start: start,
),
);
},
);
}
}
class _FutureCallRef {
_FutureCallRef(this._invokeFutureCall);
final _InvokeFutureCall _invokeFutureCall;
late final pruning = _PruningFutureCallDispatcher(_invokeFutureCall);
}
class _PruningFutureCallDispatcher {
_PruningFutureCallDispatcher(this._invokeFutureCall);
final _InvokeFutureCall _invokeFutureCall;
Future<void> pruneAnonymousPeople() {
return _invokeFutureCall(
'PruningPruneAnonymousPeopleFutureCall',
null,
);
}
}
/// Prunes one batch and logs how many people went.
class PruningPruneAnonymousPeopleFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {
@override
_ida.Future<void> invoke(
_is.Session session,
_is.SerializableModel? object,
) async {
await _igevg04y.PruningFutureCall().pruneAnonymousPeople(session);
}
}

View file

@ -1,3 +1,5 @@
account:
- delete:
anonymousIdp:
- login:
devices:

View file

@ -18,6 +18,7 @@ import 'endpoints.dart' as _iavctuc6;
import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
export 'package:serverpod/serverpod.dart' hide Serverpod;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
/// The Serverpod server for this project.
///

View file

@ -9,6 +9,7 @@ environment:
resolution: workspace
dependencies:
clock: ^1.1.3
serverpod: 4.0.3
serverpod_auth_idp_server: 4.0.3
serverpod_offline_sync_server: 0.0.8

View file

@ -0,0 +1,170 @@
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod('Deleting an account', (sessionBuilder, endpoints) {
late Session session;
late UuidValue anna;
late UuidValue annasPhone;
late UuidValue annasStudyset;
late UuidValue ben;
late UuidValue bensStudyset;
setUp(() async {
useServerAuth();
session = sessionBuilder.build();
anna = await _newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
annasStudyset = await _studysetOf(session, anna);
ben = await _newAnonymousPerson(session);
await signInNewDevice(session, ben);
bensStudyset = await _studysetOf(session, ben);
});
test('refuses callers who are not signed in', () async {
await expectLater(
endpoints.account.delete(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('deletes the caller with everything they have, and nobody '
'else', () async {
await endpoints.account.delete(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
),
);
expect(await AuthUser.db.findById(session, anna), isNull);
expect(await _sessionsOf(session, anna), 0);
expect(await _isAnonymous(session, anna), isFalse);
expect(await _studysetExists(session, annasStudyset), isFalse);
expect(await AuthUser.db.findById(session, ben), isNotNull);
expect(await _sessionsOf(session, ben), 1);
expect(await _studysetExists(session, bensStudyset), isTrue);
});
});
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
late Session session;
final now = DateTime.now().toUtc();
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
setUp(() {
useServerAuth();
session = sessionBuilder.build();
});
test(
'deletes anonymous people unused for 90 days, and only them',
() async {
final unused = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, unused, daysAgo(95));
final neverSignedIn = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
final recentlyUsed = await _newAnonymousPerson(
session,
createdAt: daysAgo(100),
);
await _lastUsed(session, recentlyUsed, daysAgo(10));
final newcomer = await _newAnonymousPerson(
session,
createdAt: daysAgo(5),
);
final identifiedPerson = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await _lastUsed(session, identifiedPerson, daysAgo(365));
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
expect(pruned, 2);
expect(await AuthUser.db.findById(session, unused), isNull);
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
expect(await AuthUser.db.findById(session, kept), isNotNull);
}
},
);
});
}
/// A person as the app's first launch creates them: anonymous.
Future<UuidValue> _newAnonymousPerson(
Session session, {
DateTime? createdAt,
}) async {
final person = (await AuthServices.instance.authUsers.create(session)).id;
await AnonymousAccount.db.insertRow(
session,
AnonymousAccount(authUserId: person, createdAt: createdAt),
);
return person;
}
/// Signs [person] in on a device last used at [at].
Future<void> _lastUsed(Session session, UuidValue person, DateTime at) async {
final device = await signInNewDevice(session, person);
final row = await ServerSideSession.db.findById(session, device);
await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at));
}
/// A studyset in [person]'s personal space, written the way the sync engine
/// stores it; returns its id.
Future<UuidValue> _studysetOf(Session session, UuidValue person) async {
// INSERT INTO "offline_sync_spaces" ("uuidSpaceId") VALUES ($1) RETURNING "id"
final space = await session.db.unsafeQuery(
'INSERT INTO "offline_sync_spaces" ("uuidSpaceId") '
'VALUES (@person::uuid) RETURNING "id"',
parameters: QueryParameters.named({'person': person.uuid}),
);
// INSERT INTO "studyset" ("spaceId", "payload") VALUES ($1, $2) RETURNING "id"
final studyset = await session.db.unsafeQuery(
'INSERT INTO "studyset" ("spaceId", "payload") '
r"VALUES (@space, '\x7b7d'::bytea) RETURNING "
'"id"',
parameters: QueryParameters.named({'space': space.first.first}),
);
return UuidValue.withValidation(studyset.first.first.toString());
}
Future<bool> _studysetExists(Session session, UuidValue id) async =>
(await session.db.unsafeQuery(
'SELECT 1 FROM "studyset" WHERE "id" = @id::uuid',
parameters: QueryParameters.named({'id': id.uuid}),
)).isNotEmpty;
Future<int> _sessionsOf(Session session, UuidValue person) =>
ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(person),
);
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0;

View file

@ -15,6 +15,8 @@ import 'dart:async' as _ida;
import 'dart:io' as _idi;
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
as _ipdmo8cq;
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
as _iip9210p;
import 'package:halkyon_learn_server/src/generated/instance/instance_info.dart'
as _iof1vv19;
import 'package:serverpod/serverpod.dart' as _is;
@ -154,6 +156,10 @@ void withServerpod(
}
class TestEndpoints {
late final futureCalls = _FutureCalls();
late final _AccountEndpoint account;
late final _AnonymousIdpEndpoint anonymousIdp;
late final _DevicesEndpoint devices;
@ -172,6 +178,10 @@ class _InternalTestEndpoints extends TestEndpoints
_is.SerializationManager serializationManager,
_is.EndpointDispatch endpoints,
) {
account = _AccountEndpoint(
endpoints,
serializationManager,
);
anonymousIdp = _AnonymousIdpEndpoint(
endpoints,
serializationManager,
@ -195,6 +205,49 @@ class _InternalTestEndpoints extends TestEndpoints
}
}
class _FutureCalls {
late final pruning = _PruningFutureCall();
}
class _AccountEndpoint {
_AccountEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<void> delete(_ist.TestSessionBuilder sessionBuilder) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'account',
method: 'delete',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'account',
methodName: 'delete',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _AnonymousIdpEndpoint {
_AnonymousIdpEndpoint(
this._endpointDispatch,
@ -445,3 +498,20 @@ class _InstanceEndpoint {
});
}
}
class _PruningFutureCall {
Future<void> pruneAnonymousPeople(
_ist.TestSessionBuilder sessionBuilder,
) async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
try {
await _iip9210p.PruningPruneAnonymousPeopleFutureCall().invoke(
_localUniqueSession,
null,
);
} finally {
await _localUniqueSession.close();
}
}
}