2026-10-20 14:55:13 +02:00
|
|
|
import 'package:clock/clock.dart';
|
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 'package:halkyon_base_server/base.dart';
|
|
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
import '../support/fixtures.dart';
|
|
|
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
|
|
|
|
|
|
/// Keeps nothing; there to say the instance can mail codes.
|
|
|
|
|
final class _NoMail implements CodeMailer {
|
|
|
|
|
@override
|
|
|
|
|
Future<void> send(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required String code,
|
|
|
|
|
required String language,
|
|
|
|
|
}) async {}
|
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
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> sendResetNotice(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required AccountResetKind kind,
|
|
|
|
|
required DateTime takesEffectAt,
|
|
|
|
|
required String language,
|
|
|
|
|
}) async {}
|
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> sendCodesWaitNotice(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String to,
|
|
|
|
|
required DateTime until,
|
|
|
|
|
required String language,
|
|
|
|
|
}) async {}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
withServerpod('What every instance tells its apps', (
|
|
|
|
|
sessionBuilder,
|
|
|
|
|
endpoints,
|
|
|
|
|
) {
|
|
|
|
|
late InstanceBasics basics;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
useBase(sessionBuilder);
|
|
|
|
|
basics = await endpoints.instance.basics(
|
|
|
|
|
sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.
An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 15:18:49 +02:00
|
|
|
test('names the app it serves, so an app recognises its own instances', () {
|
|
|
|
|
expect(basics.app, testApp);
|
|
|
|
|
});
|
|
|
|
|
|
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 where the host's project publishes its source when the "
|
|
|
|
|
'operator set nothing', () {
|
|
|
|
|
// The tests run without SOURCE_URL.
|
|
|
|
|
expect(basics.sourceCodeUrl, testUpstreamSourceCodeUrl);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('names the oldest app build the host works with, to anyone, so an '
|
|
|
|
|
'older app can say it needs an update', () {
|
|
|
|
|
expect(basics.minimumAppBuild, testMinimumAppBuild);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('says nothing of where its data and backups are where the '
|
|
|
|
|
'operator said nothing', () {
|
|
|
|
|
// The tests run without DATA_LOCATION and BACKUP_LOCATION.
|
|
|
|
|
expect(basics.dataLocation, isNull);
|
|
|
|
|
expect(basics.backupLocation, isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('offers no GitHub sign-in without an OAuth app of its own', () {
|
|
|
|
|
// The tests' passwords hold no githubClientId.
|
|
|
|
|
expect(basics.gitHubClientId, isNull);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-20 14:55:13 +02:00
|
|
|
test('says when GitHub sign-in ends, until it has', () async {
|
|
|
|
|
final pod = sessionBuilder.build().serverpod;
|
|
|
|
|
final unauthenticated = sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
|
|
|
);
|
|
|
|
|
addTearDown(() => useGitHubSignInEnds(pod, null));
|
|
|
|
|
expect(basics.gitHubSignInEnds, isNull);
|
|
|
|
|
|
|
|
|
|
final ends = DateTime.utc(2027, 1, 22);
|
|
|
|
|
useGitHubSignInEnds(pod, ends);
|
|
|
|
|
expect(
|
|
|
|
|
(await withClock(
|
|
|
|
|
Clock.fixed(DateTime.utc(2026, 10, 22)),
|
|
|
|
|
() => endpoints.instance.basics(unauthenticated),
|
|
|
|
|
)).gitHubSignInEnds,
|
|
|
|
|
ends,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await withClock(
|
|
|
|
|
Clock.fixed(ends),
|
|
|
|
|
() => endpoints.instance.basics(unauthenticated),
|
|
|
|
|
)).gitHubSignInEnds,
|
|
|
|
|
isNull,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
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('offers email sign-in only where it can mail codes', () async {
|
|
|
|
|
final codes = EmailCodes.of(sessionBuilder.build().serverpod);
|
|
|
|
|
final unauthenticated = sessionBuilder.copyWith(
|
|
|
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
|
|
|
);
|
|
|
|
|
addTearDown(() => codes.mailer = null);
|
|
|
|
|
|
|
|
|
|
codes.mailer = null;
|
|
|
|
|
expect(
|
|
|
|
|
(await endpoints.instance.basics(unauthenticated)).emailSignIn,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
codes.mailer = _NoMail();
|
|
|
|
|
expect(
|
|
|
|
|
(await endpoints.instance.basics(unauthenticated)).emailSignIn,
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|