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:
parent
f6390120f2
commit
abe599d0aa
12 changed files with 626 additions and 0 deletions
|
|
@ -28,6 +28,25 @@ import 'protocol.dart' as _il2as5qe;
|
||||||
import 'sync_tables.dart' as _ii3f3u05;
|
import 'sync_tables.dart' as _ii3f3u05;
|
||||||
import 'package:halkyon_learn_client/migrations/migration_registry.dart';
|
import 'package:halkyon_learn_client/migrations/migration_registry.dart';
|
||||||
|
|
||||||
|
/// The signed-in person's account.
|
||||||
|
/// {@category Endpoint}
|
||||||
|
class EndpointAccount extends _isc.EndpointRef {
|
||||||
|
EndpointAccount(_isc.EndpointCaller caller) : super(caller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'account';
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
_ida.Future<void> delete() => caller.callServerEndpoint<void>(
|
||||||
|
'account',
|
||||||
|
'delete',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
||||||
/// account is rate-limited per IP address (see `auth_config.dart`).
|
/// account is rate-limited per IP address (see `auth_config.dart`).
|
||||||
/// {@category Endpoint}
|
/// {@category Endpoint}
|
||||||
|
|
@ -195,6 +214,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
disconnectStreamsOnLostInternetConnection,
|
disconnectStreamsOnLostInternetConnection,
|
||||||
httpClientOverride: httpClientOverride,
|
httpClientOverride: httpClientOverride,
|
||||||
) {
|
) {
|
||||||
|
account = EndpointAccount(this);
|
||||||
anonymousIdp = EndpointAnonymousIdp(this);
|
anonymousIdp = EndpointAnonymousIdp(this);
|
||||||
devices = EndpointDevices(this);
|
devices = EndpointDevices(this);
|
||||||
gitHubSignIn = EndpointGitHubSignIn(this);
|
gitHubSignIn = EndpointGitHubSignIn(this);
|
||||||
|
|
@ -203,6 +223,8 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
modules = Modules(this);
|
modules = Modules(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final EndpointAccount account;
|
||||||
|
|
||||||
late final EndpointAnonymousIdp anonymousIdp;
|
late final EndpointAnonymousIdp anonymousIdp;
|
||||||
|
|
||||||
late final EndpointDevices devices;
|
late final EndpointDevices devices;
|
||||||
|
|
@ -217,6 +239,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, _isc.EndpointRef> get endpointRefLookup => {
|
Map<String, _isc.EndpointRef> get endpointRefLookup => {
|
||||||
|
'account': account,
|
||||||
'anonymousIdp': anonymousIdp,
|
'anonymousIdp': anonymousIdp,
|
||||||
'devices': devices,
|
'devices': devices,
|
||||||
'gitHubSignIn': gitHubSignIn,
|
'gitHubSignIn': gitHubSignIn,
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,16 @@ Future<void> run(List<String> args) async {
|
||||||
|
|
||||||
// Start the server.
|
// Start the server.
|
||||||
await pod.start();
|
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';
|
||||||
|
|
|
||||||
69
halkyon_learn_server/lib/src/auth/account.dart
Normal file
69
halkyon_learn_server/lib/src/auth/account.dart
Normal 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;
|
||||||
|
}
|
||||||
22
halkyon_learn_server/lib/src/auth/account_endpoint.dart
Normal file
22
halkyon_learn_server/lib/src/auth/account_endpoint.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
halkyon_learn_server/lib/src/auth/pruning_future_call.dart
Normal file
16
halkyon_learn_server/lib/src/auth/pruning_future_call.dart
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
// ignore_for_file: invalid_use_of_internal_member
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// 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/serverpod.dart' as _is;
|
||||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||||
as _iacs;
|
as _iacs;
|
||||||
|
|
@ -17,16 +19,24 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
||||||
as _iais;
|
as _iais;
|
||||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
||||||
as _izehhkf5;
|
as _izehhkf5;
|
||||||
|
import '../auth/account_endpoint.dart' as _ii6ycf6v;
|
||||||
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
||||||
import '../auth/devices_endpoint.dart' as _iduct7ue;
|
import '../auth/devices_endpoint.dart' as _iduct7ue;
|
||||||
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
|
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
|
||||||
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
|
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
|
||||||
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
||||||
|
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||||
|
|
||||||
class Endpoints extends _is.EndpointDispatch {
|
class Endpoints extends _is.EndpointDispatch {
|
||||||
@override
|
@override
|
||||||
void initializeEndpoints(_is.Server server) {
|
void initializeEndpoints(_is.Server server) {
|
||||||
var endpoints = <String, _is.Endpoint>{
|
var endpoints = <String, _is.Endpoint>{
|
||||||
|
'account': _ii6ycf6v.AccountEndpoint()
|
||||||
|
..initialize(
|
||||||
|
server,
|
||||||
|
'account',
|
||||||
|
null,
|
||||||
|
),
|
||||||
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
|
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
|
||||||
..initialize(
|
..initialize(
|
||||||
server,
|
server,
|
||||||
|
|
@ -58,6 +68,22 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
null,
|
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(
|
connectors['anonymousIdp'] = _is.EndpointConnector(
|
||||||
name: 'anonymousIdp',
|
name: 'anonymousIdp',
|
||||||
endpoint: endpoints['anonymousIdp']!,
|
endpoint: endpoints['anonymousIdp']!,
|
||||||
|
|
@ -219,4 +245,9 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||||
..initializeEndpoints(server);
|
..initializeEndpoints(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
_is.FutureCallDispatch? get futureCalls {
|
||||||
|
return _iip9210p.FutureCalls();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
halkyon_learn_server/lib/src/generated/future_calls.dart
Normal file
209
halkyon_learn_server/lib/src/generated/future_calls.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
account:
|
||||||
|
- delete:
|
||||||
anonymousIdp:
|
anonymousIdp:
|
||||||
- login:
|
- login:
|
||||||
devices:
|
devices:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import 'endpoints.dart' as _iavctuc6;
|
||||||
import 'protocol.dart' as _il2as5qe;
|
import 'protocol.dart' as _il2as5qe;
|
||||||
import 'sync_tables.dart' as _ii3f3u05;
|
import 'sync_tables.dart' as _ii3f3u05;
|
||||||
export 'package:serverpod/serverpod.dart' hide Serverpod;
|
export 'package:serverpod/serverpod.dart' hide Serverpod;
|
||||||
|
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||||
|
|
||||||
/// The Serverpod server for this project.
|
/// The Serverpod server for this project.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ environment:
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
clock: ^1.1.3
|
||||||
serverpod: 4.0.3
|
serverpod: 4.0.3
|
||||||
serverpod_auth_idp_server: 4.0.3
|
serverpod_auth_idp_server: 4.0.3
|
||||||
serverpod_offline_sync_server: 0.0.8
|
serverpod_offline_sync_server: 0.0.8
|
||||||
|
|
|
||||||
170
halkyon_learn_server/test/integration/auth/account_test.dart
Normal file
170
halkyon_learn_server/test/integration/auth/account_test.dart
Normal 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;
|
||||||
|
|
@ -15,6 +15,8 @@ import 'dart:async' as _ida;
|
||||||
import 'dart:io' as _idi;
|
import 'dart:io' as _idi;
|
||||||
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
|
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
|
||||||
as _ipdmo8cq;
|
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'
|
import 'package:halkyon_learn_server/src/generated/instance/instance_info.dart'
|
||||||
as _iof1vv19;
|
as _iof1vv19;
|
||||||
import 'package:serverpod/serverpod.dart' as _is;
|
import 'package:serverpod/serverpod.dart' as _is;
|
||||||
|
|
@ -154,6 +156,10 @@ void withServerpod(
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestEndpoints {
|
class TestEndpoints {
|
||||||
|
late final futureCalls = _FutureCalls();
|
||||||
|
|
||||||
|
late final _AccountEndpoint account;
|
||||||
|
|
||||||
late final _AnonymousIdpEndpoint anonymousIdp;
|
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||||
|
|
||||||
late final _DevicesEndpoint devices;
|
late final _DevicesEndpoint devices;
|
||||||
|
|
@ -172,6 +178,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
||||||
_is.SerializationManager serializationManager,
|
_is.SerializationManager serializationManager,
|
||||||
_is.EndpointDispatch endpoints,
|
_is.EndpointDispatch endpoints,
|
||||||
) {
|
) {
|
||||||
|
account = _AccountEndpoint(
|
||||||
|
endpoints,
|
||||||
|
serializationManager,
|
||||||
|
);
|
||||||
anonymousIdp = _AnonymousIdpEndpoint(
|
anonymousIdp = _AnonymousIdpEndpoint(
|
||||||
endpoints,
|
endpoints,
|
||||||
serializationManager,
|
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 {
|
class _AnonymousIdpEndpoint {
|
||||||
_AnonymousIdpEndpoint(
|
_AnonymousIdpEndpoint(
|
||||||
this._endpointDispatch,
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue