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

@ -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');