feat(app): a wait screen for accounts that wait for the operator (ADR 0021, step 1)

An account signed in or up to on an instance whose operator approves
new accounts, or one suspended, gets no session but a wait: the device
keeps its pass, words and handle in the keyring per instance
(SecureAccountWaitStore, InstanceStorage.waitOf), the auth status is
Waiting, and SessionGate shows WaitScreen in its own app, so the start
screen's sheets go with it. Every sign-in flow hands over to it: the
password's sign-in, sign-up, reset and account reset, SimpleLogin's
sign-in, sign-up and mending; their cubits show no problem then.

The wait screen shows the handle, the four words the operator reads out
for the person to match, and that Halkyon never asks anyone to read them
out (D33); it asks where the wait stands as it opens, every 30 seconds,
as the app comes back and when asked. A new account can be deleted with
its pass, a suspended one can't (ADR 0017). Approved, it leads to the
usual sign-in, once more as before (D31); a pass the instance knows no
more ends the wait. The start screen says where new accounts wait for
approval, and offers no Create an account where the operator takes none;
both refusals (closed, too many waiting) have their words, en and de.

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-25 15:29:22 +01:00
commit 7ac715cd97
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -32,15 +33,60 @@ final class _Instance() {
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
/// What each call sent, by its endpoint and method.
final bodies = <String, String>{};
/// Who may sign up, as the basics say.
SignUpMode signUp = SignUpMode.open;
/// Where the wait stands for the pass `ben-pass`, as `waiting.look` says.
WaitingState waiting = WaitingState.waiting;
/// Whether a sign-up waits for the operator: a pass in place of the
/// session (Halkyon Learn's ADR 0021, D31).
bool signUpWaits = false;
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
bodies[call] = request.body;
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.passwordSignIn/signUp' when signUpWaits => http.Response(
sent({
// As the instance names it: by its module.
'className': 'halkyon_base.AccountWaitingException',
'data': AccountWaitingException(
pass: 'ben-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'ben',
suspended: false,
).toJson(),
}),
400,
),
'halkyon_base.passwordSignIn/signUp' => http.Response(
sent(_session(ben, 'ben-token', identified: true)),
200,
),
'halkyon_base.instance/basics' => http.Response(
sent(
InstanceBasics(
app: 'learn',
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/halkyon',
minimumAppBuild: 1,
signUp: signUp,
).toJson(),
),
200,
),
'halkyon_base.waiting/look' => http.Response(sent(waiting.toJson()), 200),
'halkyon_base.waiting/deleteAccount' => http.Response(
sent(waiting == WaitingState.waiting),
200,
),
'halkyon_base.waiting/forget' => http.Response('null', 200),
_ => http.Response('', 404),
};
});
@ -57,9 +103,26 @@ final class _Instance() {
).toJson();
}
/// A setup for a sign-up as a test sends it: none of it is read.
PasswordSetup _setup() => PasswordSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
verifier: ByteData(384),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
recoveryProofKey: ByteData(32),
);
void main() {
late _Instance instance;
late BaseClient app;
late MemoryAccountWaitStore waits;
late ServerpodAuthGateway gateway;
setUp(() async {
@ -73,7 +136,8 @@ void main() {
storage: _MemoryStorage(),
);
await app.auth.initialize();
gateway = ServerpodAuthGateway(app, base: app.modules);
waits = MemoryAccountWaitStore();
gateway = ServerpodAuthGateway(app, base: app.modules, waits: waits);
addTearDown(() async {
await gateway.dispose();
app.close();
@ -119,26 +183,7 @@ void main() {
);
final grant = await passwords.signUp(
PasswordSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
verifier: ByteData(384),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryProofKey: ByteData(32),
),
_setup(),
request: '0191f0c4-3333-7000-8000-000000000003',
code: 'K7QXM2PD',
handle: 'ben',
@ -160,4 +205,146 @@ void main() {
isEmpty,
);
});
group("waiting for the operator (Halkyon Learn's ADR 0021, D31)", () {
const ben = AccountWait(
pass: 'ben-pass',
words: ['abandon', 'ability', 'able', 'about'],
handle: 'ben',
);
test('a sign-up the instance lets wait hands the device a pass in '
'place of a session', () async {
instance.signUpWaits = true;
final passwords = ServerpodPasswordGateway(
app,
base: app.modules,
httpClient: instance.client,
);
await expectLater(
passwords.signUp(
_setup(),
request: '0191f0c4-3333-7000-8000-000000000003',
code: 'K7QXM2PD',
handle: 'ben',
),
throwsA(isA<AccountWaits>().having((w) => w.wait, 'wait', ben)),
);
expect(
gateway.status,
const SignedOut(),
reason:
'kept only by the '
'repository, which hands it to the gateway',
);
});
test('keeps the wait across starts, on the device alone, and tells it '
'as the status until a session takes its place', () async {
final changes = <AuthStatus>[];
gateway.statusChanges.listen(changes.add);
await gateway.wait(ben);
expect(gateway.status, const Waiting(ben));
expect(waits.kept, ben);
final again = ServerpodAuthGateway(app, base: app.modules, waits: waits);
addTearDown(again.dispose);
expect(again.status, const SignedOut(), reason: 'not yet read');
await again.initialize();
expect(again.status, const Waiting(ben));
await gateway.useSession(
SessionGrant(
'${_Instance.ben}',
AuthSuccess.fromJson(
_Instance._session(_Instance.ben, 'ben-token', identified: true),
),
),
);
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
);
expect(waits.kept, isNull);
expect(changes.first, const Waiting(ben));
});
test('asks where the wait stands with the pass alone, and keeps what '
'the instance says', () async {
await gateway.wait(ben);
expect(await gateway.lookWhileWaiting(), AccountWaitState.waiting);
instance.waiting = WaitingState.suspended;
expect(await gateway.lookWhileWaiting(), AccountWaitState.suspended);
expect(gateway.status, Waiting(ben.standing(AccountWaitState.suspended)));
instance.waiting = WaitingState.approved;
expect(await gateway.lookWhileWaiting(), AccountWaitState.approved);
expect(
waits.kept,
ben.standing(AccountWaitState.approved),
reason:
'the instance forgot the pass with that answer: the device '
'remembers it was approved',
);
final look = instance.calls.lastWhere(
(call) => call.call == 'halkyon_base.waiting/look',
);
expect(look.session, isNull);
expect(jsonDecode(instance.bodies[look.call]!), {'pass': 'ben-pass'});
});
test('a pass the instance knows no more ends the wait', () async {
await gateway.wait(ben);
instance.waiting = WaitingState.unknown;
expect(await gateway.lookWhileWaiting(), AccountWaitState.ended);
expect(gateway.status, Waiting(ben.standing(AccountWaitState.ended)));
});
test('deletes a new account with its pass and stops waiting; a '
'suspended one stays', () async {
await gateway.wait(ben);
instance.waiting = WaitingState.suspended;
expect(await gateway.deleteWaitingAccount(), isFalse);
expect(gateway.status, const Waiting(ben));
instance.waiting = WaitingState.waiting;
expect(await gateway.deleteWaitingAccount(), isTrue);
expect(gateway.status, const SignedOut());
expect(waits.kept, isNull);
});
test('stops waiting: the pass forgotten on the instance and here, but '
'not asked of an instance that forgot it already', () async {
await gateway.wait(ben);
await gateway.stopWaiting();
expect(gateway.status, const SignedOut());
expect(waits.kept, isNull);
expect(
instance.calls.where((c) => c.call == 'halkyon_base.waiting/forget'),
hasLength(1),
);
await gateway.wait(ben.standing(AccountWaitState.approved));
await gateway.stopWaiting();
expect(
instance.calls.where((c) => c.call == 'halkyon_base.waiting/forget'),
hasLength(1),
);
});
test('says who may sign up, as the basics do', () async {
expect(await gateway.signUps(), SignUps.open);
instance.signUp = SignUpMode.approval;
expect(await gateway.signUps(), SignUps.approval);
instance.signUp = SignUpMode.closed;
expect(await gateway.signUps(), SignUps.closed);
});
});
}