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
2 changed files with 674 additions and 134 deletions

View file

@ -22,9 +22,13 @@ void main() {
useBase(sessionBuilder);
session = sessionBuilder.build();
});
tearDown(
() => PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0),
);
tearDown(() async {
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
});
/// A person with an identity, signed in.
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
@ -219,6 +223,100 @@ void main() {
expect((await save(bob, 'alice')).handle, 'alice');
});
test('a profile is found by its handle only once its person goes '
'public: with something published, or the profile filled in; '
'their own they see all along (D5)', () async {
Future<String?> found() async {
try {
return (await endpoints.profiles.byHandle(bob, 'alice')).handle;
} on ProfileNotFoundException {
return null;
}
}
await save(alice, 'Alice');
expect(await found(), isNull);
expect((await endpoints.profile.mine(alice))!.handle, 'Alice');
for (final (displayName, bio, links) in [
('Alice', '', const <String>[]),
('', 'Learning Japanese.', const <String>[]),
('', '', ['https://halkyon.app']),
]) {
await save(
alice,
'Alice',
displayName: displayName,
bio: bio,
links: links,
);
expect(await found(), 'Alice', reason: '$displayName$bio$links');
}
await save(alice, 'Alice');
expect(await found(), isNull);
final row = (await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals('alice'),
))!;
await PublicProfile.db.updateRow(
session,
row.copyWith(firstPublishedAt: clock.now().toUtc()),
);
expect(await found(), 'Alice');
});
group('Answers about handles', () {
Future<ProfileRefusal?> check(
TestSessionBuilder person,
String handle,
) => endpoints.profile.checkHandle(person, handle);
test('are at most 30 an hour for a person: checking a handle and '
'saving a new one count, a refused one neither', () async {
await save(alice, 'alice');
for (var i = 0; i < 29; i++) {
await check(bob, 'bob$i');
}
await save(bob, 'bob');
await expectLater(
check(bob, 'alice'),
refused(ProfileRefusal.tooManyHandleChecks),
);
await expectLater(
save(bob, 'alice'),
refused(ProfileRefusal.tooManyHandleChecks),
);
expect((await endpoints.profile.mine(bob))!.handle, 'bob');
// Saving their own handle again, whatever its case, answers nothing
// new, nor does checking it, or a handle that is none.
expect(
(await save(bob, 'Bob', displayName: 'Bob')).displayName,
'Bob',
);
expect(await check(bob, 'BOB'), isNull);
expect(await check(bob, 'a-b'), ProfileRefusal.handleInvalid);
expect(await check(bob, 'admin'), ProfileRefusal.handleReserved);
// Others ask on.
expect(await check(alice, 'carla'), isNull);
});
test('are at most 60 an hour from one network, whoever asks', () async {
for (var i = 0; i < 30; i++) {
await check(alice, 'carla$i');
}
for (var i = 0; i < 30; i++) {
await check(bob, 'dora$i');
}
await expectLater(
check(identifiedPerson(), 'emil'),
refused(ProfileRefusal.tooManyHandleChecks),
);
});
});
test("a handle nobody has isn't found", () async {
await expectLater(
endpoints.profiles.byHandle(alice, 'nobody'),
@ -306,6 +404,22 @@ void main() {
expect(await stored(), 0);
});
test('makes a profile public by itself (D5)', () async {
await save(alice, 'alice');
await endpoints.profile.setAvatar(alice, png(1));
expect(
(await endpoints.profiles.byHandle(bob, 'alice')).handle,
'alice',
);
await endpoints.profile.removeAvatar(alice);
await expectLater(
endpoints.profiles.byHandle(bob, 'alice'),
throwsA(isA<ProfileNotFoundException>()),
);
});
test('needs a profile to show on', () async {
await expectLater(
endpoints.profile.setAvatar(alice, png(1)),

View file

@ -222,6 +222,23 @@ void main() {
expect(page.html, isNot(contains('class="app"')));
});
test('is not found until its person goes public (D5)', () async {
useBase(sessionBuilder);
await endpoints.profile.save(alice, 'alice', '', '', const []);
final host = profilePageUrl(session.serverpod, 'alice').host;
expect(
(await profilePage(session, '@alice', host: host)).status,
404,
);
await save(alice, 'alice', const []);
expect(
(await profilePage(session, '@alice', host: host)).status,
200,
);
});
test(
'is not found for a handle nobody has, or anything else',
() async {