feat(base)!: removing a profile makes it private (ADR 0020, D27)
Some checks failed
CI / check (push) Failing after 30m48s
CI / deploy (push) Has been skipped

Every account keeps a handle now, so removing a public profile no
longer deletes it and frees the handle: it takes away what the profile
shows and leaves it private, as a profile is until its person goes
public (D5).

- `profile.remove` and the register's `removeProfile` (members ask the
  register) empty the display name, bio, links with their checks and
  the avatar, keep the row with its handle, and return what is left,
  null for none. `/@handle` and `profiles.byHandle` then answer as for
  a handle nobody has. Once the person has published, removing is
  refused as before.
- Learn: the profile form starts afresh from what is left, "Remove
  profile" shows only while something is filled in, and the dialog says
  the handle stays.
- The module's README and ADR 0020 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:26:56 +02:00
commit d5651c5beb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 255 additions and 60 deletions

View file

@ -82,10 +82,12 @@ class ProfileEndpoint extends Endpoint {
Future<Profile> removeAvatar(Session session) =>
_register(session).removeAvatar(session, _caller(session));
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once your handle is fixed:
/// others refer to it.
Future<void> remove(Session session) =>
/// Removes what your public profile shows (display name, bio, links,
/// avatar), making it private again: the handle stays yours (Halkyon
/// Learn's ADR 0020, D27). Returns it; null when you have none. Throws
/// [ProfileRefusedException] once your handle is fixed: others refer to
/// it.
Future<Profile?> remove(Session session) =>
_register(session).removeProfile(session, _caller(session));
}

View file

@ -102,9 +102,11 @@ abstract interface class AccountRegister {
/// `ProfileNotFoundException` while they have no profile.
Future<Profile> removeAvatar(Session session, UuidValue person);
/// Removes [person]'s public profile, freeing its handle; nothing when they
/// have none. Throws `ProfileRefusedException` once their handle is fixed.
Future<void> removeProfile(Session session, UuidValue person);
/// Removes what [person]'s public profile shows (display name, bio, links,
/// avatar), making it private again, the handle kept theirs (Halkyon
/// Learn's ADR 0020, D27); returns it, null when they have none. Throws
/// `ProfileRefusedException` once their handle is fixed.
Future<Profile?> removeProfile(Session session, UuidValue person);
/// Fixes [person]'s handle, as others now refer to it (Halkyon Learn: their
/// first publish); nothing when it is fixed already or they have no

View file

@ -166,8 +166,11 @@ Future<Map<String, Object?>?> _answer(
);
return {'profile': profile.toJson()};
case RegisterOperation.removeProfile:
await register.removeProfile(session, arguments.id('person'));
return const {};
final profile = await register.removeProfile(
session,
arguments.id('person'),
);
return {'profile': profile?.toJson()};
case RegisterOperation.fixHandle:
await register.fixHandle(session, arguments.id('person'));
return const {};

View file

@ -318,22 +318,37 @@ final class LocalRegister implements AccountRegister {
}
@override
Future<void> removeProfile(Session session, UuidValue person) async {
Future<Profile?> removeProfile(Session session, UuidValue person) async {
final mine = await _rowOf(session, person);
if (mine == null) {
return;
return null;
}
if (mine.firstPublishedAt != null) {
throw ProfileRefusedException(reason: ProfileRefusal.published);
}
// DELETE FROM "public_profile" WHERE "authUserId" = $1
await PublicProfile.db.deleteWhere(
// UPDATE "public_profile" SET "displayName" = '', "bio" = '',
// "links" = '[]', "verifiedLinks" = NULL, "avatarHash" = NULL,
// "updatedAt" = $1 WHERE "id" = $2
final emptied = await PublicProfile.db.updateRow(
session,
where: (t) => t.authUserId.equals(person),
mine.copyWith(
displayName: '',
bio: '',
links: const [],
// Not the default: the generated copyWith tells an explicit null
// (clear it) from no argument (keep it), which the lint can't see.
// ignore: avoid_redundant_argument_values
verifiedLinks: null,
// As above.
// ignore: avoid_redundant_argument_values
avatarHash: null,
updatedAt: clock.now().toUtc(),
),
);
if (mine.avatarHash case final hash?) {
await forgetAvatar(session, hash);
}
return await profileViewOf(session, emptied);
}
@override

View file

@ -222,8 +222,12 @@ final class RemoteRegister implements AccountRegister {
)!;
@override
Future<void> removeProfile(Session session, UuidValue person) =>
_ask(RegisterOperation.removeProfile, {'person': person.uuid});
Future<Profile?> removeProfile(Session session, UuidValue person) async =>
_profile(
(await _ask(RegisterOperation.removeProfile, {
'person': person.uuid,
}))['profile'],
);
@override
Future<void> fixHandle(Session session, UuidValue person) =>