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:
parent
87671208df
commit
630965d920
4 changed files with 2305 additions and 395 deletions
|
|
@ -184,7 +184,11 @@ void main() {
|
|||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
device = await PasswordDevice.make();
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
anna = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
handle: 'anna',
|
||||
);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
annasLaptop = await signInNewDevice(session, anna);
|
||||
final code = await mailedCode(
|
||||
|
|
@ -975,7 +979,11 @@ void main() {
|
|||
'code by mail asked for deleting, nothing less, nor one asked for '
|
||||
'anything else',
|
||||
() async {
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final ben = await newPersonWithAddress(
|
||||
session,
|
||||
'ben@example.org',
|
||||
handle: 'ben',
|
||||
);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final bens = await PasswordDevice.make(seed: 7);
|
||||
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
anna = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
handle: 'anna',
|
||||
);
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
|
|
@ -353,10 +357,139 @@ void main() {
|
|||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
handle: 'ben',
|
||||
);
|
||||
expect(signedUp.authUserId, isNot(ben));
|
||||
});
|
||||
|
||||
group('for an account without a handle (ADR 0020, D28)', () {
|
||||
late UuidValue ben;
|
||||
late UuidValue bensPhone;
|
||||
|
||||
setUp(() async {
|
||||
ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
bensPhone = await signInNewDevice(
|
||||
session,
|
||||
ben,
|
||||
scopes: {identified},
|
||||
);
|
||||
});
|
||||
|
||||
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{identified},
|
||||
authId: bensPhone.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
Future<({UuidValue request, String code})> bensCode() => mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
|
||||
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
test('gives it the handle with its first password, its profile '
|
||||
'private', () async {
|
||||
expect(
|
||||
(await endpoints.account.signInWays(asBen())).handle,
|
||||
isFalse,
|
||||
);
|
||||
final code = await bensCode();
|
||||
|
||||
await endpoints.password.set(
|
||||
asBen(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
handle: 'Ben',
|
||||
);
|
||||
|
||||
final profile = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
))!;
|
||||
expect(profile.handle, 'Ben');
|
||||
expect(profile.displayName, isEmpty);
|
||||
expect(
|
||||
(await endpoints.account.signInWays(asBen())).handle,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused without a handle, and for one refused as saving a '
|
||||
'profile refuses it, before the code is used', () async {
|
||||
final code = await bensCode();
|
||||
Future<void> set(String? handle) => endpoints.password.set(
|
||||
asBen(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
handle: handle,
|
||||
);
|
||||
|
||||
for (final (handle, reason) in [
|
||||
(null, ProfileRefusal.handleInvalid),
|
||||
('ANNA', ProfileRefusal.handleTaken),
|
||||
('b-e-n', ProfileRefusal.handleInvalid),
|
||||
('admin', ProfileRefusal.handleReserved),
|
||||
]) {
|
||||
await expectLater(
|
||||
set(handle),
|
||||
refusedAsProfile(reason),
|
||||
reason: '$handle',
|
||||
);
|
||||
}
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await PublicProfile.db.count(session), 1);
|
||||
|
||||
await set('ben');
|
||||
expect(
|
||||
await AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused a handle for an account that has one, before the '
|
||||
'code is used', () async {
|
||||
final code = await ownAddressCode();
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
handle: 'anna_two',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.handleInvalid),
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
expect(
|
||||
(await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.handle,
|
||||
'anna',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('for someone who signs in with GitHub alone', () {
|
||||
setUp(() async {
|
||||
// Anna has no address here: GitHub is her only way in.
|
||||
|
|
@ -1460,6 +1593,7 @@ void main() {
|
|||
setup: bensDevice.setup(),
|
||||
request: bensCode.request,
|
||||
code: bensCode.code,
|
||||
handle: 'ben',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
|
|
@ -1831,6 +1965,7 @@ void main() {
|
|||
setup: (await PasswordDevice.make(seed: 7)).setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
handle: 'ben',
|
||||
);
|
||||
final bens = await endpoints.authenticator.enrol(
|
||||
asBen,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
anna = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
handle: 'anna',
|
||||
);
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
|
|
|
|||
|
|
@ -74,16 +74,22 @@ void main() {
|
|||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
||||
Future<AuthSuccess> signUp(String address, {PasswordDevice? with_}) =>
|
||||
atNow(() async {
|
||||
final code = await mailedCode(address);
|
||||
return await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: (with_ ?? device).setup(),
|
||||
);
|
||||
});
|
||||
/// Signs up with [address], a code mailed there and [handle] (the
|
||||
/// address's part before the @ unless given).
|
||||
Future<AuthSuccess> signUp(
|
||||
String address, {
|
||||
PasswordDevice? with_,
|
||||
String? handle,
|
||||
}) => atNow(() async {
|
||||
final code = await mailedCode(address);
|
||||
return await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: (with_ ?? device).setup(),
|
||||
handle: handle ?? address.split('@').first,
|
||||
);
|
||||
});
|
||||
|
||||
/// Signs in as a device would, both steps, with a code by mail where
|
||||
/// [mailedTo] names its address.
|
||||
|
|
@ -196,7 +202,7 @@ void main() {
|
|||
final other = await PasswordDevice.make(seed: 99);
|
||||
|
||||
await expectLater(
|
||||
signUp('anna@example.org', with_: other),
|
||||
signUp('anna@example.org', with_: other, handle: 'anna_again'),
|
||||
refused(PasswordRefusal.passwordExists),
|
||||
);
|
||||
final after = (await AccountPassword.db.findFirstRow(
|
||||
|
|
@ -219,6 +225,7 @@ void main() {
|
|||
request: code.request,
|
||||
code: 'WRONGCOD',
|
||||
setup: device.setup(),
|
||||
handle: 'anna',
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
|
|
@ -231,6 +238,7 @@ void main() {
|
|||
request: code.request,
|
||||
code: code.code,
|
||||
setup: short,
|
||||
handle: 'anna',
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
|
|
@ -241,6 +249,7 @@ void main() {
|
|||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
handle: 'anna',
|
||||
),
|
||||
refused(PasswordRefusal.unavailable),
|
||||
);
|
||||
|
|
@ -249,6 +258,185 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a handle at sign-up (ADR 0020, D26, D29)', () {
|
||||
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
Future<PublicProfile?> profileOf(UuidValue person) =>
|
||||
PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
Future<ProfileRefusal?> check(UuidValue request, String handle) =>
|
||||
endpoints.passwordSignIn.checkHandle(
|
||||
notSignedIn(),
|
||||
request: request,
|
||||
handle: handle,
|
||||
);
|
||||
|
||||
test('gives a new account the handle, its profile private until its '
|
||||
'person goes public', () async {
|
||||
final anna = (await signUp(
|
||||
'anna@example.org',
|
||||
handle: 'Anna_M',
|
||||
)).authUserId;
|
||||
|
||||
final profile = (await profileOf(anna))!;
|
||||
expect(profile.handle, 'Anna_M');
|
||||
expect(profile.handleKey, 'anna_m');
|
||||
expect(profile.displayName, isEmpty);
|
||||
expect((await endpoints.account.signInWays(as(anna))).handle, isTrue);
|
||||
await expectLater(
|
||||
endpoints.profiles.byHandle(as(anna), 'anna_m'),
|
||||
throwsA(isA<ProfileNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('is checked for the code request while the code travels, as '
|
||||
'saving a profile checks it', () async {
|
||||
await signUp('anna@example.org', handle: 'anna');
|
||||
final ben = await mailedCode('ben@example.org');
|
||||
|
||||
expect(await check(ben.request, 'ben'), isNull);
|
||||
expect(await check(ben.request, 'ANNA'), ProfileRefusal.handleTaken);
|
||||
expect(await check(ben.request, 'a-b'), ProfileRefusal.handleInvalid);
|
||||
expect(
|
||||
await check(ben.request, 'admin'),
|
||||
ProfileRefusal.handleReserved,
|
||||
);
|
||||
// Checking used nothing: the code still signs Ben up.
|
||||
await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: ben.request,
|
||||
code: ben.code,
|
||||
setup: device.setup(),
|
||||
handle: 'ben',
|
||||
);
|
||||
});
|
||||
|
||||
test('is checked for no request but one for a sign-up waiting: '
|
||||
'none unknown, used, past its time or for anything else, nor '
|
||||
'where the instance has no password sign-in', () async {
|
||||
final unknown = const Uuid().v7obj();
|
||||
final used = await mailedCode('anna@example.org');
|
||||
await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: used.request,
|
||||
code: used.code,
|
||||
setup: device.setup(),
|
||||
handle: 'anna',
|
||||
);
|
||||
final other = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.finishSetup,
|
||||
);
|
||||
for (final request in [unknown, used.request, other.request]) {
|
||||
await expectLater(
|
||||
check(request, 'carla'),
|
||||
refusedAsEmail(EmailSignInRefusal.codeExpired),
|
||||
);
|
||||
}
|
||||
final late_ = await atNow(() => mailedCode('carla@example.org'));
|
||||
now = now.add(emailCodeLifetime + const Duration(seconds: 1));
|
||||
await expectLater(
|
||||
atNow(() => check(late_.request, 'carla')),
|
||||
refusedAsEmail(EmailSignInRefusal.codeExpired),
|
||||
);
|
||||
final waiting = await mailedCode('dora@example.org');
|
||||
usePasswordKeys(pod, null);
|
||||
await expectLater(
|
||||
check(waiting.request, 'dora'),
|
||||
refused(PasswordRefusal.unavailable),
|
||||
);
|
||||
});
|
||||
|
||||
test('answers at most 30 an hour for a request, signing up '
|
||||
'counted with them', () async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
for (var i = 0; i < 29; i++) {
|
||||
await check(code.request, 'anna$i');
|
||||
}
|
||||
await check(code.request, 'anna');
|
||||
|
||||
await expectLater(
|
||||
check(code.request, 'anna'),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
handle: 'anna',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), 0);
|
||||
// Another request asks on.
|
||||
final ben = await mailedCode('ben@example.org');
|
||||
expect(await check(ben.request, 'ben'), isNull);
|
||||
});
|
||||
|
||||
test('refused as saving a profile refuses it, makes nobody and leaves '
|
||||
'the code unused', () async {
|
||||
await signUp('anna@example.org', handle: 'anna');
|
||||
final ben = await mailedCode('ben@example.org');
|
||||
for (final (handle, reason) in [
|
||||
('Anna', ProfileRefusal.handleTaken),
|
||||
('b e n', ProfileRefusal.handleInvalid),
|
||||
('', ProfileRefusal.handleInvalid),
|
||||
('admin', ProfileRefusal.handleReserved),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: ben.request,
|
||||
code: ben.code,
|
||||
setup: device.setup(),
|
||||
handle: handle,
|
||||
),
|
||||
refusedAsProfile(reason),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
expect(await AuthUser.db.count(session), 1);
|
||||
expect(await EmailIdentity.db.count(session), 1);
|
||||
|
||||
final signedUp = await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: ben.request,
|
||||
code: ben.code,
|
||||
setup: device.setup(),
|
||||
handle: 'ben',
|
||||
);
|
||||
expect((await profileOf(signedUp.authUserId))!.handle, 'ben');
|
||||
});
|
||||
|
||||
test('gives an account with the address and no password its first '
|
||||
'handle, and leaves the handle of one that has one', () async {
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final carl = await newPersonWithAddress(
|
||||
session,
|
||||
'carl@example.org',
|
||||
handle: 'carl',
|
||||
);
|
||||
|
||||
await signUp('ben@example.org', handle: 'ben');
|
||||
await signUp('carl@example.org', handle: 'carl_new');
|
||||
|
||||
expect((await profileOf(ben))!.handle, 'ben');
|
||||
expect((await profileOf(carl))!.handle, 'carl');
|
||||
expect(await PublicProfile.db.count(session), 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('a setup never finished', () {
|
||||
late UuidValue anna;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue