Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
692 lines
25 KiB
Dart
692 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:halkyon_base_server/src/auth/account.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:halkyon_base_server/src/register/local_register.dart';
|
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
|
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
|
import 'package:serverpod/serverpod.dart';
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
// The calls between an instance's services (ADR 0016), read into plain values
|
|
// so tests drive them without a web server; `instance_routes.dart` puts them
|
|
// on the web server. Every call carries the instance's shared secret: without
|
|
// it, or on a server without one, nothing is answered.
|
|
|
|
/// What a call between the instance's services is answered: an HTTP status
|
|
/// and a JSON body.
|
|
typedef InstanceAnswer = ({int status, Map<String, Object?> body});
|
|
|
|
const _unauthorized = (status: 401, body: <String, Object?>{'error': 'secret'});
|
|
const _notHere = (status: 404, body: <String, Object?>{'error': 'not here'});
|
|
const _unreadable = (status: 400, body: <String, Object?>{'error': 'request'});
|
|
|
|
/// Whether [authorization] (a request's header) carries the instance's
|
|
/// secret, which [pod] must have.
|
|
bool _carriesSecret(Serverpod pod, String? authorization) {
|
|
final secret = instanceSecretOf(pod);
|
|
if (secret == null || authorization == null) {
|
|
return false;
|
|
}
|
|
const scheme = 'Bearer ';
|
|
return authorization.startsWith(scheme) &&
|
|
isInstanceSecret(authorization.substring(scheme.length), secret);
|
|
}
|
|
|
|
/// [body] as a JSON object; null for anything else.
|
|
Map<String, Object?>? _arguments(String body) {
|
|
try {
|
|
return switch (jsonDecode(body)) {
|
|
final Map<String, Object?> arguments => arguments,
|
|
_ => null,
|
|
};
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// The register answering a member's [operation] (`register_protocol.dart`),
|
|
/// asked with [body] (JSON) and [authorization] (the request's header). On
|
|
/// a server that is a member itself, nothing is answered.
|
|
Future<InstanceAnswer> answerMember(
|
|
Session session,
|
|
String operation,
|
|
String body, {
|
|
required String? authorization,
|
|
}) async {
|
|
final pod = session.serverpod;
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
return _unauthorized;
|
|
}
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is MemberOfRegister) {
|
|
return _notHere;
|
|
}
|
|
final arguments = _arguments(body);
|
|
if (arguments == null) {
|
|
return _unreadable;
|
|
}
|
|
try {
|
|
final answer = await _answer(session, operation, _Arguments(arguments));
|
|
return answer == null ? _notHere : (status: 200, body: answer);
|
|
} on FormatException {
|
|
return _unreadable;
|
|
} on ProfileNotFoundException {
|
|
return (status: 404, body: <String, Object?>{'notFound': 'profile'});
|
|
} on Exception catch (error) {
|
|
if (refusalJson(error) case final refusal?) {
|
|
return (status: 409, body: <String, Object?>{'refusal': refusal});
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// [operation]'s answer from this server's own register; null for an
|
|
/// operation it doesn't know.
|
|
Future<Map<String, Object?>?> _answer(
|
|
Session session,
|
|
String operation,
|
|
_Arguments arguments,
|
|
) async {
|
|
const register = LocalRegister();
|
|
switch (operation) {
|
|
case RegisterOperation.profileOf:
|
|
final profile = await register.profileOf(session, arguments.id('person'));
|
|
return {'profile': profile?.toJson()};
|
|
case RegisterOperation.profilesOf:
|
|
final profiles = await register.profilesOf(
|
|
session,
|
|
arguments.ids('people'),
|
|
);
|
|
return {
|
|
'profiles': {
|
|
for (final MapEntry(:key, :value) in profiles.entries)
|
|
key.uuid: value.toJson(),
|
|
},
|
|
};
|
|
case RegisterOperation.profileByHandle:
|
|
final profile = await register.profileByHandle(
|
|
session,
|
|
arguments.string('handle'),
|
|
);
|
|
return {'profile': profile?.toJson()};
|
|
case RegisterOperation.personWithHandle:
|
|
final person = await register.personWithHandle(
|
|
session,
|
|
arguments.string('handle'),
|
|
);
|
|
return {'person': person?.uuid};
|
|
case RegisterOperation.saveProfile:
|
|
final profile = await register.saveProfile(
|
|
session,
|
|
arguments.id('person'),
|
|
handle: arguments.string('handle'),
|
|
displayName: arguments.string('displayName'),
|
|
bio: arguments.string('bio'),
|
|
links: arguments.strings('links'),
|
|
);
|
|
return {'profile': profile.toJson()};
|
|
case RegisterOperation.checkHandle:
|
|
final refusal = await register.checkHandle(
|
|
session,
|
|
arguments.id('person'),
|
|
arguments.string('handle'),
|
|
);
|
|
return {'refusal': refusal?.name};
|
|
case RegisterOperation.checkLinks:
|
|
final profile = await register.checkLinks(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {'profile': profile.toJson()};
|
|
case RegisterOperation.setAvatar:
|
|
final Uint8List png;
|
|
try {
|
|
png = base64Decode(arguments.string('png'));
|
|
} on FormatException {
|
|
throw ProfileRefusedException(reason: ProfileRefusal.avatarInvalid);
|
|
}
|
|
final profile = await register.setAvatar(
|
|
session,
|
|
arguments.id('person'),
|
|
ByteData.sublistView(png),
|
|
);
|
|
return {'profile': profile.toJson()};
|
|
case RegisterOperation.removeAvatar:
|
|
final profile = await register.removeAvatar(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {'profile': profile.toJson()};
|
|
case RegisterOperation.removeProfile:
|
|
await register.removeProfile(session, arguments.id('person'));
|
|
return const {};
|
|
case RegisterOperation.fixHandle:
|
|
await register.fixHandle(session, arguments.id('person'));
|
|
return const {};
|
|
case RegisterOperation.emailSignInOffered:
|
|
return {'offered': await register.emailSignInOffered(session)};
|
|
case RegisterOperation.sendSignInCode:
|
|
final request = await register.sendSignInCode(
|
|
session,
|
|
address: arguments.string('address'),
|
|
language: arguments.string('language'),
|
|
purpose: EmailCodePurpose.fromJson(arguments.string('purpose')),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'request': request.uuid};
|
|
case RegisterOperation.linkGitHub:
|
|
await register.linkGitHub(
|
|
session,
|
|
person: arguments.id('person'),
|
|
gitHubUserId: arguments.string('gitHubUserId'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.accountWithGitHub:
|
|
final account = await register.accountWithGitHub(
|
|
session,
|
|
arguments.string('gitHubUserId'),
|
|
);
|
|
return {'account': account?.uuid};
|
|
case RegisterOperation.simpleLoginOffered:
|
|
return {'offered': await register.simpleLoginOffered(session)};
|
|
case RegisterOperation.beginSimpleLogin:
|
|
final start = await register.beginSimpleLogin(
|
|
session,
|
|
marker: arguments.string('marker'),
|
|
caller: arguments.optionalId('caller'),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'start': start.toJson()};
|
|
case RegisterOperation.completeSimpleLogin:
|
|
final proof = await register.completeSimpleLogin(
|
|
session,
|
|
attempt: arguments.id('attempt'),
|
|
code: arguments.string('code'),
|
|
caller: arguments.optionalId('caller'),
|
|
);
|
|
return {'proof': proof.toJson()};
|
|
case RegisterOperation.checkSimpleLoginHandle:
|
|
final refusal = await register.checkSimpleLoginHandle(
|
|
session,
|
|
attempt: arguments.id('attempt'),
|
|
handle: arguments.string('handle'),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'refusal': refusal?.name};
|
|
case RegisterOperation.signUpWithSimpleLogin:
|
|
final account = await register.signUpWithSimpleLogin(
|
|
session,
|
|
attempt: arguments.id('attempt'),
|
|
handle: arguments.string('handle'),
|
|
setup: arguments.model('setup', KeysSetup.fromJson),
|
|
);
|
|
return {'account': account.uuid};
|
|
case RegisterOperation.beginSimpleLoginMend:
|
|
final challenge = await register.beginSimpleLoginMend(
|
|
session,
|
|
attempt: arguments.id('attempt'),
|
|
handle: arguments.string('handle'),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.mendSimpleLogin:
|
|
final proof = await register.mendSimpleLogin(
|
|
session,
|
|
attempt: arguments.id('attempt'),
|
|
proof: arguments.model('proof', KeysProof.fromJson),
|
|
);
|
|
return {'proof': proof.toJson()};
|
|
case RegisterOperation.beginPasswordKeyProof:
|
|
final challenge = await register.beginPasswordKeyProof(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.unlockKeys:
|
|
final keys = await register.unlockKeys(
|
|
session,
|
|
arguments.id('person'),
|
|
arguments.model('proof', KeysProof.fromJson),
|
|
);
|
|
return {'keys': keys.toJson()};
|
|
case RegisterOperation.setFirstKeys:
|
|
await register.setFirstKeys(
|
|
session,
|
|
person: arguments.id('person'),
|
|
attempt: arguments.id('attempt'),
|
|
setup: arguments.model('setup', KeysSetup.fromJson),
|
|
handle: arguments.optionalString('handle'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.beginPhraseKeyProof:
|
|
final challenge = await register.beginPhraseKeyProof(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.changeKeysPassword:
|
|
await register.changeKeysPassword(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
password: arguments.model('password', KeysPassword.fromJson),
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.resetKeysPassword:
|
|
await register.resetKeysPassword(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
password: arguments.model('password', KeysPassword.fromJson),
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.setKeysRecovery:
|
|
await register.setKeysRecovery(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', KeysOwnerProof.fromJson),
|
|
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
|
recoveryProofKey: arguments.bytes('recoveryProofKey'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.signInWaysOf:
|
|
final ways = await register.signInWaysOf(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {'ways': ways.toJson()};
|
|
case RegisterOperation.noteSessionEndedForProofs:
|
|
await register.noteSessionEndedForProofs(session, arguments.id('person'));
|
|
return const {};
|
|
case RegisterOperation.dismissSessionEndedWarning:
|
|
await register.dismissSessionEndedWarning(
|
|
session,
|
|
arguments.id('person'),
|
|
// When the member's session asking was made (the full reading's L1).
|
|
sessionMadeAt: arguments.optionalTime('sessionMadeAt'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.deleteAccount:
|
|
await register.deleteAccount(
|
|
session,
|
|
arguments.id('person'),
|
|
proof: arguments.has('proof')
|
|
? arguments.model('proof', DeletionProof.fromJson)
|
|
: null,
|
|
keysProof: arguments.has('keysProof')
|
|
? arguments.model('keysProof', KeysOwnerProof.fromJson)
|
|
: null,
|
|
);
|
|
return const {};
|
|
case RegisterOperation.knownAccounts:
|
|
final known = await register.knownAccounts(
|
|
session,
|
|
arguments.ids('people'),
|
|
);
|
|
return {
|
|
'known': [for (final person in known) person.uuid],
|
|
};
|
|
case RegisterOperation.passwordSignInOffered:
|
|
return {'offered': await register.passwordSignInOffered(session)};
|
|
case RegisterOperation.setPassword:
|
|
await register.setPassword(
|
|
session,
|
|
person: arguments.id('person'),
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
request: arguments.optionalId('request'),
|
|
code: arguments.optionalString('code'),
|
|
// The member tells it of its own session (the fourth reading) and
|
|
// person (the full reading's M1): the register keeps no anonymous
|
|
// person of a member's.
|
|
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
|
|
anonymousHere: arguments.flag('anonymousHere'),
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.beginPasswordSignIn:
|
|
final challenge = await register.beginPasswordSignIn(
|
|
session,
|
|
address: arguments.string('address'),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.completePasswordSignIn:
|
|
final proof = await register.completePasswordSignIn(
|
|
session,
|
|
challenge: arguments.id('challenge'),
|
|
clientValue: arguments.bytes('clientValue'),
|
|
clientProof: arguments.bytes('clientProof'),
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
|
request: arguments.optionalId('request'),
|
|
code: arguments.optionalString('code'),
|
|
instanceUrl: arguments.string('instanceUrl'),
|
|
address: arguments.string('address'),
|
|
language: arguments.string('language'),
|
|
);
|
|
return {'proof': proof.toJson()};
|
|
case RegisterOperation.signUpWithCode:
|
|
final account = await register.signUpWithCode(
|
|
session,
|
|
request: arguments.id('request'),
|
|
code: arguments.string('code'),
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
);
|
|
return {'account': account.uuid};
|
|
case RegisterOperation.enrolAuthenticator:
|
|
final enrolment = await register.enrolAuthenticator(
|
|
session,
|
|
person: arguments.id('person'),
|
|
name: arguments.string('name'),
|
|
label: arguments.string('label'),
|
|
);
|
|
return {'enrolment': enrolment.toJson()};
|
|
case RegisterOperation.confirmAuthenticator:
|
|
final codes = await register.confirmAuthenticator(
|
|
session,
|
|
person: arguments.id('person'),
|
|
authenticator: arguments.id('authenticator'),
|
|
code: arguments.string('code'),
|
|
proof: arguments.has('proof')
|
|
? arguments.model('proof', OwnerProof.fromJson)
|
|
: null,
|
|
);
|
|
return {'recoveryCodes': codes};
|
|
case RegisterOperation.authenticatorsOf:
|
|
final authenticators = await register.authenticatorsOf(
|
|
session,
|
|
arguments.id('person'),
|
|
);
|
|
return {
|
|
'authenticators': [
|
|
for (final authenticator in authenticators) authenticator.toJson(),
|
|
],
|
|
};
|
|
case RegisterOperation.removeAuthenticator:
|
|
await register.removeAuthenticator(
|
|
session,
|
|
person: arguments.id('person'),
|
|
authenticator: arguments.id('authenticator'),
|
|
proof: arguments.has('proof')
|
|
? arguments.model('proof', OwnerProof.fromJson)
|
|
: null,
|
|
);
|
|
return const {};
|
|
case RegisterOperation.newRecoveryCodes:
|
|
final codes = await register.newRecoveryCodes(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
|
);
|
|
return {'recoveryCodes': codes};
|
|
case RegisterOperation.resetAuthenticator:
|
|
final pending = await register.resetAuthenticator(
|
|
session,
|
|
challenge: arguments.id('challenge'),
|
|
clientValue: arguments.bytes('clientValue'),
|
|
clientProof: arguments.bytes('clientProof'),
|
|
address: arguments.string('address'),
|
|
language: arguments.string('language'),
|
|
request: arguments.optionalId('request'),
|
|
code: arguments.optionalString('code'),
|
|
keyChallenge: arguments.optionalId('keyChallenge'),
|
|
signature: arguments.optionalBytes('signature'),
|
|
instanceUrl: arguments.string('instanceUrl'),
|
|
);
|
|
return {'pending': pending.toJson()};
|
|
case RegisterOperation.beginKeyProof:
|
|
final challenge = await register.beginKeyProof(
|
|
session,
|
|
address: arguments.string('address'),
|
|
network: arguments.string('network'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.resetPasswordWithPhrase:
|
|
final ended = await register.resetPasswordWithPhrase(
|
|
session,
|
|
challenge: arguments.id('challenge'),
|
|
signature: arguments.bytes('signature'),
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
|
instanceUrl: arguments.string('instanceUrl'),
|
|
address: arguments.string('address'),
|
|
language: arguments.string('language'),
|
|
);
|
|
return _sessionsEnded(ended);
|
|
case RegisterOperation.startAccountReset:
|
|
final pending = await register.startAccountReset(
|
|
session,
|
|
address: arguments.string('address'),
|
|
language: arguments.string('language'),
|
|
request: arguments.id('request'),
|
|
code: arguments.string('code'),
|
|
);
|
|
return {'pending': pending.toJson()};
|
|
case RegisterOperation.completeAccountReset:
|
|
final ended = await register.completeAccountReset(
|
|
session,
|
|
address: arguments.string('address'),
|
|
request: arguments.id('request'),
|
|
code: arguments.string('code'),
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
);
|
|
return _sessionsEnded(ended);
|
|
case RegisterOperation.beginPasswordChange:
|
|
final challenge = await register.beginPasswordChange(
|
|
session,
|
|
person: arguments.id('person'),
|
|
);
|
|
return {'challenge': challenge.toJson()};
|
|
case RegisterOperation.changePassword:
|
|
await register.changePassword(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
keepSession: arguments.optionalId('keepSession'),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.callOffResets:
|
|
await register.callOffResets(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
|
);
|
|
return const {};
|
|
case RegisterOperation.setRecoveryKeys:
|
|
await register.setRecoveryKeys(
|
|
session,
|
|
person: arguments.id('person'),
|
|
proof: arguments.model('proof', OwnerProof.fromJson),
|
|
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
|
recoveryProofKey: arguments.bytes('recoveryProofKey'),
|
|
);
|
|
return const {};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// [ended] as the register answers it.
|
|
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
|
|
'account': ended.account.uuid,
|
|
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
|
|
};
|
|
|
|
/// A member answering the register that [body]'s account is gone
|
|
/// ([accountGonePath]): that person's data here is deleted, here alone (the
|
|
/// register deleted the account), and the register told so. On the register
|
|
/// itself, nothing is answered.
|
|
Future<InstanceAnswer> answerRegister(
|
|
Session session,
|
|
String body, {
|
|
required String? authorization,
|
|
}) async {
|
|
final pod = session.serverpod;
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
return _unauthorized;
|
|
}
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
return _notHere;
|
|
}
|
|
final arguments = _arguments(body);
|
|
if (arguments == null) {
|
|
return _unreadable;
|
|
}
|
|
final UuidValue account;
|
|
try {
|
|
account = _Arguments(arguments).id('account');
|
|
} on FormatException {
|
|
return _unreadable;
|
|
}
|
|
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
|
|
if (await AuthUser.db.findById(session, account) != null) {
|
|
await deletePerson(session, account);
|
|
await session.messages.authenticationRevoked(
|
|
account.uuid,
|
|
RevokedAuthenticationUser(),
|
|
);
|
|
}
|
|
return (status: 200, body: const <String, Object?>{});
|
|
}
|
|
|
|
/// A member answering the register that [body]'s account's sessions ended
|
|
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
|
|
/// names end, but the one it keeps and the one made here for that moment
|
|
/// (`sessionMadeAfter`), and the register is told so. On the register
|
|
/// itself, nothing is answered.
|
|
Future<InstanceAnswer> answerSessionsEnded(
|
|
Session session,
|
|
String body, {
|
|
required String? authorization,
|
|
}) async {
|
|
final pod = session.serverpod;
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
return _unauthorized;
|
|
}
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
return _notHere;
|
|
}
|
|
final arguments = _arguments(body);
|
|
if (arguments == null) {
|
|
return _unreadable;
|
|
}
|
|
final UuidValue account;
|
|
final DateTime endedBefore;
|
|
final DateTime? sentAt;
|
|
final UuidValue? keepSession;
|
|
try {
|
|
final read = _Arguments(arguments);
|
|
account = read.id('account');
|
|
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
|
sentAt = read.optionalTime('sentAt');
|
|
keepSession = read.optionalId('keepSession');
|
|
} on FormatException {
|
|
return _unreadable;
|
|
}
|
|
await endSessionsHere(
|
|
session,
|
|
account,
|
|
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
|
|
keepSessions: {
|
|
?keepSession,
|
|
?sessionMadeAfter(account, endedBefore),
|
|
},
|
|
);
|
|
return (status: 200, body: const <String, Object?>{});
|
|
}
|
|
|
|
/// The register's moment [endedBefore] by this server's clock: as long
|
|
/// before now as it was before the register sent it ([sentAt], by the
|
|
/// register's clock), so the clocks' offset drops out (the reading of the
|
|
/// fixes' C1; one way's travel makes it a moment later, ending a session
|
|
/// made in that instant too). A moment sent before it was taken (a clock set
|
|
/// back) is taken as now; without [sentAt] (an older register), as sent.
|
|
DateTime _byThisClock(DateTime endedBefore, {required DateTime? sentAt}) {
|
|
if (sentAt == null) {
|
|
return endedBefore;
|
|
}
|
|
final age = sentAt.difference(endedBefore);
|
|
return clock.now().toUtc().subtract(age.isNegative ? Duration.zero : age);
|
|
}
|
|
|
|
/// A request's arguments, each read as what it must be; a [FormatException]
|
|
/// for anything else.
|
|
extension type const _Arguments(Map<String, Object?> _map) {
|
|
String string(String name) => switch (_map[name]) {
|
|
final String value => value,
|
|
_ => throw FormatException('$name is no string'),
|
|
};
|
|
|
|
List<String> strings(String name) => switch (_map[name]) {
|
|
final List<Object?> values when values.every((value) => value is String) =>
|
|
values.cast<String>(),
|
|
_ => throw FormatException('$name is no list of strings'),
|
|
};
|
|
|
|
String? optionalString(String name) => switch (_map[name]) {
|
|
null => null,
|
|
final String value => value,
|
|
_ => throw FormatException('$name is no string'),
|
|
};
|
|
|
|
UuidValue id(String name) => UuidValue.withValidation(string(name));
|
|
|
|
UuidValue? optionalId(String name) => switch (optionalString(name)) {
|
|
final value? => UuidValue.withValidation(value),
|
|
null => null,
|
|
};
|
|
|
|
ByteData bytes(String name) => ByteData.sublistView(
|
|
base64Decode(string(name)),
|
|
);
|
|
|
|
ByteData? optionalBytes(String name) =>
|
|
optionalString(name) == null ? null : bytes(name);
|
|
|
|
/// The moment [name] (ISO 8601) in UTC; null where it is missing.
|
|
DateTime? optionalTime(String name) => switch (optionalString(name)) {
|
|
final value? => DateTime.parse(value).toUtc(),
|
|
null => null,
|
|
};
|
|
|
|
/// Whether the argument [name] holds anything (not null).
|
|
bool has(String name) => _map[name] != null;
|
|
|
|
/// The flag [name]: false where it is missing.
|
|
bool flag(String name) => switch (_map[name]) {
|
|
null => false,
|
|
final bool value => value,
|
|
_ => throw FormatException('$name is no flag'),
|
|
};
|
|
|
|
/// The model [read] makes of the object [name]; a [FormatException] for
|
|
/// what isn't one.
|
|
T model<T>(String name, T Function(Map<String, dynamic> json) read) {
|
|
final json = _map[name];
|
|
if (json is! Map<String, Object?>) {
|
|
throw FormatException('$name is no object');
|
|
}
|
|
try {
|
|
return read(json);
|
|
} on FormatException {
|
|
rethrow;
|
|
} on Object catch (error) {
|
|
// A field missing or of another type throws a TypeError: the request
|
|
// is unreadable all the same.
|
|
throw FormatException('$name: ${error.runtimeType}');
|
|
}
|
|
}
|
|
|
|
Set<UuidValue> ids(String name) => {
|
|
for (final value in strings(name)) UuidValue.withValidation(value),
|
|
};
|
|
}
|