feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:clock/clock.dart';
|
2026-10-19 14:39:00 +02:00
|
|
|
import 'package:halkyon_base_server/base.dart';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
|
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
import '../support/fixtures.dart';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
withServerpod(
|
|
|
|
|
'Public profiles',
|
|
|
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
|
|
|
(sessionBuilder, endpoints) {
|
|
|
|
|
late Session session;
|
|
|
|
|
|
2026-10-19 14:39:00 +02:00
|
|
|
setUp(() {
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
useBase(sessionBuilder);
|
2026-10-19 14:39:00 +02:00
|
|
|
session = sessionBuilder.build();
|
|
|
|
|
});
|
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>
2026-10-23 22:39:14 +02:00
|
|
|
tearDown(() async {
|
|
|
|
|
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
|
|
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.id.notEquals(null),
|
|
|
|
|
);
|
|
|
|
|
});
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
|
|
|
|
|
/// A person with an identity, signed in.
|
|
|
|
|
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
|
|
|
const Uuid().v7(),
|
|
|
|
|
{identified},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final alice = identifiedPerson();
|
|
|
|
|
final bob = identifiedPerson();
|
|
|
|
|
|
|
|
|
|
Future<Profile> save(
|
|
|
|
|
TestSessionBuilder person,
|
|
|
|
|
String handle, {
|
|
|
|
|
String displayName = '',
|
|
|
|
|
String bio = '',
|
|
|
|
|
List<String> links = const [],
|
|
|
|
|
}) => endpoints.profile.save(person, handle, displayName, bio, links);
|
|
|
|
|
|
|
|
|
|
Matcher refused(ProfileRefusal reason) => throwsA(
|
|
|
|
|
isA<ProfileRefusedException>().having(
|
|
|
|
|
(error) => error.reason,
|
|
|
|
|
'reason',
|
|
|
|
|
reason,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('a person with an identity makes a profile, found by its handle '
|
|
|
|
|
'whatever its case, at the account domain', () async {
|
|
|
|
|
final saved = await save(
|
|
|
|
|
alice,
|
|
|
|
|
'Raisondetredev',
|
|
|
|
|
displayName: ' Tizian ',
|
|
|
|
|
bio: 'Learning Japanese.',
|
|
|
|
|
links: [' https://halkyon.app ', ''],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(saved.handle, 'Raisondetredev');
|
|
|
|
|
expect(saved.address, startsWith('Raisondetredev@'));
|
|
|
|
|
expect(saved.displayName, 'Tizian');
|
|
|
|
|
expect(saved.links, ['https://halkyon.app']);
|
|
|
|
|
expect(saved.handleFixed, isFalse);
|
|
|
|
|
// Generated classes compare by identity: their JSON by value.
|
|
|
|
|
expect((await endpoints.profile.mine(alice))!.toJson(), saved.toJson());
|
|
|
|
|
expect(
|
|
|
|
|
(await endpoints.profiles.byHandle(bob, 'raisondetredev')).toJson(),
|
|
|
|
|
saved.toJson(),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an anonymous person has no profile to make or read', () async {
|
|
|
|
|
final anonymous = sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
|
|
|
const Uuid().v7(),
|
|
|
|
|
{},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(anonymous, 'nobody'),
|
|
|
|
|
throwsA(isA<ServerpodInsufficientAccessException>()),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.mine(anonymous),
|
|
|
|
|
throwsA(isA<ServerpodInsufficientAccessException>()),
|
|
|
|
|
);
|
2026-10-12 19:07:42 +02:00
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.checkHandle(anonymous, 'nobody'),
|
|
|
|
|
throwsA(isA<ServerpodInsufficientAccessException>()),
|
|
|
|
|
);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
expect(await PublicProfile.db.count(session), 0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
test('a handle is checked as saving would check it: free, or your '
|
|
|
|
|
'own, whatever its case; else why not', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
|
|
|
|
|
Future<ProfileRefusal?> check(String handle) =>
|
|
|
|
|
endpoints.profile.checkHandle(bob, handle);
|
|
|
|
|
expect(await check('bob'), isNull);
|
|
|
|
|
expect(await check('ALICE'), ProfileRefusal.handleTaken);
|
|
|
|
|
expect(await check('a-b'), ProfileRefusal.handleInvalid);
|
|
|
|
|
expect(await check('Admin'), ProfileRefusal.handleReserved);
|
|
|
|
|
expect(
|
|
|
|
|
await endpoints.profile.checkHandle(alice, 'Alice'),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
// Checking makes nothing.
|
|
|
|
|
expect(await endpoints.profile.mine(bob), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
test('nobody signed in reads profiles', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profiles.byHandle(sessionBuilder, 'alice'),
|
|
|
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a handle someone else has is refused, whatever its case; their '
|
|
|
|
|
'profile stays theirs', () async {
|
|
|
|
|
await save(alice, 'alice', displayName: 'Alice');
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(bob, 'ALICE'),
|
|
|
|
|
refused(ProfileRefusal.handleTaken),
|
|
|
|
|
);
|
|
|
|
|
expect((await endpoints.profile.mine(alice))!.displayName, 'Alice');
|
|
|
|
|
expect(await endpoints.profile.mine(bob), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('handles are 1 to 30 letters, digits or underscores, not one '
|
|
|
|
|
'kept for the instance', () async {
|
|
|
|
|
for (final handle in ['', 'with space', 'é', 'a-b', 'a' * 31]) {
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, handle),
|
|
|
|
|
refused(ProfileRefusal.handleInvalid),
|
|
|
|
|
reason: handle,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'Admin'),
|
|
|
|
|
refused(ProfileRefusal.handleReserved),
|
|
|
|
|
);
|
|
|
|
|
expect((await save(alice, 'a_1' * 10)).handle, 'a_1' * 10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the name, bio and links stay within their bounds', () async {
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'alice', displayName: 'x' * 31),
|
|
|
|
|
refused(ProfileRefusal.displayNameTooLong),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'alice', bio: 'x' * 501),
|
|
|
|
|
refused(ProfileRefusal.bioTooLong),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'alice', links: List.filled(5, 'https://a.example')),
|
|
|
|
|
refused(ProfileRefusal.tooManyLinks),
|
|
|
|
|
);
|
|
|
|
|
for (final link in ['halkyon.app', 'ftp://a.example', 'https://']) {
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'alice', links: [link]),
|
|
|
|
|
refused(ProfileRefusal.linkInvalid),
|
|
|
|
|
reason: link,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the handle changes until the first publish; then it is fixed, '
|
|
|
|
|
'and the profile stays, though the rest still changes', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
expect((await save(alice, 'alice_jp')).handle, 'alice_jp');
|
|
|
|
|
|
|
|
|
|
final row = (await PublicProfile.db.findFirstRow(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.handleKey.equals('alice_jp'),
|
|
|
|
|
))!;
|
|
|
|
|
await PublicProfile.db.updateRow(
|
|
|
|
|
session,
|
|
|
|
|
row.copyWith(firstPublishedAt: clock.now().toUtc()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
save(alice, 'alice'),
|
|
|
|
|
refused(ProfileRefusal.published),
|
|
|
|
|
);
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(
|
|
|
|
|
await endpoints.profile.checkHandle(alice, 'alice'),
|
|
|
|
|
ProfileRefusal.published,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
await endpoints.profile.checkHandle(alice, 'ALICE_jp'),
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.remove(alice),
|
|
|
|
|
refused(ProfileRefusal.published),
|
|
|
|
|
);
|
|
|
|
|
final renamed = await save(alice, 'Alice_JP', displayName: 'Alice');
|
|
|
|
|
expect((renamed.handle, renamed.handleFixed), ('Alice_JP', true));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('removing a profile frees its handle', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
|
|
|
|
|
await endpoints.profile.remove(alice);
|
|
|
|
|
|
|
|
|
|
expect(await endpoints.profile.mine(alice), isNull);
|
|
|
|
|
expect((await save(bob, 'alice')).handle, 'alice');
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-23 22:39:14 +02:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
test("a handle nobody has isn't found", () async {
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profiles.byHandle(alice, 'nobody'),
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
throwsA(isA<ProfileNotFoundException>()),
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('deleting a person deletes their profile', () async {
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
useBase(sessionBuilder);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
final carol = (await AuthServices.instance.authUsers.create(session))
|
|
|
|
|
.id;
|
|
|
|
|
await endpoints.profile.save(
|
|
|
|
|
sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
|
|
|
carol.uuid,
|
|
|
|
|
{identified},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
'carol',
|
|
|
|
|
'',
|
|
|
|
|
'',
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await deletePerson(session, carol);
|
|
|
|
|
|
|
|
|
|
expect(await PublicProfile.db.count(session), 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('An avatar', () {
|
|
|
|
|
/// A PNG of one pixel, [shade] of grey.
|
|
|
|
|
ByteData png(int shade) {
|
|
|
|
|
final bytes = base64Decode(
|
|
|
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
|
|
|
);
|
|
|
|
|
// A byte past the signature, so each shade is another file.
|
|
|
|
|
bytes[bytes.length - 1] = shade;
|
|
|
|
|
return ByteData.sublistView(bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<int> stored() => CloudStorageEntry.db.count(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.storageId.equals('public'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tearDown(
|
|
|
|
|
() => CloudStorageEntry.db.deleteWhere(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.storageId.equals('public'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('is shown from a public URL that names the picture, not the '
|
|
|
|
|
'person', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
|
|
|
|
|
final profile = await endpoints.profile.setAvatar(alice, png(1));
|
|
|
|
|
|
|
|
|
|
final url = profile.avatarUrl!;
|
|
|
|
|
expect(url, contains('avatars'));
|
|
|
|
|
final row = (await PublicProfile.db.findFirstRow(
|
|
|
|
|
session,
|
|
|
|
|
where: (t) => t.handleKey.equals('alice'),
|
|
|
|
|
))!;
|
|
|
|
|
expect(url, isNot(contains(row.authUserId.toString())));
|
|
|
|
|
expect(await stored(), 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is a PNG of at most 1 MB, nothing else', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.setAvatar(
|
|
|
|
|
alice,
|
|
|
|
|
ByteData.sublistView(Uint8List.fromList(utf8.encode('GIF89a…'))),
|
|
|
|
|
),
|
|
|
|
|
refused(ProfileRefusal.avatarInvalid),
|
|
|
|
|
);
|
|
|
|
|
final huge = Uint8List(1024 * 1024 + 1)
|
|
|
|
|
..setRange(0, 8, png(1).buffer.asUint8List(0, 8));
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.setAvatar(alice, ByteData.sublistView(huge)),
|
|
|
|
|
refused(ProfileRefusal.avatarInvalid),
|
|
|
|
|
);
|
|
|
|
|
expect(await stored(), 0);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-23 22:39:14 +02:00
|
|
|
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>()),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
test('needs a profile to show on', () async {
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.profile.setAvatar(alice, png(1)),
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
throwsA(isA<ProfileNotFoundException>()),
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a new one replaces the old, whose file goes; one another '
|
|
|
|
|
'profile shows stays', () async {
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
await save(bob, 'bob');
|
|
|
|
|
await endpoints.profile.setAvatar(alice, png(1));
|
|
|
|
|
await endpoints.profile.setAvatar(bob, png(1));
|
|
|
|
|
|
|
|
|
|
await endpoints.profile.setAvatar(alice, png(2));
|
|
|
|
|
expect(await stored(), 2);
|
|
|
|
|
await endpoints.profile.setAvatar(bob, png(3));
|
|
|
|
|
expect(await stored(), 2);
|
|
|
|
|
|
|
|
|
|
final removed = await endpoints.profile.removeAvatar(alice);
|
|
|
|
|
expect(removed.avatarUrl, isNull);
|
|
|
|
|
expect(await stored(), 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('goes with its person, and with the profile', () async {
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
useBase(sessionBuilder);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
final carol = (await AuthServices.instance.authUsers.create(
|
|
|
|
|
session,
|
|
|
|
|
)).id;
|
|
|
|
|
final asCarol = sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
|
|
|
carol.uuid,
|
|
|
|
|
{identified},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await save(asCarol, 'carol');
|
|
|
|
|
await endpoints.profile.setAvatar(asCarol, png(4));
|
|
|
|
|
await save(alice, 'alice');
|
|
|
|
|
await endpoints.profile.setAvatar(alice, png(5));
|
|
|
|
|
|
|
|
|
|
await deletePerson(session, carol);
|
|
|
|
|
expect(await stored(), 1);
|
|
|
|
|
await endpoints.profile.remove(alice);
|
|
|
|
|
expect(await stored(), 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|