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:
parent
8dd3ec0522
commit
554e1604b4
1 changed files with 108488 additions and 42 deletions
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue