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
26 changed files with 2305 additions and 395 deletions

View file

@ -14,8 +14,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `signUp`: an account at the address a mailed code proves, with a password; a session | without a session |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one, and their first handle where they have none (Halkyon Learn's ADR 0020, D28); `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `checkHandle(request, handle)`: whether a sign-up could take a handle, while its mailed code waits (ADR 0020, D29); `signUp`: an account at the address a mailed code proves, with a password and a handle (an account there keeps its own where it has one); a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
@ -47,9 +47,10 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
avatar): until then its page and `profiles.byHandle` answer as for a handle nobody
has, while the host's own code still finds whose it is (`personWithHandle`). Every
answer about a new handle tells whether an account has it, so they are counted
together (`countHandleCheck`, on the register: checking one, saving one, signing up
or setting first keys with one, and the host's own lookups, such as Halkyon Learn's
blocks), at most 30 an hour per person (or attempt) and 60 per network
together (`countHandleCheck`, on the register: checking one, saving one, signing up,
setting a first password or first keys with one, and the host's own lookups, such as
Halkyon Learn's blocks), at most 30 an hour per person (or attempt, or code request)
and 60 per network
(`tooManyHandleChecks`); a person's own handle and what is no handle aren't
counted.
- SimpleLogin (Halkyon Learn's ADR 0020) is halkyon_oidc's sign-in (ADR 0023) with its

View file

@ -271,6 +271,19 @@ Future<String> proveAddressCode(
});
}
/// Whether a code mailed for [request], asked for [purpose], still waits to
/// be typed: neither used nor past its time. It proves nothing, as no code
/// is given: for what may be done while the code is on its way, as checking
/// a sign-up's handle (Halkyon Learn's ADR 0020, D29).
Future<bool> codeWaits(
Session session, {
required UuidValue request,
required EmailCodePurpose purpose,
}) async {
final asked = await _requestById(session, request, transaction: null);
return asked != null && asked.purpose == purpose && !_isExpired(asked);
}
/// Deletes the code requests past their time at [now], and returns how many:
/// hourly, with the rate limit's notes (`PruningFutureCall`).
Future<int> forgetExpiredEmailCodes(

View file

@ -12,6 +12,9 @@ fields:
### The account's keys, under a password that signs in (ADR 0017) or one
### that only opens them, as for SimpleLogin (ADR 0020, D19).
keys: bool, default=false
### A handle (ADR 0020, D5), private or public: an account of before
### without one is asked for it as it takes a password or keys (D28).
handle: bool, default=false
### A confirmed authenticator: the password signs in only with one.
authenticator: bool, default=false
### The account signs in with its password alone: it had a confirmed

View file

@ -21,6 +21,7 @@ abstract class SignInWays
bool? simpleLogin,
bool? password,
bool? keys,
bool? handle,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -30,6 +31,7 @@ abstract class SignInWays
}) : simpleLogin = simpleLogin ?? false,
password = password ?? false,
keys = keys ?? false,
handle = handle ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
secondFactorWorks = secondFactorWorks ?? false;
@ -40,6 +42,7 @@ abstract class SignInWays
bool? simpleLogin,
bool? password,
bool? keys,
bool? handle,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -61,6 +64,9 @@ abstract class SignInWays
keys: jsonSerialization['keys'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['keys']),
handle: jsonSerialization['handle'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['handle']),
authenticator: jsonSerialization['authenticator'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['authenticator']),
@ -107,6 +113,10 @@ abstract class SignInWays
/// that only opens them, as for SimpleLogin (ADR 0020, D19).
bool keys;
/// A handle (ADR 0020, D5), private or public: an account of before
/// without one is asked for it as it takes a password or keys (D28).
bool handle;
/// A confirmed authenticator: the password signs in only with one.
bool authenticator;
@ -142,6 +152,7 @@ abstract class SignInWays
bool? simpleLogin,
bool? password,
bool? keys,
bool? handle,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -158,6 +169,7 @@ abstract class SignInWays
'simpleLogin': simpleLogin,
'password': password,
'keys': keys,
'handle': handle,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
@ -178,6 +190,7 @@ abstract class SignInWays
'simpleLogin': simpleLogin,
'password': password,
'keys': keys,
'handle': handle,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
@ -204,6 +217,7 @@ class _SignInWaysImpl extends SignInWays {
bool? simpleLogin,
bool? password,
bool? keys,
bool? handle,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -216,6 +230,7 @@ class _SignInWaysImpl extends SignInWays {
simpleLogin: simpleLogin,
password: password,
keys: keys,
handle: handle,
authenticator: authenticator,
passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
@ -234,6 +249,7 @@ class _SignInWaysImpl extends SignInWays {
bool? simpleLogin,
bool? password,
bool? keys,
bool? handle,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -247,6 +263,7 @@ class _SignInWaysImpl extends SignInWays {
simpleLogin: simpleLogin ?? this.simpleLogin,
password: password ?? this.password,
keys: keys ?? this.keys,
handle: handle ?? this.handle,
authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,

View file

@ -806,6 +806,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String?>(),
nullable: true,
),
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
@ -817,6 +822,7 @@ class Endpoints extends _is.EndpointDispatch {
setup: params['setup'],
request: params['request'],
code: params['code'],
handle: params['handle'],
),
),
'beginChange': _is.MethodConnector(
@ -1016,6 +1022,33 @@ class Endpoints extends _is.EndpointDispatch {
language: params['language'],
),
),
'checkHandle': _is.MethodConnector(
name: 'checkHandle',
params: {
'request': _is.ParameterDescription(
name: 'request',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['passwordSignIn']
as _ik13m562.PasswordSignInEndpoint)
.checkHandle(
session,
request: params['request'],
handle: params['handle'],
),
),
'signUp': _is.MethodConnector(
name: 'signUp',
params: {
@ -1034,6 +1067,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<_ikhyee47.PasswordSetup>(),
nullable: false,
),
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
@ -1047,6 +1085,7 @@ class Endpoints extends _is.EndpointDispatch {
request: params['request'],
code: params['code'],
setup: params['setup'],
handle: params['handle'],
),
),
},

View file

@ -37,6 +37,7 @@ password:
passwordSignIn:
- begin:
- complete:
- checkHandle:
- signUp:
profile:
- mine:

View file

@ -4,6 +4,7 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/profile/first_handle.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin.dart';
import 'package:serverpod/serverpod.dart';
@ -76,14 +77,7 @@ Future<void> setFirstKeys(
0) {
throw refusedFor(PasswordRefusal.passwordExists);
}
// SELECT count(*) FROM "public_profile" WHERE "authUserId" = $1
final hasHandle =
await PublicProfile.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0;
if (hasHandle == (handle != null)) {
if (await hasHandle(session, person) == (handle != null)) {
throw ProfileRefusedException(reason: ProfileRefusal.handleInvalid);
}
try {

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
@ -25,13 +26,16 @@ class PasswordEndpoint extends Endpoint {
/// (`EmailCodePurpose.firstPassword`), else the one they add
/// (`EmailCodePurpose.signUp`); someone who signs in with GitHub alone
/// does it within [gitHubSignInFreshFor] of signing in with GitHub on this
/// session. Refusals as `setPassword`'s. It signs in once an authenticator
/// is confirmed (`authenticator.confirm`).
/// session. [handle] where the caller has none (`account.signInWays` says
/// `handle`; ADR 0020, D28), null otherwise, counted as `profile.checkHandle`
/// counts it. Refusals as `setPassword`'s. It signs in once an
/// authenticator is confirmed (`authenticator.confirm`).
Future<void> set(
Session session, {
required PasswordSetup setup,
UuidValue? request,
String? code,
String? handle,
}) async {
final person = session.authenticated!.authUserId;
await AccountRegister.of(session.serverpod).setPassword(
@ -40,6 +44,8 @@ class PasswordEndpoint extends Endpoint {
setup: setup,
request: request,
code: code,
handle: handle,
network: networkOf(session),
freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
keepSession: switch (session.authenticated!.authId) {
final id when Uuid.isValidUUID(fromString: id) =>

View file

@ -95,21 +95,50 @@ class PasswordSignInEndpoint extends Endpoint {
);
}
/// Whether someone signing up with the code mailed for [request]
/// (`emailCode.sendCode`, for `EmailCodePurpose.signUp`) could take
/// [handle], while the code is on its way (ADR 0020, D29): null where they
/// could, else why not, as saving a profile would say. For the app to say
/// so while it is typed; [signUp] checks again. At most 30 for a request
/// and 60 from a network in an hour, with every other answer about a
/// handle (`ProfileRefusal.tooManyHandleChecks`, thrown): each tells which
/// handles exist. Refused (`EmailSignInRefusal.codeExpired`) where no such
/// code waits, and as `checkSignUpHandle` refuses.
Future<ProfileRefusal?> checkHandle(
Session session, {
required UuidValue request,
required String handle,
}) => AccountRegister.of(session.serverpod).checkSignUpHandle(
session,
request: request,
handle: handle,
network: networkOf(session),
);
/// Signs up with the address a [code] mailed for [request]
/// (`emailCode.sendCode`) proves and the password [setup] describes (all
/// of it made on the device): a new account, or the account with that
/// address that has no password yet. Signed in, the device sets up an
/// authenticator next, before the password signs in elsewhere. Refusals as
/// `signUpWithCode`'s.
/// (`emailCode.sendCode`) proves, the password [setup] describes (all of
/// it made on the device) and [handle] (ADR 0020, D26): a new account,
/// whose profile stays private until its person goes public, or the
/// account with that address that has no password yet, which keeps a
/// handle it has. Signed in, the device sets up an authenticator next,
/// before the password signs in elsewhere. Refusals as `signUpWithCode`'s:
/// a handle refused (`ProfileRefusedException`, counted as [checkHandle]
/// counts it) before the code is used.
Future<AuthSuccess> signUp(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
required String handle,
}) async {
final account = await AccountRegister.of(
session.serverpod,
).signUpWithCode(session, request: request, code: code, setup: setup);
final account = await AccountRegister.of(session.serverpod).signUpWithCode(
session,
request: request,
code: code,
setup: setup,
handle: handle,
network: networkOf(session),
);
return await issueSession(session, account, method: passwordMethod);
}
}

View file

@ -14,6 +14,7 @@ import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:halkyon_base_server/src/profile/first_handle.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_base_server/src/srp/srp_group.dart';
@ -429,7 +430,9 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// ([EmailCodePurpose.signUp]). Anyone else has no way in left to prove (a
/// GitHub ID forgotten at its end, the full reading's M1, or an anonymous
/// account left from before, ADR 0020, D25), and a session of theirs alone
/// would make the account a thief's.
/// would make the account a thief's. An account without a handle takes
/// [handle] with it (ADR 0020, D28), its profile private until its person
/// goes public; one with a handle takes none.
///
/// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, without an address, for an address
@ -439,15 +442,20 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
/// before any code is used), and for someone without a way in
/// ([PasswordRefusal.wrongCredentials], before any code is used); a
/// code's refusals as `proveAddressCode`'s, and
/// another address than the account's as email sign-in refuses it
/// ([EmailSignInRefusedException]). Nothing changes then.
/// handle missing where the account has none, or given where it has one
/// ([ProfileRefusal.handleInvalid]), and one refused as saving a profile
/// refuses it, counted as checking it is, from [network]
/// ([ProfileRefusedException], before any code is used); a code's refusals
/// as `proveAddressCode`'s, and another address than the account's as email
/// sign-in refuses it ([EmailSignInRefusedException]). Nothing changes then.
Future<void> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
String? handle,
required String network,
bool freshGitHubSignIn = false,
UuidValue? keepSession,
}) async {
@ -460,22 +468,10 @@ Future<void> setPassword(
session,
where: (t) => t.authUserId.equals(person),
);
final String? proven;
if (own != null) {
if (request == null || code == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
proven = await proveAddressCode(
session,
request: request,
code: code,
purpose: EmailCodePurpose.firstPassword,
);
if (proven != own.addressHash) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
} else {
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
@ -491,6 +487,35 @@ Future<void> setPassword(
if (!gitHub) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
}
if (await hasHandle(session, person) == (handle != null)) {
throw ProfileRefusedException(reason: ProfileRefusal.handleInvalid);
}
if (handle != null) {
if (await refusalOfNewHandle(
session,
handle,
asker: person.uuid,
network: network,
)
case final reason?) {
throw ProfileRefusedException(reason: reason);
}
}
final String? proven;
if (own != null) {
proven = await proveAddressCode(
session,
request: request!,
code: code!,
purpose: EmailCodePurpose.firstPassword,
);
if (proven != own.addressHash) {
throw EmailSignInRefusedException(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
} else {
proven = request != null && code != null
? await proveAddressCode(
session,
@ -557,6 +582,15 @@ Future<void> setPassword(
transaction: transaction,
);
final now = clock.now().toUtc();
if (handle != null) {
await insertFirstProfile(
session,
person,
handle,
now: now,
transaction: transaction,
);
}
if (existing == null) {
// INSERT INTO "account_password" (…) VALUES (…)
await AccountPassword.db.insertRow(
@ -594,39 +628,66 @@ Future<void> setPassword(
await announceSessionsEnded(session, person);
}
} on DatabaseQueryException catch (error) {
// The same person set one at the same moment: the unique index refused
// the second (unique_violation).
// The same person set one at the same moment, or someone took the
// handle between the check and the write: a unique index refused it
// (unique_violation).
if (error.code == '23505') {
throw refusedFor(PasswordRefusal.passwordExists);
throw _refusalOfUnique(error);
}
rethrow;
}
}
/// What a unique index's refusal of an account's first password means: the
/// handle taken meanwhile, or a password set meanwhile.
Exception _refusalOfUnique(DatabaseQueryException error) =>
error.constraintName == 'public_profile_handle_idx'
? ProfileRefusedException(reason: ProfileRefusal.handleTaken)
: refusedFor(PasswordRefusal.passwordExists);
/// Signs up with the address [code] proves for [request] (mailed with
/// `sendSignInCode`) and the password [setup] describes, without a session
/// (ADR 0017): an address nobody has becomes a new account, identified,
/// whose ID is returned; an account with the address and no password (one
/// that signed in with codes by mail; no keys exist, so nothing waits)
/// takes the password, keeping its ID and everything it has. Either way
/// the password signs in once an authenticator is confirmed, which the
/// session the caller issues sets up.
/// `sendSignInCode`), the password [setup] describes and [handle], without
/// a session (ADR 0017, ADR 0020's D26): an address nobody has becomes a
/// new account, identified, with the handle (its profile private until its
/// person goes public), whose ID is returned; an account with the address
/// and no password (one that signed in with codes by mail; no keys exist,
/// so nothing waits) takes the password, keeping its ID and everything it
/// has, and the handle where it has none (its own stays). Either way the
/// password signs in once an authenticator is confirmed, which the session
/// the caller issues sets up.
///
/// Refused ([PasswordRefusedException]) where the instance has no password
/// sign-in, for a setup out of range, and for an address whose account has
/// a password already ([PasswordRefusal.passwordExists]: it signs in with
/// it, or resets it); a code's refusals as `proveAddressCode`'s
/// it, or resets it); ([ProfileRefusedException]) for a handle refused as
/// saving a profile refuses it, counted as [checkSignUpHandle] counts it,
/// before the code is used, and taken between that check and the account
/// (the code used then); a code's refusals as `proveAddressCode`'s
/// ([EmailSignInRefusedException]). Nothing changes then.
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
required String handle,
required String network,
}) async {
requirePasswordKeys(session.serverpod);
if (!isPasswordSetup(setup)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
// Before the code is used, so one refused leaves it to type again; for an
// account with a handle of its own too, so that nothing tells whether
// the address has one.
if (await refusalOfNewHandle(
session,
handle,
asker: request.uuid,
network: network,
)
case final reason?) {
throw ProfileRefusedException(reason: reason);
}
final proven = await proveAddressCode(
session,
request: request,
@ -662,6 +723,13 @@ Future<UuidValue> signUpWithCode(
),
transaction: transaction,
);
await insertFirstProfile(
session,
person,
handle,
now: now,
transaction: transaction,
);
} else {
person = identity.authUserId;
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
@ -676,6 +744,15 @@ Future<UuidValue> signUpWithCode(
// An address's person is identified already, as email sign-in
// left them.
await grantIdentified(session, person, transaction: transaction);
if (!await hasHandle(session, person, transaction: transaction)) {
await insertFirstProfile(
session,
person,
handle,
now: now,
transaction: transaction,
);
}
}
// INSERT INTO "account_password" (…) VALUES (…)
await AccountPassword.db.insertRow(
@ -686,15 +763,48 @@ Future<UuidValue> signUpWithCode(
return person;
});
} on DatabaseQueryException catch (error) {
// The same address signed up at the same moment: the unique index
// refused the second (unique_violation).
// The same address signed up at the same moment, or someone took the
// handle between the check and the write: a unique index refused it
// (unique_violation).
if (error.code == '23505') {
throw refusedFor(PasswordRefusal.passwordExists);
throw _refusalOfUnique(error);
}
rethrow;
}
}
/// Whether someone signing up with the code mailed for [request]
/// ([EmailCodePurpose.signUp]) could take [handle], while the code is on its
/// way (ADR 0020, D29): null where they could, else why not, as saving a
/// profile would refuse it. For the app to say so while it is typed;
/// [signUpWithCode] checks again. Counted with every answer about a handle,
/// for the request and from [network] (`countHandleCheck`: 30 and 60 an
/// hour, [ProfileRefusal.tooManyHandleChecks]), as each tells which handles
/// exist. Refused ([PasswordRefusedException]) where the instance has no
/// password sign-in, and ([EmailSignInRefusal.codeExpired]) where no such
/// code waits: used, past its time or asked for something else.
Future<ProfileRefusal?> checkSignUpHandle(
Session session, {
required UuidValue request,
required String handle,
required String network,
}) async {
requirePasswordKeys(session.serverpod);
if (!await codeWaits(
session,
request: request,
purpose: EmailCodePurpose.signUp,
)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.codeExpired);
}
return await refusalOfNewHandle(
session,
handle,
asker: request.uuid,
network: network,
);
}
/// Writes [password] (a change, or a reset keeping the keys) over [row]'s
/// password alone: the salt, the costs, the verifier and the keys wrapped
/// under it. Never the row's other columns, which [row] may hold as they

View file

@ -0,0 +1,73 @@
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/profile/handle_checks.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:serverpod/serverpod.dart';
// An account's first handle (ADR 0020, D5): every account has one, given as
// it is made (SimpleLogin's sign-up, a password's) or, for one of before
// without, with its first keys or password (D28).
/// Why [handle] can't be a new account's, nor the first of an account
/// without one, as saving a profile would refuse it; null where it can. A
/// handle at all is counted as an answer about it, for [asker] and from
/// [network] (`countHandleCheck`), apart from [transaction].
Future<ProfileRefusal?> refusalOfNewHandle(
Session session,
String handle, {
required String asker,
required String network,
Transaction? transaction,
}) async {
if (profileRefusal(handle: handle, displayName: '', bio: '', links: const [])
case final reason?) {
return reason;
}
await countHandleCheck(session, asker: asker, network: network);
// SELECT count(*) FROM "public_profile" WHERE "handleKey" = $1
final taken = await PublicProfile.db.count(
session,
where: (t) => t.handleKey.equals(handle.toLowerCase()),
transaction: transaction,
);
return taken > 0 ? ProfileRefusal.handleTaken : null;
}
/// [person]'s first profile, with [handle] alone (checked before:
/// [refusalOfNewHandle]), made [now]: private until they go public.
Future<void> insertFirstProfile(
Session session,
UuidValue person,
String handle, {
required DateTime now,
required Transaction transaction,
}) async {
// INSERT INTO "public_profile" (…) VALUES (…)
await PublicProfile.db.insertRow(
session,
PublicProfile(
authUserId: person,
handle: handle,
handleKey: handle.toLowerCase(),
displayName: '',
bio: '',
links: const [],
verifiedLinks: const [],
updatedAt: now,
),
transaction: transaction,
);
}
/// Whether [person] has a handle: a profile, private or public.
Future<bool> hasHandle(
Session session,
UuidValue person, {
Transaction? transaction,
}) async =>
// SELECT count(*) FROM "public_profile" WHERE "authUserId" = $1
await PublicProfile.db.count(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
) >
0;

View file

@ -290,14 +290,18 @@ abstract interface class AccountRegister {
/// Gives [person] the password [setup] describes, with the address [code]
/// proves for [request] (their own where they have one), and for one who
/// signs in with GitHub alone a GitHub sign-in of theirs made recently
/// ([freshGitHubSignIn], of the session asking). Replacing a setup never finished ends every other session of the
/// account but [keepSession]. Refusals as `setPassword`'s.
/// ([freshGitHubSignIn], of the session asking); [handle] where they have
/// none, counted as a check from [network]. Replacing a setup never
/// finished ends every other session of the account but [keepSession].
/// Refusals as `setPassword`'s.
Future<void> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
String? handle,
required String network,
bool freshGitHubSignIn = false,
UuidValue? keepSession,
});
@ -328,14 +332,27 @@ abstract interface class AccountRegister {
required String language,
});
/// Signs up with the address [code] proves for [request] and the password
/// [setup] describes: the account, new or one with the address and no
/// password, for the caller's session. Refusals as `signUpWithCode`'s.
/// Whether someone signing up with the code mailed for [request] could
/// take [handle], the checks from [network] counted; null where they
/// could. Refusals as `checkSignUpHandle`'s.
Future<ProfileRefusal?> checkSignUpHandle(
Session session, {
required UuidValue request,
required String handle,
required String network,
});
/// Signs up with the address [code] proves for [request], the password
/// [setup] describes and [handle], counted as a check from [network]: the
/// account, new or one with the address and no password, for the caller's
/// session. Refusals as `signUpWithCode`'s.
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
required String handle,
required String network,
});
/// A new authenticator of [person]. Refusals as `enrolAuthenticator`'s.

View file

@ -340,6 +340,8 @@ Future<Map<String, Object?>?> _answer(
setup: arguments.model('setup', PasswordSetup.fromJson),
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
handle: arguments.optionalString('handle'),
network: arguments.string('network'),
// The member tells it of its own session (the fourth reading).
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
keepSession: arguments.optionalId('keepSession'),
@ -367,12 +369,22 @@ Future<Map<String, Object?>?> _answer(
language: arguments.string('language'),
);
return {'proof': proof.toJson()};
case RegisterOperation.checkSignUpHandle:
final refusal = await register.checkSignUpHandle(
session,
request: arguments.id('request'),
handle: arguments.string('handle'),
network: arguments.string('network'),
);
return {'refusal': refusal?.name};
case RegisterOperation.signUpWithCode:
final account = await register.signUpWithCode(
session,
request: arguments.id('request'),
code: arguments.string('code'),
setup: arguments.model('setup', PasswordSetup.fromJson),
handle: arguments.string('handle'),
network: arguments.string('network'),
);
return {'account': account.uuid};
case RegisterOperation.enrolAuthenticator:

View file

@ -20,6 +20,7 @@ import 'package:halkyon_base_server/src/password/password_change.dart'
as change;
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart' as passwords;
import 'package:halkyon_base_server/src/profile/first_handle.dart';
import 'package:halkyon_base_server/src/profile/handle_checks.dart';
import 'package:halkyon_base_server/src/profile/link_verification.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
@ -565,6 +566,7 @@ final class LocalRegister implements AccountRegister {
0,
password: password?.verifier != null,
keys: password != null,
handle: await hasHandle(session, person),
authenticator: await passwords.hasConfirmedAuthenticator(
session,
person,
@ -603,6 +605,8 @@ final class LocalRegister implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
String? handle,
required String network,
bool freshGitHubSignIn = false,
UuidValue? keepSession,
}) => passwords.setPassword(
@ -611,6 +615,8 @@ final class LocalRegister implements AccountRegister {
setup: setup,
request: request,
code: code,
handle: handle,
network: network,
freshGitHubSignIn: freshGitHubSignIn,
keepSession: keepSession,
);
@ -653,17 +659,34 @@ final class LocalRegister implements AccountRegister {
language: language,
);
@override
Future<ProfileRefusal?> checkSignUpHandle(
Session session, {
required UuidValue request,
required String handle,
required String network,
}) => passwords.checkSignUpHandle(
session,
request: request,
handle: handle,
network: network,
);
@override
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
required String handle,
required String network,
}) => passwords.signUpWithCode(
session,
request: request,
code: code,
setup: setup,
handle: handle,
network: network,
);
@override

View file

@ -51,6 +51,7 @@ abstract final class RegisterOperation {
static const setPassword = 'set-password';
static const beginPasswordSignIn = 'begin-password-sign-in';
static const completePasswordSignIn = 'complete-password-sign-in';
static const checkSignUpHandle = 'check-sign-up-handle';
static const signUpWithCode = 'sign-up-with-code';
static const enrolAuthenticator = 'enrol-authenticator';
static const confirmAuthenticator = 'confirm-authenticator';

View file

@ -507,6 +507,8 @@ final class RemoteRegister implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
String? handle,
required String network,
bool freshGitHubSignIn = false,
UuidValue? keepSession,
}) => _ask(RegisterOperation.setPassword, {
@ -514,6 +516,8 @@ final class RemoteRegister implements AccountRegister {
'setup': setup.toJson(),
'request': request?.uuid,
'code': code,
'handle': handle,
'network': network,
'freshGitHubSignIn': freshGitHubSignIn,
'keepSession': keepSession?.uuid,
});
@ -564,18 +568,37 @@ final class RemoteRegister implements AccountRegister {
PasswordProof.fromJson,
);
@override
Future<ProfileRefusal?> checkSignUpHandle(
Session session, {
required UuidValue request,
required String handle,
required String network,
}) async => switch ((await _ask(RegisterOperation.checkSignUpHandle, {
'request': request.uuid,
'handle': handle,
'network': network,
}))['refusal']) {
final String reason => ProfileRefusal.values.asNameMap()[reason],
_ => null,
};
@override
Future<UuidValue> signUpWithCode(
Session session, {
required UuidValue request,
required String code,
required PasswordSetup setup,
required String handle,
required String network,
}) async =>
_id(
(await _ask(RegisterOperation.signUpWithCode, {
'request': request.uuid,
'code': code,
'setup': setup.toJson(),
'handle': handle,
'network': network,
}))['account'],
) ??
(throw RegisterUnavailableException(

View file

@ -5,8 +5,7 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/profile/handle_checks.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/profile/first_handle.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
@ -203,31 +202,6 @@ Future<ProfileRefusal?> checkSimpleLoginHandle(
);
}
/// Why [handle] can't be a new account's, nor the first of an account
/// without one, as saving a profile would refuse it; null where it can. A
/// handle at all is counted as an answer about it, for [asker] and from
/// [network] (`countHandleCheck`), apart from [transaction].
Future<ProfileRefusal?> refusalOfNewHandle(
Session session,
String handle, {
required String asker,
required String network,
Transaction? transaction,
}) async {
if (profileRefusal(handle: handle, displayName: '', bio: '', links: const [])
case final reason?) {
return reason;
}
await countHandleCheck(session, asker: asker, network: network);
// SELECT count(*) FROM "public_profile" WHERE "handleKey" = $1
final taken = await PublicProfile.db.count(
session,
where: (t) => t.handleKey.equals(handle.toLowerCase()),
transaction: transaction,
);
return taken > 0 ? ProfileRefusal.handleTaken : null;
}
/// Signs up someone new whose identity waits under [attempt] (D18): in one
/// transaction, the identity taken, an account made, identified, with
/// [handle] (its profile private until they go public) and the keys [setup]
@ -306,32 +280,6 @@ Future<UuidValue> signUpWithSimpleLogin(
}
}
/// [person]'s first profile, with [handle] alone (checked before:
/// [refusalOfNewHandle]), made [now]: private until they go public.
Future<void> insertFirstProfile(
Session session,
UuidValue person,
String handle, {
required DateTime now,
required Transaction transaction,
}) async {
// INSERT INTO "public_profile" (…) VALUES (…)
await PublicProfile.db.insertRow(
session,
PublicProfile(
authUserId: person,
handle: handle,
handleKey: handle.toLowerCase(),
displayName: '',
bio: '',
links: const [],
verifiedLinks: const [],
updatedAt: now,
),
transaction: transaction,
);
}
/// Refused ([OidcRefusal.unknownAttempt]) unless [person] signed in with
/// SimpleLogin under [attempt] in the last [confirmationLasts]
/// ([completeSimpleLogin] keeps it): D20's second factor, checked before a

View file

@ -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(

View file

@ -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,

View file

@ -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,

Some files were not shown because too many files have changed in this diff Show more