feat(base,app,learn): invitations to shared spaces by mail and by link

The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40).

An invitation by mail (sharedSpace.inviteByEmail) goes out through the
register (send-space-invitation), naming who invites by their handle
and nothing of what is shared, with a link to /join/<token>. The
instance keeps only the address's keyed hash, as for sign-in, and the
SHA-256 of the token. Whoever opens it joins with a code mailed to that
address (EmailCodePurpose.joinSpace, proven through the register,
prove-address-code), so a forwarded mail admits nobody else; a code
proving another address is refused. It lapses after 14 days, and the
hourly pruning forgets it; inviting the address again replaces it.

A space's own link (setJoinLink, the Owner's) admits whoever opens it
as a Viewer (joinByLink) while it is on; switched off and on, it gets a
new token. Its token is kept as it is, so that whoever may invite copies
the link again (joinLink). The page at /join/<token>, in English and
German, says who invites and as whom; preview tells the app the same.

A membership someone else made is new to its member until they mark it
seen (isNew, addedBy; mine names who shared it), for the Inbox.

halkyon_app learns the two new refusals (handleNeeded, otherAddress),
which Learn reports as not allowed until its screens offer these ways
in. The ADR records D37 to D40 and how the instance's half is built.

The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):

  ALTER TABLE "shared_space" ADD COLUMN "joinToken" text;
  CREATE UNIQUE INDEX "shared_space_join_token_idx"
    ON "shared_space" ("joinToken");
  CREATE TABLE "shared_space_email_invitation" (…), with its unique
    indexes on the space and address hash, and on the token's hash,
    and its foreign key to "shared_space" ON DELETE CASCADE;
  ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid;
  ALTER TABLE "shared_space_member"
    ADD COLUMN "isNew" boolean NOT NULL DEFAULT false;

Every space gets no token, and the unique index allows any number of
nulls; every membership gets no one who added it and isn't new, so
nothing already shared shows in an Inbox. The new table starts empty.
None of it rewrites a table, and the server before the switch names its
own columns, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 18:28:13 +01:00
commit 554e1604b4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 108488 additions and 42 deletions

View file

@ -41,6 +41,17 @@ final class _Mailbox implements CodeMailer {
required String language,
}) async {}
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async {}
/// The last code mailed to [address].
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
@ -73,6 +84,17 @@ final class _Refusing implements CodeMailer {
required DateTime until,
required String language,
}) async => throw const SocketException('Connection refused');
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {

View file

@ -32,6 +32,17 @@ final class _NoMail implements CodeMailer {
required DateTime until,
required String language,
}) async {}
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async {}
}
void main() {

View file

@ -229,6 +229,35 @@ final class _Register implements AccountRegister {
return request;
});
@override
Future<String> sendSpaceInvitation(
Session session, {
required String address,
required String language,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String network,
}) => _answer('sendSpaceInvitation', () {
networks.add(network);
return 'hash of $address';
});
@override
Future<String> proveAddressCode(
Session session, {
required UuidValue request,
required String code,
required EmailCodePurpose purpose,
}) => _answer('proveAddressCode', () {
final mailed = codes[request];
if (mailed == null || mailed.code != code) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode);
}
return 'hash of ${mailed.address}';
});
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
'signInWaysOf',

View file

@ -29,6 +29,17 @@ final _train = Uri.parse('http://train.test:8082');
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code})>[];
final invitations =
<
({
String to,
String inviter,
String app,
Uri link,
DateTime lapsesAt,
String language,
})
>[];
@override
Future<void> send(
@ -56,6 +67,24 @@ final class _Mailbox implements CodeMailer {
required String language,
}) async {}
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async => invitations.add((
to: to,
inviter: inviter,
app: app,
link: link,
lapsesAt: lapsesAt,
language: language,
));
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
@ -483,6 +512,56 @@ void main() {
expect(mailbox.sent, hasLength(11));
});
test("a member's invitation is mailed with its link, and a code "
'proves the address it went to, as the hash it keeps', () async {
final lapsesAt = DateTime.utc(2026, 11, 25, 12);
final hash = await remote.sendSpaceInvitation(
session,
address: 'Dana@example.org',
language: 'de',
inviter: 'Alice (@alice)',
app: 'Halkyon Todo',
link: Uri.parse('https://todo.example.org/join/abc'),
lapsesAt: lapsesAt,
network: '203.0.113.7',
);
expect(hash, addressHash(pod, 'dana@example.org'));
expect(mailbox.invitations.single, (
to: 'dana@example.org',
inviter: 'Alice (@alice)',
app: 'Halkyon Todo',
link: Uri.parse('https://todo.example.org/join/abc'),
lapsesAt: lapsesAt,
language: 'de',
));
final request = await remote.sendSignInCode(
session,
address: 'dana@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.joinSpace,
);
await expectLater(
remote.proveAddressCode(
session,
request: request,
code: 'AAAA-AAAA',
purpose: EmailCodePurpose.joinSpace,
),
refused(EmailSignInRefusal.wrongCode),
);
expect(
await remote.proveAddressCode(
session,
request: request,
code: mailbox.codeFor('dana@example.org'),
purpose: EmailCodePurpose.joinSpace,
),
hash,
);
});
test("a code to a member's person's address proves it for their "
'password, a wrong one refusing it, and someone without a way '
'in sets none, before the code is used', () async {

View file

@ -0,0 +1,488 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/mailbox.dart';
import '../test_tools/serverpod_test_tools.dart';
/// The sync engine as a host hands it over, in memory: each space's
/// members, and whether each may write.
final class _Engine implements SpaceGrants {
final spaces = <UuidValue, Map<UuidValue, bool>>{};
@override
Future<UuidValue> create(
Session session, {
required UuidValue owner,
required Transaction transaction,
}) async {
final space = const Uuid().v7obj();
spaces[space] = {owner: true};
return space;
}
@override
Future<void> grant(
Session session,
UuidValue space,
UuidValue person, {
required bool writes,
required Transaction transaction,
}) async => spaces[space]![person] = writes;
@override
Future<void> revoke(
Session session,
UuidValue space,
UuidValue person, {
required Transaction transaction,
}) async => spaces[space]?.remove(person);
@override
Future<void> delete(
Session session,
UuidValue space, {
required Transaction transaction,
}) async => spaces.remove(space);
}
/// Joining a shared space otherwise than by handle (ADR 0044, D37 to D40):
/// an invitation mailed to an address, which only whoever proves it takes,
/// the space's own link, and what is new to whom; every rule refused too.
void main() {
withServerpod('Invitations by mail and by link', (sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Engine engine;
late Mailbox mailbox;
late UuidValue alice;
late UuidValue bob;
late UuidValue carol;
late UuidValue dave;
/// A person with an account here and a public profile of [handle].
Future<UuidValue> person(String handle) async {
final id = (await AuthServices.instance.authUsers.create(session)).id;
await saveProfile(session, id, handle: handle, displayName: handle);
return id;
}
setUp(() async {
engine = _Engine();
useBase(sessionBuilder, spaces: engine);
session = sessionBuilder.build();
pod = session.serverpod;
mailbox = Mailbox();
EmailCodes.of(pod).mailer = mailbox;
alice = await person('alice');
bob = await person('bob');
carol = await person('carol');
dave = await person('dave');
});
tearDown(() => EmailCodes.of(pod).mailer = null);
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(person.uuid, {
identified,
}),
);
final api = endpoints.sharedSpace;
Matcher refused(SharedSpaceRefusal reason) => throwsA(
isA<SharedSpaceRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
isA<EmailSignInRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
final notFound = throwsA(isA<SharedSpaceNotFoundException>());
/// A space of Alice's with Bob an Editor and Carol a Viewer.
Future<UuidValue> shared() async {
final space = (await api.create(signedIn(alice), 'studyset')).id;
await api.invite(signedIn(alice), space, 'bob', SharedSpaceRole.editor);
await api.invite(signedIn(alice), space, 'carol', SharedSpaceRole.viewer);
return space;
}
/// The token of the link mailed last.
String mailedToken() => mailbox.invitations.last.link.pathSegments.last;
/// A code mailed to [address] to join, and its request.
Future<({UuidValue request, String code})> joinCode(
UuidValue person,
String address,
) async {
final request = await endpoints.emailCode.sendCode(
signedIn(person),
address: address,
language: 'en',
purpose: EmailCodePurpose.joinSpace,
);
return (request: request, code: mailbox.codeFor(address));
}
test('an invitation mails a link naming who invites, and the instance '
'keeps only the address hashed', () async {
final space = await shared();
final invited = await api.inviteByEmail(
signedIn(alice),
space,
' Dana@Example.org ',
SharedSpaceRole.editor,
'de',
);
final mail = mailbox.invitations.single;
expect(mail.to, 'dana@example.org');
expect(mail.inviter, 'alice (@alice)');
expect(mail.app, testAppName);
expect(mail.language, 'de');
expect(mail.link.path, startsWith('/join/'));
expect(invited.byEmail, isTrue);
expect(invited.handle, isNull);
expect(invited.invited, isTrue);
final row = (await SharedSpaceEmailInvitation.db.find(session)).single;
expect(row.addressHash, isNot(contains('example')));
expect(row.tokenHash, isNot(mailedToken()));
expect(
(await api.members(signedIn(carol), space)).where((m) => m.byEmail),
[
isA<SharedSpaceMemberInfo>()
.having((m) => m.role, 'role', SharedSpaceRole.editor)
.having((m) => m.displayName, 'name', ''),
],
);
});
test('only who may invite by handle invites by mail, never as the Owner, '
'and only with a handle to sign it', () async {
final space = await shared();
Future<void> invite(UuidValue who, [SharedSpaceRole? role]) =>
api.inviteByEmail(
signedIn(who),
space,
'dana@example.org',
role ?? SharedSpaceRole.viewer,
'en',
);
await expectLater(invite(bob), refused(SharedSpaceRefusal.notAllowed));
await expectLater(invite(dave), notFound);
await expectLater(
invite(alice, SharedSpaceRole.owner),
refused(SharedSpaceRefusal.ownerRole),
);
final nameless = (await AuthServices.instance.authUsers.create(
session,
)).id;
final theirs = (await api.create(signedIn(nameless), 'studyset')).id;
await expectLater(
api.inviteByEmail(
signedIn(nameless),
theirs,
'dana@example.org',
SharedSpaceRole.viewer,
'en',
),
refused(SharedSpaceRefusal.handleNeeded),
);
expect(mailbox.invitations, isEmpty);
expect(await SharedSpaceEmailInvitation.db.count(session), 0);
});
test('whoever proves the address joins as invited, and the invitation '
'goes', () async {
final space = await shared();
await api.inviteByEmail(
signedIn(alice),
space,
'dana@example.org',
SharedSpaceRole.editor,
'en',
);
final token = mailedToken();
final preview = await api.preview(signedIn(dave), token);
expect(preview.byEmail, isTrue);
expect(preview.kind, 'studyset');
expect(preview.role, SharedSpaceRole.editor);
expect(preview.inviterHandle, 'alice');
expect(preview.lapsesAt, isNotNull);
expect(preview.member, isFalse);
final code = await joinCode(dave, 'dana@example.org');
final joined = await api.acceptEmailInvitation(
signedIn(dave),
token,
code.request,
code.code,
);
expect(joined.id, space);
expect(joined.role, SharedSpaceRole.editor);
expect(engine.spaces[space]![dave], isTrue);
expect(await SharedSpaceEmailInvitation.db.count(session), 0);
await expectLater(api.preview(signedIn(dave), token), notFound);
// They took it themselves: nothing new to them.
expect(
(await api.mine(signedIn(dave))).single.isNew,
isFalse,
);
});
test('a code proving another address, or asked for something else, '
'admits nobody', () async {
final space = await shared();
await api.inviteByEmail(
signedIn(alice),
space,
'dana@example.org',
SharedSpaceRole.viewer,
'en',
);
final token = mailedToken();
final other = await joinCode(dave, 'dave@example.org');
await expectLater(
api.acceptEmailInvitation(
signedIn(dave),
token,
other.request,
other.code,
),
refused(SharedSpaceRefusal.otherAddress),
);
final signUp = await endpoints.emailCode.sendCode(
signedIn(dave),
address: 'dana@example.org',
language: 'en',
purpose: EmailCodePurpose.signUp,
);
await expectLater(
api.acceptEmailInvitation(
signedIn(dave),
token,
signUp,
mailbox.codeFor('dana@example.org'),
),
refusedAsEmail(EmailSignInRefusal.codeExpired),
);
final right = await joinCode(dave, 'dana@example.org');
await expectLater(
api.acceptEmailInvitation(signedIn(dave), token, right.request, 'X'),
refusedAsEmail(EmailSignInRefusal.wrongCode),
);
await expectLater(
api.acceptEmailInvitation(
signedIn(dave),
'no such token',
right.request,
right.code,
),
notFound,
);
expect(engine.spaces[space]!.containsKey(dave), isFalse);
expect(await SharedSpaceEmailInvitation.db.count(session), 1);
});
test('an invitation lapses after 14 days, and is forgotten', () async {
final space = await shared();
final mailedAt = DateTime.utc(2026, 11, 1, 12);
await withClock(
Clock.fixed(mailedAt),
() => api.inviteByEmail(
signedIn(alice),
space,
'dana@example.org',
SharedSpaceRole.viewer,
'en',
),
);
final token = mailedToken();
final code = await joinCode(dave, 'dana@example.org');
final lapsed = mailedAt.add(const Duration(days: 14, minutes: 1));
await withClock(Clock.fixed(lapsed), () async {
await expectLater(api.preview(signedIn(dave), token), notFound);
await expectLater(
api.acceptEmailInvitation(
signedIn(dave),
token,
code.request,
code.code,
),
notFound,
);
expect(
(await api.members(signedIn(alice), space)).where((m) => m.byEmail),
isEmpty,
);
});
expect(
await forgetLapsedSpaceInvitations(
session,
now: mailedAt.add(const Duration(days: 13)),
),
0,
);
expect(await forgetLapsedSpaceInvitations(session, now: lapsed), 1);
});
test('inviting the address again ends the link mailed before; the Owner '
'changes its role or takes it back', () async {
final space = await shared();
Future<SharedSpaceMemberInfo> invite() => api.inviteByEmail(
signedIn(alice),
space,
'dana@example.org',
SharedSpaceRole.viewer,
'en',
);
await invite();
final first = mailedToken();
final again = await invite();
await expectLater(api.preview(signedIn(dave), first), notFound);
expect(await SharedSpaceEmailInvitation.db.count(session), 1);
await expectLater(
api.setRole(signedIn(bob), space, again.id, SharedSpaceRole.editor),
refused(SharedSpaceRefusal.notAllowed),
);
await api.setRole(
signedIn(alice),
space,
again.id,
SharedSpaceRole.editor,
);
expect(
(await api.preview(signedIn(dave), mailedToken())).role,
SharedSpaceRole.editor,
);
await expectLater(
api.remove(signedIn(bob), space, again.id),
refused(SharedSpaceRefusal.notAllowed),
);
await api.remove(signedIn(alice), space, again.id);
await expectLater(api.preview(signedIn(dave), mailedToken()), notFound);
});
test('only the Owner switches the link; who may invite reads it', () async {
final space = await shared();
await expectLater(
api.setJoinLink(signedIn(bob), space, true),
refused(SharedSpaceRefusal.notAllowed),
);
await expectLater(api.setJoinLink(signedIn(dave), space, true), notFound);
expect(await api.joinLink(signedIn(alice), space), isNull);
final link = (await api.setJoinLink(signedIn(alice), space, true))!;
expect(Uri.parse(link).path, startsWith('/join/'));
expect(await api.joinLink(signedIn(alice), space), link);
await expectLater(
api.joinLink(signedIn(carol), space),
refused(SharedSpaceRefusal.notAllowed),
);
await expectLater(api.joinLink(signedIn(dave), space), notFound);
});
test('a link admits whoever opens it as a Viewer while it is on; switched '
'off, it never comes back', () async {
final space = await shared();
final token = Uri.parse(
(await api.setJoinLink(signedIn(alice), space, true))!,
).pathSegments.last;
final preview = await api.preview(signedIn(dave), token);
expect(preview.byEmail, isFalse);
expect(preview.role, SharedSpaceRole.viewer);
expect(preview.inviterHandle, 'alice');
expect(preview.lapsesAt, isNull);
expect((await api.preview(signedIn(bob), token)).member, isTrue);
final joined = await api.joinByLink(signedIn(dave), token);
expect(joined.role, SharedSpaceRole.viewer);
expect(engine.spaces[space]![dave], isFalse);
// A member keeps their role.
expect(
(await api.joinByLink(signedIn(bob), token)).role,
SharedSpaceRole.editor,
);
await api.setJoinLink(signedIn(alice), space, false);
await api.leave(signedIn(dave), space);
await expectLater(api.joinByLink(signedIn(dave), token), notFound);
await expectLater(api.preview(signedIn(dave), token), notFound);
await api.setJoinLink(signedIn(alice), space, true);
await expectLater(api.joinByLink(signedIn(dave), token), notFound);
});
test("a link's page says who invites and how to go on, and only that "
"a link that holds no more doesn't", () async {
final space = await shared();
await api.inviteByEmail(
signedIn(alice),
space,
'dana@example.org',
SharedSpaceRole.editor,
'en',
);
final mailed = await joinPage(
session,
mailedToken(),
acceptLanguage: 'de-DE,de;q=0.9,en;q=0.8',
);
expect(mailed.status, 200);
expect(mailed.html, contains('alice (@alice) möchte in $testAppName'));
expect(mailed.html, contains('Die Einladung gilt bis'));
expect(mailed.html, contains('noindex'));
final link = Uri.parse(
(await api.setJoinLink(signedIn(alice), space, true))!,
).pathSegments.last;
final linked = await joinPage(session, link);
expect(linked.status, 200);
expect(linked.html, contains('alice (@alice) wants to share'));
expect(linked.html, contains('you see what is shared'));
expect(linked.html, isNot(contains('The invitation holds')));
await api.setJoinLink(signedIn(alice), space, false);
for (final token in [link, 'no such token']) {
final over = await joinPage(session, token);
expect(over.status, 404);
expect(over.html, contains('This invitation is over'));
}
});
test('a space shared with someone is new to them until they have seen '
'it, with who shared it', () async {
final space = await shared();
expect((await api.mine(signedIn(alice))).single.isNew, isFalse);
final bobs = (await api.mine(signedIn(bob))).single;
expect(bobs.isNew, isTrue);
expect(bobs.sharedBy, 'alice');
// Seen by someone else, or by nobody in it, changes nothing for Bob.
await api.markSeen(signedIn(carol), space);
await api.markSeen(signedIn(dave), space);
expect((await api.mine(signedIn(bob))).single.isNew, isTrue);
await api.markSeen(signedIn(bob), space);
final seen = (await api.mine(signedIn(bob))).single;
expect(seen.isNew, isFalse);
expect(seen.sharedBy, isNull);
});
});
}

View file

@ -18,6 +18,10 @@ final class Mailbox implements CodeMailer {
/// The notices that codes wait (ADR 0017).
final codesWait = <({String to, DateTime until, String language})>[];
/// The invitations to shared spaces (ADR 0044, D37).
final invitations =
<({String to, String inviter, String app, Uri link, String language})>[];
/// Whether it takes no mail, as a mail service down.
bool refusing = false;
@ -67,6 +71,28 @@ final class Mailbox implements CodeMailer {
codesWait.add((to: to, until: until, language: language));
}
@override
Future<void> sendInvitation(
Session session, {
required String to,
required String inviter,
required String app,
required Uri link,
required DateTime lapsesAt,
required String language,
}) async {
if (refusing) {
throw Exception('Connection refused');
}
invitations.add((
to: to,
inviter: inviter,
app: app,
link: link,
language: language,
));
}
/// The last code mailed to [address].
String codeFor(String address) =>
codes.lastWhere((mail) => mail.to == address).code;

View file

@ -83,6 +83,8 @@ import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
as _idszjcpz;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_info.dart'
as _i582118y;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_join_preview.dart'
as _iwjsl5cu;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_key_need.dart'
as _ije0apa9;
import 'package:halkyon_base_server/src/generated/shared_space/shared_space_member_info.dart'
@ -2969,6 +2971,37 @@ class _SharedSpaceEndpoint {
});
}
_ida.Future<void> markSeen(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'markSeen',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'markSeen',
parameters: _ist.testObjectToJson({'space': space}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<_izxzwa6x.SharedSpaceMemberInfo>> members(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
@ -3037,6 +3070,210 @@ class _SharedSpaceEndpoint {
});
}
_ida.Future<_izxzwa6x.SharedSpaceMemberInfo> inviteByEmail(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
String address,
_i4jk9by9.SharedSpaceRole role,
String language,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'inviteByEmail',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'inviteByEmail',
parameters: _ist.testObjectToJson({
'space': space,
'address': address,
'role': role,
'language': language,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_izxzwa6x.SharedSpaceMemberInfo>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_iwjsl5cu.SharedSpaceJoinPreview> preview(
_ist.TestSessionBuilder sessionBuilder,
String token,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'preview',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'preview',
parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iwjsl5cu.SharedSpaceJoinPreview>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_i582118y.SharedSpaceInfo> acceptEmailInvitation(
_ist.TestSessionBuilder sessionBuilder,
String token,
_is.UuidValue request,
String code,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'acceptEmailInvitation',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'acceptEmailInvitation',
parameters: _ist.testObjectToJson({
'token': token,
'request': request,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_i582118y.SharedSpaceInfo>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<String?> joinLink(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'joinLink',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'joinLink',
parameters: _ist.testObjectToJson({'space': space}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<String?>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<String?> setJoinLink(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,
bool on,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'setJoinLink',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'setJoinLink',
parameters: _ist.testObjectToJson({
'space': space,
'on': on,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<String?>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_i582118y.SharedSpaceInfo> joinByLink(
_ist.TestSessionBuilder sessionBuilder,
String token,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'sharedSpace',
method: 'joinByLink',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'sharedSpace',
methodName: 'joinByLink',
parameters: _ist.testObjectToJson({'token': token}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_i582118y.SharedSpaceInfo>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> setRole(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue space,

View file

@ -130,6 +130,9 @@ void main() {
EmailCodePurpose.deletion: 'to delete your account',
EmailCodePurpose.firstPassword:
'to give your account on learn.halkyon.eu its first password',
EmailCodePurpose.joinSpace:
'to join, with your account on learn.halkyon.eu, what this '
'address was invited to',
};
const german = {
EmailCodePurpose.signUp: 'um dein Konto auf learn.halkyon.eu anzulegen',
@ -141,6 +144,9 @@ void main() {
EmailCodePurpose.firstPassword:
'um deinem Konto auf learn.halkyon.eu sein erstes Passwort zu '
'geben',
EmailCodePurpose.joinSpace:
'um mit deinem Konto auf learn.halkyon.eu dem beizutreten, wozu '
'diese Adresse eingeladen wurde',
};
for (final purpose in EmailCodePurpose.values) {
final en = mailFor(purpose, 'en').body;
@ -189,6 +195,8 @@ void main() {
EmailCodePurpose.firstPassword:
'K7QX-M2PD: your Halkyon Learn code to give your account a '
'password',
EmailCodePurpose.joinSpace:
'K7QX-M2PD: your Halkyon Learn code to accept an invitation',
};
const german = {
EmailCodePurpose.signUp:
@ -207,6 +215,8 @@ void main() {
EmailCodePurpose.firstPassword:
'K7QX-M2PD: dein Halkyon-Learn-Code, um deinem Konto ein Passwort '
'zu geben',
EmailCodePurpose.joinSpace:
'K7QX-M2PD: dein Halkyon-Learn-Code, um einer Einladung zu folgen',
};
for (final purpose in EmailCodePurpose.values) {
expect(subjectOf(purpose, 'en'), english[purpose]);
@ -421,4 +431,27 @@ void main() {
);
});
});
test('an invitation names who invites and where, its link and when it '
'lapses, that joining takes a code mailed here, and nothing of what is '
'shared, in each language', () {
for (final language in ['en', 'de']) {
final mail = invitationMail(
inviter: 'Alice (@alice)',
app: 'Halkyon Learn',
link: Uri.parse('https://learn.halkyon.eu/join/abc'),
lapsesAt: DateTime.utc(2026, 11, 25, 12),
language: language,
);
expect(mail.subject, contains('Alice (@alice)'));
expect(mail.subject, contains('Halkyon Learn'));
expect(mail.body, contains('https://learn.halkyon.eu/join/abc'));
expect(mail.body, contains('learn.halkyon.eu.'));
expect(mail.body, contains('2026-11-25 12:00 UTC'));
expect(
mail.body,
contains(language == 'de' ? 'einen Code' : 'mails a code'),
);
}
});
}