feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)

A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.

So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.

Halkyon Learn's handle dialog words the new refusal (English, German).

Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 22:39:14 +02:00
commit 7eb1961b9f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
37 changed files with 674 additions and 134 deletions

File diff suppressed because one or more lines are too long

View file

@ -719,7 +719,9 @@ class EndpointInbox extends _isc.EndpointRef {
/// Keeps the person with [handle] from writing on the caller's published
/// studysets: what they wrote there, and write, shows to them alone.
/// Throws [NotFoundException] for a handle nobody has.
/// Throws [NotFoundException] for a handle nobody has; at most 30 an hour
/// with the caller's other answers about handles, as checking one
/// (`ProfileRefusal.tooManyHandleChecks`).
_ida.Future<void> block(String handle) => caller.callServerEndpoint<void>(
'inbox',
'block',
@ -727,7 +729,7 @@ class EndpointInbox extends _isc.EndpointRef {
);
/// Lets the person with [handle] write on the caller's published
/// studysets again.
/// studysets again; counted as [block] is.
_ida.Future<void> unblock(String handle) => caller.callServerEndpoint<void>(
'inbox',
'unblock',

View file

@ -541,6 +541,7 @@
"profileProblemLinkInvalid": "Links beginnen mit https:// oder http://.",
"profileProblemAvatarInvalid": "Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.",
"profileProblemTooManyLinkChecks": "Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.",
"profileProblemTooManyHandleChecks": "Es wurden zu viele Handles ausprobiert. Versuche es in einer Stunde noch einmal.",
"publicProfilePicture": "Bild",
"publicProfileChoosePicture": "Bild wählen",
"publicProfileRemovePicture": "Bild entfernen",

View file

@ -612,6 +612,7 @@
"profileProblemLinkInvalid": "Links start with https:// or http://.",
"profileProblemAvatarInvalid": "This picture can't be used: choose a PNG, JPEG, WebP or GIF image.",
"profileProblemTooManyLinkChecks": "The links were checked too often. Try again in an hour.",
"profileProblemTooManyHandleChecks": "Too many handles were tried. Try again in an hour.",
"publicProfilePicture": "Picture",
"publicProfileChoosePicture": "Choose picture",
"publicProfileRemovePicture": "Remove picture",

View file

@ -3244,6 +3244,12 @@ abstract class AppLocalizations {
/// **'The links were checked too often. Try again in an hour.'**
String get profileProblemTooManyLinkChecks;
/// No description provided for @profileProblemTooManyHandleChecks.
///
/// In en, this message translates to:
/// **'Too many handles were tried. Try again in an hour.'**
String get profileProblemTooManyHandleChecks;
/// No description provided for @publicProfilePicture.
///
/// In en, this message translates to:

View file

@ -1963,6 +1963,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get profileProblemTooManyLinkChecks =>
'Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.';
@override
String get profileProblemTooManyHandleChecks =>
'Es wurden zu viele Handles ausprobiert. Versuche es in einer Stunde noch einmal.';
@override
String get publicProfilePicture => 'Bild';

View file

@ -1944,6 +1944,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get profileProblemTooManyLinkChecks =>
'The links were checked too often. Try again in an hour.';
@override
String get profileProblemTooManyHandleChecks =>
'Too many handles were tried. Try again in an hour.';
@override
String get publicProfilePicture => 'Picture';

View file

@ -110,7 +110,10 @@ enum ProfileProblem() {
avatarInvalid,
/// The links were checked too often in the last hour.
tooManyLinkChecks;
tooManyLinkChecks,
/// Too many handles were asked about in the last hour.
tooManyHandleChecks;
/// The problems with a handle.
static const handleProblems = {
@ -118,6 +121,7 @@ enum ProfileProblem() {
handleReserved,
handleTaken,
published,
tooManyHandleChecks,
};
}

View file

@ -679,20 +679,22 @@ class const _Picture({
}
}
String _problemText(AppLocalizations l10n, ProfileProblem problem) =>
switch (problem) {
ProfileProblem.handleInvalid => l10n.profileProblemHandleInvalid,
ProfileProblem.handleReserved => l10n.profileProblemHandleReserved,
ProfileProblem.handleTaken => l10n.profileProblemHandleTaken,
ProfileProblem.published => l10n.profileProblemPublished,
ProfileProblem.displayNameTooLong =>
l10n.profileProblemDisplayNameTooLong,
ProfileProblem.bioTooLong => l10n.profileProblemBioTooLong,
ProfileProblem.tooManyLinks => l10n.profileProblemTooManyLinks,
ProfileProblem.linkInvalid => l10n.profileProblemLinkInvalid,
ProfileProblem.avatarInvalid => l10n.profileProblemAvatarInvalid,
ProfileProblem.tooManyLinkChecks => l10n.profileProblemTooManyLinkChecks,
};
String _problemText(
AppLocalizations l10n,
ProfileProblem problem,
) => switch (problem) {
ProfileProblem.handleInvalid => l10n.profileProblemHandleInvalid,
ProfileProblem.handleReserved => l10n.profileProblemHandleReserved,
ProfileProblem.handleTaken => l10n.profileProblemHandleTaken,
ProfileProblem.published => l10n.profileProblemPublished,
ProfileProblem.displayNameTooLong => l10n.profileProblemDisplayNameTooLong,
ProfileProblem.bioTooLong => l10n.profileProblemBioTooLong,
ProfileProblem.tooManyLinks => l10n.profileProblemTooManyLinks,
ProfileProblem.linkInvalid => l10n.profileProblemLinkInvalid,
ProfileProblem.avatarInvalid => l10n.profileProblemAvatarInvalid,
ProfileProblem.tooManyLinkChecks => l10n.profileProblemTooManyLinkChecks,
ProfileProblem.tooManyHandleChecks => l10n.profileProblemTooManyHandleChecks,
};
/// The profile's page on the web, to link back to, with a way to copy its
/// address, and what linking back to it does (ADR 0011).

View file

@ -103,6 +103,27 @@ void main() {
expect(find.text('Saved'), findsOneWidget);
});
testWidgets('the dialog says when the instance answers no more about '
'handles this hour, and nothing can be chosen', (tester) async {
final profiles = FakeProfileGateway()..checksSpent = true;
await showProfile(tester, profiles);
await tester.tap(find.text('Choose a handle'));
await tester.pumpAndSettle();
await typeHandle(tester, 'raisondetredev');
expect(
find.text('Too many handles were tried. Try again in an hour.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Choose'))
.onPressed,
isNull,
);
});
testWidgets('a profile saves a field when it is left or Enter is pressed, '
'only what changed, without losing what is being written', (
tester,

View file

@ -39,13 +39,18 @@ class FakeProfileGateway({
/// The handles checked, in order.
final checked = <String>[];
/// Whether the instance answers no more about handles this hour.
bool checksSpent = false;
@override
Future<ProfileProblem?> checkHandle(String handle) async {
if (fail case final error?) {
throw error;
}
checked.add(handle);
return _problemOf(handle);
return checksSpent
? ProfileProblem.tooManyHandleChecks
: _problemOf(handle);
}
ProfileProblem? _problemOf(String handle) {

View file

@ -94,7 +94,9 @@ class InboxEndpoint extends Endpoint {
/// Keeps the person with [handle] from writing on the caller's published
/// studysets: what they wrote there, and write, shows to them alone.
/// Throws [NotFoundException] for a handle nobody has.
/// Throws [NotFoundException] for a handle nobody has; at most 30 an hour
/// with the caller's other answers about handles, as checking one
/// (`ProfileRefusal.tooManyHandleChecks`).
Future<void> block(Session session, String handle) async {
final author = session.authenticated!.authUserId;
final blocked = await _personOf(session, handle);
@ -119,7 +121,7 @@ class InboxEndpoint extends Endpoint {
}
/// Lets the person with [handle] write on the caller's published
/// studysets again.
/// studysets again; counted as [block] is.
Future<void> unblock(Session session, String handle) async {
final author = session.authenticated!.authUserId;
final blocked = await _personOf(session, handle);
@ -195,10 +197,19 @@ class InboxEndpoint extends Endpoint {
);
}
/// The person with [handle]; throws [NotFoundException] for none.
Future<UuidValue> _personOf(Session session, String handle) async =>
await personWithHandle(session, handle.trim()) ??
(throw NotFoundException(what: 'person'));
/// The person with [handle]; throws [NotFoundException] for none. Counted
/// with the caller's other answers about handles (`countHandleCheck`),
/// since whose a handle is tells that an account has it, while handles
/// stay hidden until their people go public (ADR 0020, D5).
Future<UuidValue> _personOf(Session session, String handle) async {
await countHandleCheck(
session,
asker: session.authenticated!.authUserId.uuid,
network: networkOf(session),
);
return await personWithHandle(session, handle.trim()) ??
(throw NotFoundException(what: 'person'));
}
/// Whether the thread's [payload] names the file [sha256] among those of
/// its proposed content (`files`).

View file

@ -261,6 +261,33 @@ void main() {
},
);
test('blocking and unblocking count among the answers about handles '
'(30 an hour), as whose a handle is tells that an account has '
'it', () async {
// Her handle, new as her profile was made, counted one.
for (var i = 1; i < handleChecksPerAsker; i++) {
await countHandleCheck(session, asker: alice.uuid, network: null);
}
for (final call in [
() => endpoints.inbox.block(signedIn(alice), 'bob'),
() => endpoints.inbox.unblock(signedIn(alice), 'bob'),
]) {
await expectLater(
call(),
throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
ProfileRefusal.tooManyHandleChecks,
),
),
);
}
expect(await endpoints.inbox.blockedPeople(signedIn(alice)), isEmpty);
await endpoints.inbox.block(signedIn(bob), 'alice');
});
test("a suggestion's files go to the author alone", () async {
final publication = await japanese();
final image = utf8.encode('an image');

View file

@ -52,6 +52,17 @@
decoy; mending ends every session of the account, as whoever came in through the old
link may still be in; and SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's
three keys, a server with a SimpleLogin app and without it stopping as it starts.
Then handles (9, D5): `/@handle` and `profiles.byHandle` answer as for a handle nobody
has until its person goes public (a handle fixed by a publish, or a display name, a
bio, a link or an avatar), the host's own code still finding whose a handle is.
Otherwise than proposed: not only asking whether a handle is free is limited, but
every answer about a new handle, counted together (`countHandleCheck`, 30 an hour per
person or attempt, 60 per network): saving a profile with one, signing up and the
first keys with one, and Halkyon Learn's blocks by handle, as each tells the same, so
a limit on checking alone would limit nothing; a person's own handle and what is no
handle aren't counted. The handle suggested at sign-up is not counted: it tells at
most that the name SimpleLogin shared is someone's handle already, once a sign-in.
Step 3's deviations accepted by the developer (23 Oct, night).
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
register), ADR 0017 (a password, TOTP and the keys).
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their

View file

@ -287,8 +287,8 @@ class EndpointKeys extends _isc.EndpointRef {
/// ten minutes on this device, under [attempt]. Refused for an account
/// with keys (`PasswordRefusal.passwordExists`), a setup out of range
/// (`PasswordRefusal.invalidSetup`), a handle refused or missing
/// (`ProfileRefusedException`), and without that sign-in
/// (`OidcRefusal.unknownAttempt`).
/// (`ProfileRefusedException`; a handle counted as `profile.checkHandle`
/// counts it), and without that sign-in (`OidcRefusal.unknownAttempt`).
_ida.Future<void> set({
required _isc.UuidValue attempt,
required _iec2fqr0.KeysSetup setup,
@ -790,7 +790,8 @@ class EndpointProfile extends _isc.EndpointRef {
/// Saves your public profile, making it if you have none. Throws
/// [ProfileRefusedException] when it can't be saved (see
/// [ProfileRefusal]): a handle someone else has, whatever its case, or a
/// new handle once yours is fixed.
/// new handle once yours is fixed. A new handle counts as [checkHandle]
/// counts it.
_ida.Future<_i8qdmv6n.Profile> save(
String handle,
String displayName,
@ -823,9 +824,13 @@ class EndpointProfile extends _isc.EndpointRef {
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
/// handle, reserved for the instance, someone else's whatever its case,
/// or a new one once yours is fixed; null when it can, as your own can. For
/// the app to say so while it is typed; saving checks again. Whose a handle
/// is, anyone can find out already ([ProfilesEndpoint.byHandle]), so this
/// tells nothing new.
/// the app to say so while it is typed; saving checks again. Handles stay
/// hidden until their people go public (Halkyon Learn's ADR 0020, D5), so
/// this tells that an account exists: at most 30 an hour for you and 60
/// from your network, with every other answer about a new handle (saving
/// one, signing up with one), refused beyond
/// ([ProfileRefusal.tooManyHandleChecks], thrown); your own handle and
/// what is no handle aren't counted.
_ida.Future<_ih722tf9.ProfileRefusal?> checkHandle(String handle) =>
caller.callServerEndpoint<_ih722tf9.ProfileRefusal?>(
'halkyon_base.profile',
@ -873,7 +878,10 @@ class EndpointProfiles extends _isc.EndpointRef {
String get name => 'halkyon_base.profiles';
/// The public profile with [handle], whatever its case. Throws
/// [ProfileNotFoundException] when there is none.
/// [ProfileNotFoundException] when there is none, and as well while its
/// person hasn't gone public (Halkyon Learn's ADR 0020, D5): published
/// nothing and left the rest of the profile empty (display name, bio,
/// links, avatar).
_ida.Future<_i8qdmv6n.Profile> byHandle(String handle) =>
caller.callServerEndpoint<_i8qdmv6n.Profile>(
'halkyon_base.profiles',
@ -931,9 +939,10 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
/// said `newcomer`), could take [handle]: 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 an attempt and 60 from a
/// network in an hour (`ProfileRefusal.tooManyHandleChecks`, thrown):
/// each answer tells which handles exist. Refused
/// (`OidcRefusal.unknownAttempt`) where no identity waits.
/// network in an hour, with every other answer about a handle
/// (`ProfileRefusal.tooManyHandleChecks`, thrown): each tells which
/// handles exist. Refused (`OidcRefusal.unknownAttempt`) where no identity
/// waits.
_ida.Future<_ih722tf9.ProfileRefusal?> checkHandle({
required _isc.UuidValue attempt,
required String handle,
@ -952,9 +961,10 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
/// keys [setup] describes, all made on the device beforehand; a session
/// for it here. Refused, the identity still waiting: for a setup out of
/// range (`PasswordRefusal.invalidSetup`) and a handle refused
/// (`ProfileRefusedException`, as saving a profile refuses it); for an
/// attempt unknown, taken or past its time (`OidcRefusal.unknownAttempt`),
/// and called signed in (`OidcRefusal.wrongCaller`).
/// (`ProfileRefusedException`, as saving a profile refuses it, the handle
/// counted as [checkHandle] counts it); for an attempt unknown, taken or
/// past its time (`OidcRefusal.unknownAttempt`), and called signed in
/// (`OidcRefusal.wrongCaller`).
_ida.Future<_iacc.AuthSuccess> signUp({
required _isc.UuidValue attempt,
required String handle,

View file

@ -45,8 +45,9 @@ enum ProfileRefusal implements _isc.SerializableModel {
/// The links were checked too often in the last hour.
tooManyLinkChecks,
/// The handles were checked too often in the last hour: their answers
/// tell which handles exist.
/// Too many answers about new handles in the last hour (checking them,
/// saving, signing up or blocking with them): each tells which handles
/// exist.
tooManyHandleChecks;
static ProfileRefusal fromJson(String name) {

View file

@ -26,8 +26,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
| `profile` | `mine`, `save`, `checkHandle`, `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
@ -44,7 +44,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
in the public storage at `avatars/<SHA-256>.png`. The page is at
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
served by `addProfilePageRoute`, in English or German, its foot what the host's
`profilePageNote` says.
`profilePageNote` says. A handle stays its person's alone until they go public
(Halkyon Learn's ADR 0020, D5: a handle fixed, or a display name, bio, link or
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
(`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
SimpleLogin preset, on the register alone, which holds the instance's SimpleLogin app
and exchanges every code; a member asks it to begin and to complete, and issues its own

View file

@ -55,6 +55,7 @@ export 'src/password/passwords.dart'
signInChallengeLifetime,
signsInWithPassword,
wrappedKeysLength;
export 'src/profile/handle_checks.dart';
export 'src/profile/link_verification.dart';
export 'src/profile/profile_page.dart' show addProfilePageRoute, profilePage;
export 'src/profile/profile_rules.dart'

View file

@ -45,8 +45,9 @@ enum ProfileRefusal implements _is.SerializableModel {
/// The links were checked too often in the last hour.
tooManyLinkChecks,
/// The handles were checked too often in the last hour: their answers
/// tell which handles exist.
/// Too many answers about new handles in the last hour (checking them,
/// saving, signing up or blocking with them): each tells which handles
/// exist.
tooManyHandleChecks;
static ProfileRefusal fromJson(String name) {

View file

@ -53,14 +53,16 @@ Future<DeviceKeys> unlockKeys(
/// ([PasswordRefusal.invalidSetup]) and an account with keys already, under
/// either kind of password ([PasswordRefusal.passwordExists]);
/// ([ProfileRefusedException]) for a handle refused as saving a profile
/// refuses it, and one missing where the account has none or given where it
/// has one ([ProfileRefusal.handleInvalid]); the sign-in's refusals as
/// refuses it (a handle counted as checking it is, from [network]), and one
/// missing where the account has none or given where it has one
/// ([ProfileRefusal.handleInvalid]); the sign-in's refusals as
/// [takeSimpleLoginConfirmation]'s. Nothing changes then.
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
required String network,
String? handle,
}) async {
if (!isKeysSetup(setup)) {
@ -87,7 +89,13 @@ Future<void> setFirstKeys(
try {
await session.db.transaction((transaction) async {
if (handle != null) {
if (await refusalOfNewHandle(session, handle, transaction: transaction)
if (await refusalOfNewHandle(
session,
handle,
asker: person.uuid,
network: network,
transaction: transaction,
)
case final reason?) {
throw ProfileRefusedException(reason: reason);
}

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