feat(base)!: a handle at password sign-up (ADR 0020, D26, D28–D30)

Every new account gets a handle, and an account without one gets its
first as it sets a password. The handle is private until its person
goes public (D5).

- `passwordSignIn.signUp` takes a handle. `passwordSignIn.checkHandle`
  says whether a sign-up could take one, only while that sign-up's
  mailed code waits (`codeExpired` otherwise), counted per code request
  (30 an hour) and per network (60) (D29). Members ask the register.
- `password.set` takes the first handle of an account without one
  (D28); `SignInWays.handle` tells the app whether there is one.
- Each checks the handle before the code is used, so a refusal leaves
  the code for another try. An existing account at the address keeps
  its own handle where it has one, and the typed handle is still
  checked, so the answer says nothing about the address.
- `first_handle.dart` holds what SimpleLogin, the keys and passwords
  share: `refusalOfNewHandle`, `insertFirstProfile`, `hasHandle`.
- halkyon_app: one `HandleField` with its `HandleCheckCubit` (asked
  300 ms after typing pauses, only about the handle typed last; the
  instance's suggestion is free without asking) (D30). The password
  setup asks for the handle between the phrase check and the code, and
  a refusal at the code sends back to it. SimpleLogin's sign-up and
  keys steps use the same field and now check as it is typed.
- The module's README and ADR 0020 (D29, D30, the built state) amended.

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-24 18:09:04 +02:00
commit 630965d920
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -237,7 +237,8 @@ void main() {
});
testWidgets('creates an account: the phrase written down and checked '
'before the code, then the authenticator', (tester) async {
'before the code, the handle chosen while it travels (ADR 0020, '
'D29), then the authenticator', (tester) async {
await start(tester);
await tester.tap(find.text('Create an account'));
await tester.pumpAndSettle();
@ -270,6 +271,28 @@ void main() {
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Continue');
expect(
find.textContaining('A code is on its way to ben@example.org.'),
findsOneWidget,
);
await tester.enterText(field('Handle'), 'admin');
await tester.pump();
expect(find.text('Checking…'), findsOneWidget);
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
expect(
find.text('This handle is kept for the instance.'),
findsOneWidget,
);
// A handle refused goes no further.
await tapButton(tester, 'Continue');
expect(field('Code from the email'), findsNothing);
await tester.enterText(field('Handle'), 'ben');
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
expect(find.text('@ben is free'), findsOneWidget);
await tapButton(tester, 'Continue');
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('ben@example.org'),
@ -287,6 +310,41 @@ void main() {
expect(ben.password, 'a new password');
expect(ben.finished, isTrue);
expect(dependencies.keys.kept.keys, [ben.person]);
expect(dependencies.passwordGateway.handles[ben.person], 'ben');
});
testWidgets('a handle taken while the code travels is asked again, the '
'code kept', (tester) async {
await start(tester);
await tester.tap(find.text('Create an account'));
await tester.pumpAndSettle();
await tester.enterText(field('Email address'), 'ben@example.org');
await tapButton(tester, 'Continue');
await tester.enterText(field('New password'), 'a new password');
await tester.enterText(field('New password again'), 'a new password');
await tapButton(tester, 'Continue');
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Continue');
await tester.enterText(field('Handle'), 'ben');
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
await tapButton(tester, 'Continue');
dependencies.passwordGateway.handles['someone-else'] = 'ben';
final code = dependencies.passwordGateway.codeFor('ben@example.org');
await tester.enterText(field('Code from the email'), code);
await tapButton(tester, 'Continue');
expect(find.text('Someone else has this handle.'), findsOneWidget);
await tester.enterText(field('Handle'), 'ben_k');
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
expect(find.text('Someone else has this handle.'), findsNothing);
await tapButton(tester, 'Continue');
await tester.enterText(field('Code from the email'), code);
await tapButton(tester, 'Continue');
expect(field('Code from your authenticator app'), findsOneWidget);
});
testWidgets('gets back in with the recovery phrase', (tester) async {
@ -526,6 +584,53 @@ void main() {
expect(find.text('Change password'), findsNothing);
});
/// Sets up a password for the GitHub account signed in, up to the step
/// after the phrase.
Future<void> setUpPassword(WidgetTester tester) async {
await show(tester);
await tester.tap(find.text('Set up a password'));
await tester.pumpAndSettle();
await tester.enterText(field('Email address'), 'gina@example.org');
await tapButton(tester, 'Continue');
await tester.enterText(field('New password'), 'a new password');
await tester.enterText(field('New password again'), 'a new password');
await tapButton(tester, 'Continue');
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Continue');
}
testWidgets('asks one signed in with GitHub without a handle for their '
'first with the password (ADR 0020, D28)', (tester) async {
await gateway.switchToGitHub();
final person = (gateway.status as SignedIn).authUserId;
await setUpPassword(tester);
await tester.enterText(field('Handle'), 'gina');
await tester.pump(HandleCheckCubit.pause);
await tester.pumpAndSettle();
expect(find.text('@gina is free'), findsOneWidget);
await tapButton(tester, 'Continue');
await tester.enterText(
field('Code from the email'),
dependencies.passwordGateway.codeFor('gina@example.org'),
);
await tapButton(tester, 'Continue');
expect(field('Code from your authenticator app'), findsOneWidget);
expect(dependencies.passwordGateway.handles[person], 'gina');
});
testWidgets('asks no handle of one who has one', (tester) async {
await gateway.switchToGitHub();
final person = (gateway.status as SignedIn).authUserId;
dependencies.passwordGateway.handles[person] = 'gina';
await setUpPassword(tester);
expect(field('Handle'), findsNothing);
expect(field('Code from the email'), findsOneWidget);
});
testWidgets('warns that a session was ended for its wrong proofs, until '
'dismissed', (tester) async {
final anna = dependencies.passwordGateway.addAccount(