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: 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
|
|
|
tearDown(
|
|
|
|
|
() => PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// 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');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|