halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
|
|
|
import 'package:mailer/mailer.dart' as mailer;
|
|
|
|
|
import 'package:mailer/smtp_server.dart';
|
|
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
/// An SMTP server on a loopback port that takes one mail at a time, offers
|
|
|
|
|
/// no TLS, and keeps what it is told.
|
|
|
|
|
final class _FakeSmtp {
|
|
|
|
|
_FakeSmtp._(this._socket) {
|
|
|
|
|
_socket.listen(_serve);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Future<_FakeSmtp> start() async =>
|
|
|
|
|
_FakeSmtp._(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
|
|
|
|
|
|
|
|
|
|
final ServerSocket _socket;
|
|
|
|
|
|
|
|
|
|
/// The commands received, in order.
|
|
|
|
|
final commands = <String>[];
|
|
|
|
|
|
|
|
|
|
/// What each mail's DATA held, its lines joined.
|
|
|
|
|
final mails = <String>[];
|
|
|
|
|
|
|
|
|
|
int get port => _socket.port;
|
|
|
|
|
|
|
|
|
|
Future<void> close() => _socket.close();
|
|
|
|
|
|
|
|
|
|
void _serve(Socket client) {
|
|
|
|
|
client.write('220 fake ESMTP\r\n');
|
|
|
|
|
var step = '';
|
|
|
|
|
final data = StringBuffer();
|
|
|
|
|
utf8.decoder.bind(client).transform(const LineSplitter()).listen((line) {
|
|
|
|
|
if (step == 'data') {
|
|
|
|
|
if (line == '.') {
|
|
|
|
|
step = '';
|
|
|
|
|
mails.add(data.toString());
|
|
|
|
|
data.clear();
|
|
|
|
|
client.write('250 2.0.0 queued\r\n');
|
|
|
|
|
} else {
|
|
|
|
|
data.writeln(line);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (step == 'auth') {
|
|
|
|
|
step = '';
|
|
|
|
|
client.write('235 2.7.0 Authentication successful\r\n');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
commands.add(line);
|
|
|
|
|
final command = line.toUpperCase();
|
|
|
|
|
if (command.startsWith('EHLO')) {
|
|
|
|
|
client.write('250-fake\r\n250 AUTH PLAIN\r\n');
|
|
|
|
|
} else if (command == 'AUTH PLAIN') {
|
|
|
|
|
step = 'auth';
|
|
|
|
|
client.write('334 \r\n');
|
|
|
|
|
} else if (command == 'DATA') {
|
|
|
|
|
step = 'data';
|
|
|
|
|
client.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
|
|
|
|
} else if (command == 'QUIT') {
|
|
|
|
|
client
|
|
|
|
|
..write('221 2.0.0 Bye\r\n')
|
|
|
|
|
..close();
|
|
|
|
|
} else {
|
|
|
|
|
client.write('250 2.0.0 OK\r\n');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The text of [mail] as DATA held it: its headers and its body, decoded
|
|
|
|
|
/// from base64 where it was encoded so.
|
|
|
|
|
({String headers, String body}) _read(String mail) {
|
|
|
|
|
final at = mail.indexOf('\n\n');
|
|
|
|
|
final headers = mail.substring(0, at);
|
|
|
|
|
final body = mail.substring(at + 2);
|
|
|
|
|
return (
|
|
|
|
|
headers: headers,
|
|
|
|
|
body: headers.toLowerCase().contains('content-transfer-encoding: base64')
|
|
|
|
|
? utf8.decode(base64.decode(body.replaceAll(RegExp(r'\s'), '')))
|
|
|
|
|
: body,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('the mail', () {
|
|
|
|
|
test('is plain text with the code first, in the subject too, naming '
|
|
|
|
|
'the instance and how long the code lasts', () {
|
|
|
|
|
final mail = codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'en',
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
);
|
|
|
|
|
|
2026-10-21 10:44:10 +02:00
|
|
|
expect(
|
|
|
|
|
mail.subject,
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: your Halkyon Learn code to create an account or give it '
|
|
|
|
|
'a password',
|
2026-10-21 10:44:10 +02:00
|
|
|
);
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
expect(mail.body, startsWith('K7QX-M2PD\n\n'));
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
expect(mail.body, contains('create your account on learn.halkyon.eu'));
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
expect(mail.body, contains('15 minutes'));
|
|
|
|
|
expect(mail.body, isNot(contains('http')));
|
|
|
|
|
});
|
|
|
|
|
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
test('says what its code would do, and to type it nowhere but the app '
|
|
|
|
|
'on the instance, in each language', () {
|
|
|
|
|
({String subject, String body}) mailFor(
|
|
|
|
|
EmailCodePurpose purpose,
|
|
|
|
|
String language,
|
|
|
|
|
) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
purpose: purpose,
|
|
|
|
|
);
|
|
|
|
|
const english = {
|
|
|
|
|
EmailCodePurpose.signUp: 'to create your account',
|
|
|
|
|
EmailCodePurpose.finishSetup: 'to finish setting up your account',
|
|
|
|
|
EmailCodePurpose.authenticatorReset:
|
|
|
|
|
"to switch off your authenticator app's codes",
|
|
|
|
|
EmailCodePurpose.accountReset: 'to reset your account',
|
|
|
|
|
EmailCodePurpose.deletion: 'to delete your account',
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
EmailCodePurpose.firstPassword:
|
|
|
|
|
'to give your account on learn.halkyon.eu its first password',
|
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>
2026-11-11 18:28:13 +01:00
|
|
|
EmailCodePurpose.joinSpace:
|
|
|
|
|
'to join, with your account on learn.halkyon.eu, what this '
|
|
|
|
|
'address was invited to',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
};
|
|
|
|
|
const german = {
|
|
|
|
|
EmailCodePurpose.signUp: 'um dein Konto auf learn.halkyon.eu anzulegen',
|
|
|
|
|
EmailCodePurpose.finishSetup: 'um die Einrichtung deines Kontos',
|
|
|
|
|
EmailCodePurpose.authenticatorReset:
|
|
|
|
|
'um die Codes deiner Authenticator-App',
|
|
|
|
|
EmailCodePurpose.accountReset: 'zurückzusetzen',
|
|
|
|
|
EmailCodePurpose.deletion: 'mit allem darin zu löschen',
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
EmailCodePurpose.firstPassword:
|
|
|
|
|
'um deinem Konto auf learn.halkyon.eu sein erstes Passwort zu '
|
|
|
|
|
'geben',
|
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>
2026-11-11 18:28:13 +01:00
|
|
|
EmailCodePurpose.joinSpace:
|
|
|
|
|
'um mit deinem Konto auf learn.halkyon.eu dem beizutreten, wozu '
|
|
|
|
|
'diese Adresse eingeladen wurde',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
};
|
|
|
|
|
for (final purpose in EmailCodePurpose.values) {
|
|
|
|
|
final en = mailFor(purpose, 'en').body;
|
|
|
|
|
expect(en, contains(english[purpose]));
|
|
|
|
|
expect(
|
|
|
|
|
en,
|
|
|
|
|
contains('Type it only in Halkyon Learn on learn.halkyon.eu'),
|
|
|
|
|
);
|
|
|
|
|
expect(en, isNot(contains('sign in')));
|
|
|
|
|
final de = mailFor(purpose, 'de').body;
|
|
|
|
|
expect(de, contains(german[purpose]));
|
|
|
|
|
expect(
|
|
|
|
|
de,
|
|
|
|
|
contains('Gib ihn nur in Halkyon Learn auf learn.halkyon.eu'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
expect(
|
|
|
|
|
mailFor(EmailCodePurpose.accountReset, 'en').body,
|
|
|
|
|
contains('what was encrypted with the old ones is lost'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-21 10:44:10 +02:00
|
|
|
test('names its purpose in the subject, which a notification shows '
|
|
|
|
|
'alone (the third reading)', () {
|
|
|
|
|
String subjectOf(EmailCodePurpose purpose, String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
purpose: purpose,
|
|
|
|
|
).subject;
|
|
|
|
|
const english = {
|
|
|
|
|
EmailCodePurpose.signUp:
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: your Halkyon Learn code to create an account or give '
|
|
|
|
|
'it a password',
|
2026-10-21 10:44:10 +02:00
|
|
|
EmailCodePurpose.finishSetup:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to finish setting up your '
|
|
|
|
|
'account',
|
|
|
|
|
EmailCodePurpose.authenticatorReset:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to switch off your '
|
|
|
|
|
'authenticator',
|
|
|
|
|
EmailCodePurpose.accountReset:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to reset your account',
|
|
|
|
|
EmailCodePurpose.deletion:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to delete your account',
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
EmailCodePurpose.firstPassword:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to give your account a '
|
|
|
|
|
'password',
|
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>
2026-11-11 18:28:13 +01:00
|
|
|
EmailCodePurpose.joinSpace:
|
|
|
|
|
'K7QX-M2PD: your Halkyon Learn code to accept an invitation',
|
2026-10-21 10:44:10 +02:00
|
|
|
};
|
|
|
|
|
const german = {
|
|
|
|
|
EmailCodePurpose.signUp:
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um ein Konto anzulegen oder '
|
|
|
|
|
'ihm ein Passwort zu geben',
|
2026-10-21 10:44:10 +02:00
|
|
|
EmailCodePurpose.finishSetup:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto fertig '
|
|
|
|
|
'einzurichten',
|
|
|
|
|
EmailCodePurpose.authenticatorReset:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um deine Authenticator-App '
|
|
|
|
|
'abzuschalten',
|
|
|
|
|
EmailCodePurpose.accountReset:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zurückzusetzen',
|
|
|
|
|
EmailCodePurpose.deletion:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zu löschen',
|
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).
- An account with an address proves it anew: a code mailed to that
address for this (EmailCodePurpose.firstPassword, its own mail), never
the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
a GitHub sign-in (the session's method and creation time, where the
session lives; members tell the register), refused signInAgain before
any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
they add.
The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 11:31:22 +02:00
|
|
|
EmailCodePurpose.firstPassword:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um deinem Konto ein Passwort '
|
|
|
|
|
'zu geben',
|
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>
2026-11-11 18:28:13 +01:00
|
|
|
EmailCodePurpose.joinSpace:
|
|
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um einer Einladung zu folgen',
|
2026-10-21 10:44:10 +02:00
|
|
|
};
|
|
|
|
|
for (final purpose in EmailCodePurpose.values) {
|
|
|
|
|
expect(subjectOf(purpose, 'en'), english[purpose]);
|
|
|
|
|
expect(subjectOf(purpose, 'de'), german[purpose]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('for signing up, says it also gives a password to an account with '
|
|
|
|
|
'that address and none yet', () {
|
|
|
|
|
({String subject, String body}) mailIn(String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
purpose: EmailCodePurpose.signUp,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
mailIn('en').body,
|
|
|
|
|
contains(
|
|
|
|
|
'or, should an account there have this address and no password '
|
|
|
|
|
'yet, to give it one',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
mailIn('de').body,
|
|
|
|
|
contains(
|
|
|
|
|
'oder, hat dort schon ein Konto diese Adresse und noch kein '
|
|
|
|
|
'Passwort, ihm eines zu geben',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
test('in German for German, in English for anything else', () {
|
|
|
|
|
({String subject, String body}) mailIn(String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'x',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
);
|
|
|
|
|
|
2026-10-21 10:44:10 +02:00
|
|
|
expect(
|
|
|
|
|
mailIn('de').subject,
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: dein Halkyon-Learn-Code, um ein Konto anzulegen oder ihm '
|
|
|
|
|
'ein Passwort zu geben',
|
2026-10-21 10:44:10 +02:00
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
mailIn('fr').subject,
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: your Halkyon Learn code to create an account or give '
|
|
|
|
|
'it a password',
|
2026-10-21 10:44:10 +02:00
|
|
|
);
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('names the app the host is, in its text too', () {
|
|
|
|
|
({String subject, String body}) mailIn(String language) => codeMail(
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Todo',
|
|
|
|
|
instance: 'todo.example.org',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
);
|
|
|
|
|
|
2026-10-21 10:44:10 +02:00
|
|
|
expect(
|
|
|
|
|
mailIn('en').subject,
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: your Halkyon Todo code to create an account or give it a '
|
|
|
|
|
'password',
|
2026-10-21 10:44:10 +02:00
|
|
|
);
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
expect(mailIn('en').body, contains('Type this code in Halkyon Todo'));
|
2026-10-21 10:44:10 +02:00
|
|
|
expect(
|
|
|
|
|
mailIn('de').subject,
|
2026-10-21 11:46:01 +02:00
|
|
|
'K7QX-M2PD: dein Halkyon-Todo-Code, um ein Konto anzulegen oder '
|
|
|
|
|
'ihm ein Passwort zu geben',
|
2026-10-21 10:44:10 +02:00
|
|
|
);
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
expect(mailIn('de').body, contains('Gib diesen Code in Halkyon Todo'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
group("a reset's notice", () {
|
|
|
|
|
({String subject, String body}) notice(
|
|
|
|
|
AccountResetKind kind,
|
|
|
|
|
String language,
|
|
|
|
|
) => resetMail(
|
|
|
|
|
kind: kind,
|
|
|
|
|
takesEffectAt: DateTime.utc(2026, 10, 23, 14, 5, 59),
|
|
|
|
|
language: language,
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('says what goes when, in UTC, on which instance, and that signing '
|
|
|
|
|
'in calls it off, in German for German and English else', () {
|
|
|
|
|
for (final kind in AccountResetKind.values) {
|
|
|
|
|
for (final language in ['en', 'de', 'fr']) {
|
|
|
|
|
final mail = notice(kind, language);
|
|
|
|
|
expect(mail.body, contains('2026-10-23 14:05 UTC'));
|
|
|
|
|
expect(mail.body, contains('learn.halkyon.eu'));
|
|
|
|
|
expect(mail.subject, contains('Halkyon Learn'));
|
|
|
|
|
expect(
|
|
|
|
|
mail.body,
|
|
|
|
|
language == 'de'
|
|
|
|
|
? contains('bricht es ab')
|
|
|
|
|
: contains('calls it off'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(
|
|
|
|
|
notice(AccountResetKind.account, 'en').body,
|
|
|
|
|
contains('can no longer be read'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('holds no link and no code: it helps nobody but the owner', () {
|
|
|
|
|
for (final kind in AccountResetKind.values) {
|
|
|
|
|
final mail = notice(kind, 'en');
|
|
|
|
|
expect(mail.body, isNot(contains('http')));
|
|
|
|
|
expect(mail.body, isNot(matches(RegExp('[0-9A-Z]{4}-[0-9A-Z]{4}'))));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
group('codes go', () {
|
|
|
|
|
final smtp = SmtpCodeMailer(
|
|
|
|
|
server: SmtpServer('localhost'),
|
|
|
|
|
from: const mailer.Address('learn@halkyon.eu'),
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('through the SMTP service set up, in every run mode', () {
|
|
|
|
|
for (final runMode in [
|
|
|
|
|
ServerpodRunMode.development,
|
|
|
|
|
ServerpodRunMode.production,
|
|
|
|
|
]) {
|
|
|
|
|
expect(codeMailerFor(runMode: runMode, smtp: smtp), same(smtp));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('into the log in development without one, and nowhere elsewhere '
|
|
|
|
|
'(no email sign-in)', () {
|
|
|
|
|
expect(
|
|
|
|
|
codeMailerFor(runMode: ServerpodRunMode.development, smtp: null),
|
|
|
|
|
isA<LogCodeMailer>(),
|
|
|
|
|
);
|
|
|
|
|
for (final runMode in [
|
|
|
|
|
ServerpodRunMode.staging,
|
|
|
|
|
ServerpodRunMode.production,
|
|
|
|
|
ServerpodRunMode.test,
|
|
|
|
|
]) {
|
|
|
|
|
expect(codeMailerFor(runMode: runMode, smtp: null), isNull);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('over SMTP', () {
|
|
|
|
|
late _FakeSmtp server;
|
|
|
|
|
|
|
|
|
|
setUp(() async => server = await _FakeSmtp.start());
|
|
|
|
|
tearDown(() => server.close());
|
|
|
|
|
|
|
|
|
|
SmtpCodeMailer mailerFor({required bool allowInsecure}) => SmtpCodeMailer(
|
|
|
|
|
server: SmtpServer(
|
|
|
|
|
'127.0.0.1',
|
|
|
|
|
port: server.port,
|
|
|
|
|
username: 'learn@halkyon.eu',
|
|
|
|
|
password: 'token',
|
|
|
|
|
allowInsecure: allowInsecure,
|
|
|
|
|
),
|
|
|
|
|
from: const mailer.Address('learn@halkyon.eu', 'Halkyon Learn'),
|
|
|
|
|
app: 'Halkyon Learn',
|
|
|
|
|
instance: 'learn.halkyon.eu',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'mails the code from the address set up to the address typed',
|
|
|
|
|
() async {
|
|
|
|
|
await mailerFor(allowInsecure: true).mail(
|
|
|
|
|
to: 'anna@example.org',
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'de',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(server.commands, contains('MAIL FROM:<learn@halkyon.eu>'));
|
|
|
|
|
expect(server.commands, contains('RCPT TO:<anna@example.org>'));
|
|
|
|
|
final mail = _read(server.mails.single);
|
|
|
|
|
expect(mail.headers, contains('Halkyon Learn'));
|
|
|
|
|
expect(mail.headers, contains('<learn@halkyon.eu>'));
|
|
|
|
|
expect(mail.headers, contains('text/plain'));
|
|
|
|
|
expect(mail.body, startsWith('K7QX-M2PD'));
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
expect(
|
|
|
|
|
mail.body,
|
|
|
|
|
contains('um dein Konto auf learn.halkyon.eu anzulegen'),
|
|
|
|
|
);
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('never over a connection without TLS', () async {
|
|
|
|
|
await expectLater(
|
|
|
|
|
mailerFor(allowInsecure: false).mail(
|
|
|
|
|
to: 'anna@example.org',
|
|
|
|
|
code: 'K7QX-M2PD',
|
|
|
|
|
language: 'en',
|
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:21:37 +02:00
|
|
|
purpose: EmailCodePurpose.signUp,
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
),
|
|
|
|
|
throwsA(isA<Exception>()),
|
|
|
|
|
);
|
|
|
|
|
expect(server.mails, isEmpty);
|
|
|
|
|
expect(
|
|
|
|
|
server.commands.where((command) => command.startsWith('AUTH')),
|
|
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
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>
2026-11-11 18:28:13 +01:00
|
|
|
|
|
|
|
|
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'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.
The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:28:09 +02:00
|
|
|
}
|