feat(accounts): one account across an instance's services (ADR 0016)

halkyon_base runs as the instance's register or as one of its members.
The register keeps the ways in, handles and profiles; members ask it
over HTTP with the instance's shared secret (instanceSecret), and open
sessions of their own for its accounts, under the same account ID.

- AccountsMode from the environment: INSTANCE_MEMBERS on the register,
  ACCOUNT_REGISTER_URL on a member. Learn always holds the register
  (Explore joins the profiles); Todo is a member where it is set.
- AccountRegister: LocalRegister, RemoteRegister, an in-memory one in
  tests. Calls between services on the web server, refused without the
  secret (constant-time compare); a server needing it doesn't start
  without it.
- Deleting an account deletes it everywhere: notices per member kept in
  the deletion's transaction (account_deletion_notice), told at once and
  hourly until confirmed; members prune daily whom the register no longer
  knows, never while it knows none of them or can't be reached.
- Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 23:10:08 +02:00
commit 65c5cd740d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
39 changed files with 41906 additions and 167 deletions

View file

@ -46,7 +46,60 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
(`profileOf`, `profilesOf`, `personWithHandle`, `fixHandle`; `saveProfile` for its
tests), `accountDomain`, `profilePageUrl`, `addProfilePageRoute`, the register
(`AccountRegister`), and the models. A host never reads the profiles' table itself.
(`AccountRegister`, `AccountsMode`, `addInstanceRoutes`, `deliverDeletionNotices`,
`forgetPeopleTheRegisterForgot`, `issueSession`), and the models. A host never reads
the profiles' table itself.
## The instance's register (ADR 0016)
One operator's services share one account per person: the same address, GitHub account
and handle reach the same person in each, under one account ID (each service's
`AuthUser` has it), and deleting the account deletes it everywhere. One service holds
the register, its database keeping the ways in (`email_identity`, GitHub's IDs), the
handles and profiles; the others are its members and ask it. Anonymous people stay each
service's own until they add a way in, which makes them an account of the register under
the same ID. Sessions stay each service's own: every app signs in itself.
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
one service, telling the members `INSTANCE_MEMBERS` names (web servers, separated by
commas or spaces) when an account goes; `MemberOfRegister` where
`ACCOUNT_REGISTER_URL` names the register's web server. Empty values count as unset.
- **Trust:** the instance's shared secret (`instanceSecret` in the passwords), sent as a
bearer token with every call between the services and compared in constant time. A
server without one takes no such call and makes none (a member without one fails to
ask anything). A member is trusted as the register's own code: whoever holds the secret
can act for any account, so it never leaves the operator's servers.
- **The interface:** `AccountRegister`, which every endpoint and host function asks:
`LocalRegister` on the register (its own tables), `RemoteRegister` on a member (a POST
of JSON to `/halkyon-base/register/<operation>` on the register's web server, 30 s;
refusals come back as the exceptions the register threw, anything else as
`RegisterUnavailableException`); tests put an in-memory one in its place
(`AccountRegister.use`). The register answers on its web server (`addInstanceRoutes`),
a member on `/halkyon-base/member/account-gone`; neither is reachable from outside (the
host's Caddyfile never forwards them).
- **Signing in on a member:** the app calls the same endpoints in both modes. The member
asks the register to mail a code (the device's network passed on, as the register
counts codes per network) and to check it, or to say whose GitHub ID it is, and opens a
session of its own for that account (`issueSession`, which makes them a person there
under the account's ID the first time). Linking a way in makes the person identified on
the register and then on the member; a switch token signs in as the account the
address is already. Every refusal is the register's, as on the register itself.
- **Deleting:** an identified person's deletion on a member goes to the register first
(refused while it can't be reached, deleting nothing), which deletes the account and
keeps a notice for each member in the same transaction (`account_deletion_notice`),
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
confirms; notices to a member no longer named are dropped. An anonymous person goes
from their service alone. Each member also deletes, daily, the identified people the
register no longer knows (`forgetPeopleTheRegisterForgot`): it asks about everyone
before deleting anyone, deletes nobody while the register knows none of them (a
register misnamed or emptied), and fails, deleting nobody, while it can't reach it.
After restoring the register's database from a backup, accounts made since are
unknown to it: stop the members' pruning until those people are dealt with.
- **Privacy:** the register learns when someone signs in to which service; the
instance's privacy page has to say so.
- **The register being down:** members can't sign anyone in, change profiles or delete
accounts; their open sessions go on working.
## Taking it into a host
@ -85,6 +138,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
german: 'Auf Halkyon Todo, einer Aufgabenliste:',
link: Uri.parse('https://halkyon.app/todo'),
),
// The register, or a member of the one the operator names (ADR 0016).
accounts: AccountsMode.fromEnvironment(),
),
);
pod.initializeAuthServices(
@ -93,12 +148,14 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
);
```
Serve the profile pages on its web server: `addProfilePageRoute(pod)`, after any
Serve the calls between the instance's services on its web server:
`addInstanceRoutes(pod)`; and the profile pages: `addProfilePageRoute(pod)`, after any
literal routes of its own.
Then schedule the pruning in a future call of the host's own: daily
`pruneAbandonedAnonymousPeople`, hourly `forgetOldSignUpAttempts` and
`forgetExpiredEmailCodes`.
`pruneAbandonedAnonymousPeople` and `forgetPeopleTheRegisterForgot` (a member's),
hourly `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes` and
`deliverDeletionNotices` (the register's). Each does nothing in the other mode.
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
after the person's sessions ended and before their identities and the person go, in
@ -109,12 +166,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
5. Create the host's migration. A host taking the tables over from its own models (as
Halkyon Learn does) finds no change once its copies are gone: Serverpod matches tables
by name, and these are identical. `create-migration --empty` then records the module's
version, with no SQL besides.
version, with no SQL besides. Every service has the module's tables, whichever its
mode: a member's stay empty but for its people's sessions.
The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClientId` and
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`. Never change either
pepper once people sign in: every session or address hash would be lost. The environment:
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; between the
instance's services `instanceSecret`. Never change either pepper once people sign in:
every session or address hash would be lost. A member needs no email settings of its
own (the register mails the codes), but GitHub's (its own OAuth app's, whose callback is
its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
one; `SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,

View file

@ -31,3 +31,9 @@ export 'src/profile/profile_rules.dart'
reservedHandles;
export 'src/profile/profiles.dart';
export 'src/register/account_register.dart';
export 'src/register/accounts_mode.dart';
export 'src/register/deletion_notices.dart'
show MemberNotifier, deliverDeletionNotices;
export 'src/register/instance_routes.dart';
export 'src/register/member_pruning.dart';
export 'src/register/people.dart' show issueSession;

View file

@ -1,7 +1,10 @@
import 'package:halkyon_base_server/src/auth/auth_config.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -10,11 +13,38 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// would upload everything again; then the host deletes their data in its
/// own tables ([HalkyonBase.deletePersonData], in the order given); then
/// their public profile with its avatar, their address's hash and the person
/// with their other identities. Once the transaction holds, what the host's
/// deletions left for afterwards runs.
/// with their other identities. On the instance's register, an account (an
/// identified person) going leaves a notice for each member in the same
/// transaction, and the members are told once it holds (ADR 0016). Once the
/// transaction holds, what the host's deletions left for afterwards runs.
///
/// This deletes here alone: on a member, the account itself goes through the
/// register (`AccountRegister.deleteAccount`).
Future<void> deletePerson(Session session, UuidValue authUserId) async {
final deletions = HalkyonBase.of(session.serverpod).deletePersonData;
final base = HalkyonBase.of(session.serverpod);
final deletions = base.deletePersonData;
final members = switch (base.accounts) {
HoldsRegister(:final members) => members,
MemberOfRegister() => const <Uri>[],
};
var noticesKept = false;
final afterwards = await session.db.transaction((transaction) async {
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
final person = await AuthUser.db.findById(
session,
authUserId,
transaction: transaction,
);
if (members.isNotEmpty &&
(person?.scopeNames.contains(identified.name) ?? false)) {
await keepDeletionNotices(
session,
authUserId,
members,
transaction: transaction,
);
noticesKept = true;
}
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
await ServerSideSession.db.deleteWhere(
session,
@ -48,17 +78,23 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
transaction: transaction,
);
// Their GitHub id and anonymous account go with them (ON DELETE CASCADE).
// Gone already on a member the register told first (ADR 0016).
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
await AuthServices.instance.authUsers.delete(
session,
authUserId: authUserId,
transaction: transaction,
);
if (person != null) {
await AuthServices.instance.authUsers.delete(
session,
authUserId: authUserId,
transaction: transaction,
);
}
return afterwards;
});
for (final after in afterwards) {
await after();
}
if (noticesKept) {
await deliverDeletionNotices(session);
}
}
/// Deletes the anonymous people nobody can return to, at most [limit] per

View file

@ -1,5 +1,8 @@
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -11,9 +14,16 @@ class AccountEndpoint extends Endpoint {
/// Deletes the caller with everything they have (see [deletePerson]). The
/// app also calls it when an anonymous account is given up (decided
/// 2026-09-30). Their other devices learn at once that their sessions
/// ended.
/// ended. An account deletes everywhere (ADR 0016): on a member, the
/// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone.
Future<void> delete(Session session) async {
final person = session.authenticated!.authUserId;
if (isMember(session.serverpod) &&
session.authenticated!.scopes.contains(identified)) {
await AccountRegister.of(session.serverpod)
.deleteAccount(session, person);
}
await deletePerson(session, person);
await session.messages.authenticationRevoked(
person.uuid,
@ -23,24 +33,7 @@ class AccountEndpoint extends Endpoint {
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
Future<SignInWays> signInWays(Session session) async {
final person = session.authenticated!.authUserId;
return SignInWays(
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
gitHub:
await GitHubAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
// SELECT count(*) FROM "email_identity" WHERE "authUserId" = $1
email:
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
);
}
Future<SignInWays> signInWays(Session session) => AccountRegister.of(
session.serverpod,
).signInWaysOf(session, session.authenticated!.authUserId);
}

View file

@ -7,6 +7,7 @@ import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/common/short_code.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -109,11 +110,14 @@ final _codesPerNetwork = DatabaseRateLimiter(
/// same way whether anybody has the address or not. An earlier code for the
/// address stops being valid. Refused for no address, too many codes, or a
/// mail that couldn't be sent, leaving no request behind
/// ([EmailSignInRefusedException]).
/// ([EmailSignInRefusedException]). The codes a network asks for are counted
/// by the address the request came from, or [network] where a member of the
/// instance asks for its device (ADR 0016).
Future<UuidValue> sendSignInCode(
Session session, {
required String address,
required String language,
String? network,
}) async {
final pod = session.serverpod;
final mailer = EmailCodes.of(pod).mailer;
@ -130,7 +134,7 @@ Future<UuidValue> sendSignInCode(
!await _codesPerNetwork.tryRecordAttempt(
session,
// As Serverpod's own rate limits take it (no request in tests).
key: '${session.request?.connectionInfo.remote.address}',
key: network ?? networkOf(session),
)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.tooManyCodes);
}
@ -190,8 +194,10 @@ Future<UuidValue> sendSignInCode(
/// Accepts [code] for [request] from the person [authUserId]: an address
/// nobody has becomes theirs, in one transaction (they become `identified`,
/// on their sessions too, and stop being anonymous, keeping everything they
/// have); their own changes nothing; another person's changes nothing here
/// and gives a token that signs in as that person ([signInWithEmail]).
/// have; a member's person becomes an account of the register under their
/// ID, ADR 0016); their own changes nothing; another person's changes nothing
/// here and gives a token that signs in as that person
/// ([completeEmailSwitch]).
///
/// Refused for a wrong code, too many tries, a code no longer valid, or a
/// second address for someone who has one ([EmailSignInRefusedException]);
@ -242,6 +248,7 @@ Future<EmailCodeAccepted> acceptSignInCode(
reason: EmailSignInRefusal.otherAddressLinked,
);
}
await ensurePerson(session, authUserId, transaction: transaction);
// INSERT INTO "email_identity" ("authUserId", "addressHash",
// "createdAt") VALUES ($1, $2, $3)
await EmailIdentity.db.insertRow(
@ -267,12 +274,12 @@ Future<EmailCodeAccepted> acceptSignInCode(
});
}
/// A new session for the person whose address a code proved, from the token
/// [acceptSignInCode] gave; once only. Called without a session: Serverpod
/// issues no session for another person inside a signed-in call. Refused
/// once the token was used or its time is up, or for a token it never gave
/// ([EmailSignInRefusal.codeExpired]).
Future<AuthSuccess> signInWithEmail(
/// The person whose address a code proved, from the token
/// [acceptSignInCode] gave; once only. A session for them is the caller's to
/// issue (`issueSession`): this server's own, or a member's (ADR 0016).
/// Refused once the token was used or its time is up, or for a token it
/// never gave ([EmailSignInRefusal.codeExpired]).
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
}) async {
@ -301,21 +308,15 @@ Future<AuthSuccess> signInWithEmail(
reason: EmailSignInRefusal.codeExpired,
);
}
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: identity.authUserId,
transaction: transaction,
);
return await AuthServices.instance.tokenManager.issueToken(
session,
authUserId: identity.authUserId,
method: emailMethod,
scopes: person.scopeNames.map(Scope.new).toSet(),
transaction: transaction,
);
return identity.authUserId;
});
}
/// The network [session]'s request came from, as Serverpod's own rate limits
/// take it (no request in tests).
String networkOf(Session session) =>
'${session.request?.connectionInfo.remote.address}';
/// Deletes the code requests past their time at [now], and returns how many:
/// hourly, with the rate limit's notes (`PruningFutureCall`).
Future<int> forgetExpiredEmailCodes(

View file

@ -1,12 +1,17 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Signing in with an email address, by a code mailed to it:
/// the way to keep an anonymous account, as linking GitHub is, and a second
/// way in for someone who signs in with GitHub. Signed in only, as every
/// device is from its first start.
/// device is from its first start. The instance's register mails the codes
/// and keeps the addresses' hashes (ADR 0016), this server's own database
/// when it holds the register.
class EmailCodeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
@ -17,7 +22,12 @@ class EmailCodeEndpoint extends Endpoint {
Session session, {
required String address,
required String language,
}) => sendSignInCode(session, address: address, language: language);
}) => AccountRegister.of(session.serverpod).sendSignInCode(
session,
address: address,
language: language,
network: networkOf(session),
);
/// Accepts [code] for [request]: the address becomes the caller's unless
/// it is another person's, whose token then signs in as them
@ -26,10 +36,14 @@ class EmailCodeEndpoint extends Endpoint {
Session session, {
required UuidValue request,
required String code,
}) => acceptSignInCode(
session,
authUserId: session.authenticated!.authUserId,
request: request,
code: code,
);
}) async {
final person = session.authenticated!.authUserId;
final accepted = await AccountRegister.of(
session.serverpod,
).verifySignInCode(session, person: person, request: request, code: code);
if (accepted.switchToken == null && isMember(session.serverpod)) {
await becomeIdentified(session, person);
}
return accepted;
}
}

View file

@ -1,4 +1,6 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -7,9 +9,14 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// for another person inside a signed-in call.
class EmailSignInEndpoint extends Endpoint {
/// A session for the person [switchToken] names; refusals as in
/// [signInWithEmail].
/// [completeEmailSwitch].
Future<AuthSuccess> signIn(
Session session, {
required String switchToken,
}) => signInWithEmail(session, switchToken: switchToken);
}) async {
final account = await AccountRegister.of(
session.serverpod,
).completeEmailSwitch(session, switchToken: switchToken);
return await issueSession(session, account, method: emailMethod);
}
}

View file

@ -1,5 +1,6 @@
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/github.dart';
@ -54,7 +55,9 @@ Future<String> verifiedGitHubUserId(
/// Links the GitHub account [gitHubUserId] to the person [authUserId], in one
/// transaction: they become `identified` (on their existing sessions too) and
/// lose their anonymous identity, since they now sign in with GitHub. The
/// person stays the same, so everything they have stays where it is.
/// person stays the same, so everything they have stays where it is; a
/// member's person becomes an account of the register under their ID
/// (ADR 0016).
///
/// Linking the account the person already has changes nothing. Refused, with
/// nothing changed, when the account belongs to someone else or the person
@ -90,6 +93,7 @@ Future<void> linkGitHubAccount(
);
}
await ensurePerson(session, authUserId, transaction: transaction);
// INSERT INTO "serverpod_auth_idp_github_account"
// ("userIdentifier", "authUserId", "created") VALUES ($1, $2, $3)
await GitHubAccount.db.insertRow(
@ -106,13 +110,13 @@ Future<void> linkGitHubAccount(
);
});
/// A new session for the person who has the GitHub account [gitHubUserId],
/// with their scopes; used to switch to that person on another device.
/// The person who has the GitHub account [gitHubUserId]; null for nobody.
/// A session for them is the caller's to issue (`issueSession`), when it
/// switches to them on another device.
///
/// Nobody is created here: people start anonymous and link GitHub later
/// ([linkGitHubAccount]). An unknown account is refused
/// ([GitHubSignInRefusal.unknownAccount]).
Future<AuthSuccess> signInWithGitHubAccount(
/// ([linkGitHubAccount]).
Future<UuidValue?> accountWithGitHub(
Session session, {
required String gitHubUserId,
}) async {
@ -121,19 +125,5 @@ Future<AuthSuccess> signInWithGitHubAccount(
session,
where: (t) => t.userIdentifier.equals(gitHubUserId),
);
if (account == null) {
throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.unknownAccount,
);
}
final person = await AuthServices.instance.authUsers.get(
session,
authUserId: account.authUserId,
);
return await AuthServices.instance.tokenManager.issueToken(
session,
authUserId: account.authUserId,
method: gitHubMethod,
scopes: person.scopeNames.map(Scope.new).toSet(),
);
return account?.authUserId;
}

View file

@ -1,4 +1,7 @@
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -6,8 +9,9 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// to it on another device. Called without a session: Serverpod issues no
/// session for another person inside a signed-in call.
class GitHubSignInEndpoint extends Endpoint {
/// A session for the person with the GitHub account that [code] proves;
/// refusals as in [signInWithGitHubAccount].
/// A session for the person with the GitHub account that [code] proves,
/// whom the instance's register knows by it; an account it doesn't know
/// is refused ([GitHubSignInRefusal.unknownAccount]).
Future<AuthSuccess> signIn(
Session session, {
required String code,
@ -20,6 +24,13 @@ class GitHubSignInEndpoint extends Endpoint {
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
return await signInWithGitHubAccount(session, gitHubUserId: gitHubUserId);
final account =
await AccountRegister.of(
session.serverpod,
).accountWithGitHub(session, gitHubUserId) ??
(throw GitHubSignInRefusedException(
reason: GitHubSignInRefusal.unknownAccount,
));
return await issueSession(session, account, method: gitHubMethod);
}
}

View file

@ -1,10 +1,14 @@
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Links a GitHub account to the signed-in person: the lossless upgrade from
/// an anonymous account. The person and everything they have stay
/// where they are; only the way they sign in changes.
/// where they are; only the way they sign in changes. The instance's
/// register keeps the GitHub ID (ADR 0016).
class GitHubUpgradeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
@ -23,10 +27,12 @@ class GitHubUpgradeEndpoint extends Endpoint {
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
await linkGitHubAccount(
session,
authUserId: session.authenticated!.authUserId,
gitHubUserId: gitHubUserId,
);
final person = session.authenticated!.authUserId;
await AccountRegister.of(
session.serverpod,
).linkGitHub(session, person: person, gitHubUserId: gitHubUserId);
if (isMember(session.serverpod)) {
await becomeIdentified(session, person);
}
}
}

View file

@ -0,0 +1,8 @@
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:serverpod/serverpod.dart';
/// Whether [pod] is a member of its instance's register (ADR 0016), so what
/// the register links it makes true here too.
bool isMember(Serverpod pod) =>
HalkyonBase.maybeOf(pod)?.accounts is MemberOfRegister;

View file

@ -31,6 +31,7 @@ import 'profile/profile_not_found_exception.dart' as _iim3hfcf;
import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'profile/public_profile.dart' as _ioppe2ep;
import 'register/account_deletion_notice.dart' as _i2lyzpm9;
export 'auth/email_code_accepted.dart';
export 'auth/email_code_request.dart';
export 'auth/email_identity.dart';
@ -45,6 +46,7 @@ export 'profile/profile_not_found_exception.dart';
export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'profile/public_profile.dart';
export 'register/account_deletion_notice.dart';
class Protocol extends _is.DatabaseSerializationManager {
Protocol._();
@ -56,6 +58,72 @@ class Protocol extends _is.DatabaseSerializationManager {
final Set<_is.SerializationManager> _hostProtocols = {};
static List<_isp.TableDefinition> get targetTableDefinitions => [
_isp.TableDefinition(
name: 'account_deletion_notice',
dartName: 'AccountDeletionNotice',
schema: 'public',
module: 'halkyon_base',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_isp.ColumnDefinition(
name: 'account',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'member',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'createdAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
_isp.ColumnDefinition(
name: 'attempts',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
_isp.ColumnDefinition(
name: 'lastAttemptAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'account_deletion_notice_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'account',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'member',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'email_code_request',
dartName: 'EmailCodeRequest',
@ -383,6 +451,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _ioppe2ep.PublicProfile) {
return _ioppe2ep.PublicProfile.fromJson(data) as T;
}
if (t == _i2lyzpm9.AccountDeletionNotice) {
return _i2lyzpm9.AccountDeletionNotice.fromJson(data) as T;
}
if (t == _is.getType<_iys46ui1.EmailCodeAccepted?>()) {
return (data != null ? _iys46ui1.EmailCodeAccepted.fromJson(data) : null)
as T;
@ -447,6 +518,12 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _ioppe2ep.PublicProfile.fromJson(data) : null)
as T;
}
if (t == _is.getType<_i2lyzpm9.AccountDeletionNotice?>()) {
return (data != null
? _i2lyzpm9.AccountDeletionNotice.fromJson(data)
: null)
as T;
}
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
@ -487,6 +564,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_ioppe2ep.PublicProfile => 'PublicProfile',
_i2lyzpm9.AccountDeletionNotice => 'AccountDeletionNotice',
_ => null,
};
}
@ -532,6 +610,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'ProfileRefusedException';
case _ioppe2ep.PublicProfile():
return 'PublicProfile';
case _i2lyzpm9.AccountDeletionNotice():
return 'AccountDeletionNotice';
}
className = _isp.Protocol().getClassNameForObject(data);
if (className != null) {
@ -588,6 +668,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'PublicProfile') {
return deserialize<_ioppe2ep.PublicProfile>(data['data']);
}
if (dataClassName == 'AccountDeletionNotice') {
return deserialize<_i2lyzpm9.AccountDeletionNotice>(data['data']);
}
if (dataClassName.startsWith('serverpod.')) {
data['className'] = dataClassName.substring(10);
return _isp.Protocol().deserializeByClassName(data);
@ -681,6 +764,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _id1dpd4g.EmailIdentity.t;
case _ioppe2ep.PublicProfile:
return _ioppe2ep.PublicProfile.t;
case _i2lyzpm9.AccountDeletionNotice:
return _i2lyzpm9.AccountDeletionNotice.t;
}
return null;
}

View file

@ -0,0 +1,696 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// That an account is gone, still to be told to a member of the instance
/// (ADR 0016), so the member deletes that person's data too: kept by the
/// register until the member confirms, and tried again hourly.
abstract class AccountDeletionNotice
implements _is.TableRow<int?>, _is.ProtocolSerialization {
AccountDeletionNotice._({
this.id,
required this.account,
required this.member,
required this.createdAt,
required this.attempts,
this.lastAttemptAt,
});
factory AccountDeletionNotice({
int? id,
required _is.UuidValue account,
required String member,
required DateTime createdAt,
required int attempts,
DateTime? lastAttemptAt,
}) = _AccountDeletionNoticeImpl;
factory AccountDeletionNotice.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return AccountDeletionNotice(
id: jsonSerialization['id'] as int?,
account: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['account'],
),
member: jsonSerialization['member'] as String,
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
attempts: jsonSerialization['attempts'] as int,
lastAttemptAt: jsonSerialization['lastAttemptAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['lastAttemptAt'],
),
);
}
static final t = AccountDeletionNoticeTable();
static const db = AccountDeletionNoticeRepository._();
@override
int? id;
/// The account that went: the person's ID in every service.
_is.UuidValue account;
/// The member's web server, as the register's configuration names it.
String member;
/// When the account went, in UTC.
DateTime createdAt;
/// How often telling the member has failed so far.
int attempts;
/// When it was last tried, in UTC; null before the first try.
DateTime? lastAttemptAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [AccountDeletionNotice]
/// with some or all fields replaced by the given arguments.
@_is.useResult
AccountDeletionNotice copyWith({
int? id,
_is.UuidValue? account,
String? member,
DateTime? createdAt,
int? attempts,
DateTime? lastAttemptAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.AccountDeletionNotice',
if (id != null) 'id': id,
'account': account.toJson(),
'member': member,
'createdAt': createdAt.toJson(),
'attempts': attempts,
if (lastAttemptAt != null) 'lastAttemptAt': lastAttemptAt?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static AccountDeletionNoticeInclude include() {
return AccountDeletionNoticeInclude._();
}
static AccountDeletionNoticeIncludeList includeList({
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
AccountDeletionNoticeInclude? include,
}) {
return AccountDeletionNoticeIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _AccountDeletionNoticeImpl extends AccountDeletionNotice {
_AccountDeletionNoticeImpl({
int? id,
required _is.UuidValue account,
required String member,
required DateTime createdAt,
required int attempts,
DateTime? lastAttemptAt,
}) : super._(
id: id,
account: account,
member: member,
createdAt: createdAt,
attempts: attempts,
lastAttemptAt: lastAttemptAt,
);
/// Returns a shallow copy of this [AccountDeletionNotice]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
AccountDeletionNotice copyWith({
Object? id = _Undefined,
_is.UuidValue? account,
String? member,
DateTime? createdAt,
int? attempts,
Object? lastAttemptAt = _Undefined,
}) {
return AccountDeletionNotice(
id: id is int? ? id : this.id,
account: account ?? this.account,
member: member ?? this.member,
createdAt: createdAt ?? this.createdAt,
attempts: attempts ?? this.attempts,
lastAttemptAt: lastAttemptAt is DateTime?
? lastAttemptAt
: this.lastAttemptAt,
);
}
}
class AccountDeletionNoticeUpdateTable
extends _is.UpdateTable<AccountDeletionNoticeTable> {
AccountDeletionNoticeUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> account(_is.UuidValue value) =>
_is.ColumnValue(
table.account,
value,
);
_is.ColumnValue<String, String> member(String value) => _is.ColumnValue(
table.member,
value,
);
_is.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
_is.ColumnValue(
table.createdAt,
value,
);
_is.ColumnValue<int, int> attempts(int value) => _is.ColumnValue(
table.attempts,
value,
);
_is.ColumnValue<DateTime, DateTime> lastAttemptAt(DateTime? value) =>
_is.ColumnValue(
table.lastAttemptAt,
value,
);
}
class AccountDeletionNoticeTable extends _is.Table<int?> {
AccountDeletionNoticeTable({super.tableRelation})
: super(tableName: 'account_deletion_notice') {
updateTable = AccountDeletionNoticeUpdateTable(this);
account = _is.ColumnUuid(
'account',
this,
);
member = _is.ColumnString(
'member',
this,
);
createdAt = _is.ColumnDateTime(
'createdAt',
this,
);
attempts = _is.ColumnInt(
'attempts',
this,
);
lastAttemptAt = _is.ColumnDateTime(
'lastAttemptAt',
this,
);
}
late final AccountDeletionNoticeUpdateTable updateTable;
/// The account that went: the person's ID in every service.
late final _is.ColumnUuid account;
/// The member's web server, as the register's configuration names it.
late final _is.ColumnString member;
/// When the account went, in UTC.
late final _is.ColumnDateTime createdAt;
/// How often telling the member has failed so far.
late final _is.ColumnInt attempts;
/// When it was last tried, in UTC; null before the first try.
late final _is.ColumnDateTime lastAttemptAt;
@override
List<_is.Column> get columns => [
id,
account,
member,
createdAt,
attempts,
lastAttemptAt,
];
}
class AccountDeletionNoticeInclude extends _is.IncludeObject {
AccountDeletionNoticeInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => AccountDeletionNotice.t;
}
class AccountDeletionNoticeIncludeList extends _is.IncludeList {
AccountDeletionNoticeIncludeList._({
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(AccountDeletionNotice.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => AccountDeletionNotice.t;
}
class AccountDeletionNoticeRepository {
const AccountDeletionNoticeRepository._();
/// Returns a list of [AccountDeletionNotice]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<AccountDeletionNotice>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<AccountDeletionNotice>(
where: where?.call(AccountDeletionNotice.t),
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [AccountDeletionNotice] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<AccountDeletionNotice?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? where,
int? offset,
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<AccountDeletionNotice>(
where: where?.call(AccountDeletionNotice.t),
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [AccountDeletionNotice] by its [id] or null if no such row exists.
Future<AccountDeletionNotice?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<AccountDeletionNotice>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [AccountDeletionNotice]s in the list and returns the inserted rows.
///
/// The returned [AccountDeletionNotice]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> insert(
_is.DatabaseSession session,
List<AccountDeletionNotice> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<AccountDeletionNotice>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [AccountDeletionNotice] and returns the inserted row.
///
/// The returned [AccountDeletionNotice] will have its `id` field set.
Future<AccountDeletionNotice> insertRow(
_is.DatabaseSession session,
AccountDeletionNotice row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<AccountDeletionNotice>(
row,
transaction: transaction,
);
}
/// Upserts all [AccountDeletionNotice]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [AccountDeletionNotice]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> upsert(
_is.DatabaseSession session,
List<AccountDeletionNotice> rows, {
required _is.ColumnSelections<AccountDeletionNoticeTable> conflictColumns,
_is.ColumnSelections<AccountDeletionNoticeTable>? updateColumns,
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<AccountDeletionNotice>(
rows,
conflictColumns: conflictColumns(AccountDeletionNotice.t),
updateColumns: updateColumns?.call(AccountDeletionNotice.t),
updateWhere: updateWhere?.call(AccountDeletionNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [AccountDeletionNotice] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [AccountDeletionNotice] will have its `id` field set.
Future<AccountDeletionNotice?> upsertRow(
_is.DatabaseSession session,
AccountDeletionNotice row, {
required _is.ColumnSelections<AccountDeletionNoticeTable> conflictColumns,
_is.ColumnSelections<AccountDeletionNoticeTable>? updateColumns,
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<AccountDeletionNotice>(
row,
conflictColumns: conflictColumns(AccountDeletionNotice.t),
updateColumns: updateColumns?.call(AccountDeletionNotice.t),
updateWhere: updateWhere?.call(AccountDeletionNotice.t),
transaction: transaction,
);
}
/// Updates all [AccountDeletionNotice]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> update(
_is.DatabaseSession session,
List<AccountDeletionNotice> rows, {
_is.ColumnSelections<AccountDeletionNoticeTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<AccountDeletionNotice>(
rows,
columns: columns?.call(AccountDeletionNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [AccountDeletionNotice]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<AccountDeletionNotice> updateRow(
_is.DatabaseSession session,
AccountDeletionNotice row, {
_is.ColumnSelections<AccountDeletionNoticeTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<AccountDeletionNotice>(
row,
columns: columns?.call(AccountDeletionNotice.t),
transaction: transaction,
);
}
/// Updates a single [AccountDeletionNotice] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<AccountDeletionNotice?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<AccountDeletionNoticeUpdateTable>
columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<AccountDeletionNotice>(
id,
columnValues: columnValues(AccountDeletionNotice.t.updateTable),
transaction: transaction,
);
}
/// Updates all [AccountDeletionNotice]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<AccountDeletionNoticeUpdateTable>
columnValues,
required _is.WhereExpressionBuilder<AccountDeletionNoticeTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<AccountDeletionNotice>(
columnValues: columnValues(AccountDeletionNotice.t.updateTable),
where: where(AccountDeletionNotice.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [AccountDeletionNotice]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> delete(
_is.DatabaseSession session,
List<AccountDeletionNotice> rows, {
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<AccountDeletionNotice>(
rows,
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [AccountDeletionNotice].
Future<AccountDeletionNotice> deleteRow(
_is.DatabaseSession session,
AccountDeletionNotice row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<AccountDeletionNotice>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<AccountDeletionNotice>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<AccountDeletionNoticeTable> where,
_is.OrderByBuilder<AccountDeletionNoticeTable>? orderBy,
_is.OrderByListBuilder<AccountDeletionNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<AccountDeletionNotice>(
where: where(AccountDeletionNotice.t),
orderBy: orderBy?.call(AccountDeletionNotice.t),
orderByList: orderByList?.call(AccountDeletionNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<AccountDeletionNoticeTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<AccountDeletionNotice>(
where: where?.call(AccountDeletionNotice.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [AccountDeletionNotice] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<AccountDeletionNoticeTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<AccountDeletionNotice>(
where: where(AccountDeletionNotice.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:serverpod/serverpod.dart';
/// What a host deletes of a person who goes, besides what halkyon_base
@ -32,6 +33,7 @@ final class HalkyonBase {
required this.minimumAppBuild,
this.deletePersonData = const [],
this.profilePageNote,
this.accounts = const HoldsRegister(),
});
/// Which app this server serves, as its apps know it (`learn` for Halkyon
@ -61,10 +63,19 @@ final class HalkyonBase {
/// nothing.
final ProfilePageNote? profilePageNote;
/// How this server takes part in the instance's accounts (ADR 0016): the
/// register (the default, also for an instance of one service) or one of
/// its members; a host reads it from the operator's environment
/// (`AccountsMode.fromEnvironment`).
final AccountsMode accounts;
/// Sets [pod] up with [base]. Call it before the pod starts; calling it
/// again replaces the configuration (tests do).
static void configure(Serverpod pod, HalkyonBase base) => _ofPod[pod] = base;
/// How [pod] was set up; null when [configure] wasn't called for it.
static HalkyonBase? maybeOf(Serverpod pod) => _ofPod[pod];
/// How [pod] was set up; throws when [configure] wasn't called for it.
static HalkyonBase of(Serverpod pod) =>
_ofPod[pod] ??

View file

@ -1,18 +1,22 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/instance/data_location.dart';
import 'package:halkyon_base_server/src/instance/source_code.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:serverpod/serverpod.dart';
/// What every Halkyon instance tells its apps. Public: an app asks before
/// anyone is signed in.
class InstanceEndpoint extends Endpoint {
/// This instance's basics: the app it serves, GitHub sign-in only with the operator's OAuth
/// app, email sign-in only where it can mail codes, where its source is,
/// the oldest app build it works with, and where it keeps people's data
/// and its backups.
/// This instance's basics: the app it serves, GitHub sign-in only with the
/// operator's OAuth app, email sign-in only where the register can mail
/// codes (this server, or on a member the register, ADR 0016), where its
/// source is, the oldest app build it works with, and where it keeps
/// people's data and its backups.
Future<InstanceBasics> basics(Session session) async {
final pod = session.serverpod;
final base = HalkyonBase.of(pod);
@ -21,7 +25,9 @@ class InstanceEndpoint extends Endpoint {
gitHubClientId: gitHubSignInAvailable(pod)
? pod.getPassword('githubClientId')
: null,
emailSignIn: emailSignInAvailable(pod),
emailSignIn: isMember(pod)
? await _registerOffersEmail(session)
: emailSignInAvailable(pod),
sourceCodeUrl: sourceCodeUrl(pod),
minimumAppBuild: base.minimumAppBuild,
dataLocation: dataLocation(),
@ -29,3 +35,15 @@ class InstanceEndpoint extends Endpoint {
);
}
}
/// Whether the instance's register offers email sign-in, asked by a member;
/// not while it can't be reached, so the app still learns the rest.
Future<bool> _registerOffersEmail(Session session) async {
try {
return await AccountRegister.of(
session.serverpod,
).emailSignInOffered(session);
} on RegisterUnavailableException {
return false;
}
}

View file

@ -39,7 +39,11 @@ Future<({int status, String html, String? location})> profilePage(
location: null,
);
}
final own = profilePageUrl(session.serverpod, profile.handle);
// The register's own address for it: a member's server sends the page's
// visitors there (ADR 0016).
final own =
Uri.tryParse(profile.pageUrl ?? '') ??
profilePageUrl(session.serverpod, profile.handle);
if (host.split(':').first.toLowerCase() != own.host.toLowerCase()) {
return (status: 308, html: '', location: '$own');
}

View file

@ -0,0 +1,21 @@
### That an account is gone, still to be told to a member of the instance
### (ADR 0016), so the member deletes that person's data too: kept by the
### register until the member confirms, and tried again hourly.
class: AccountDeletionNotice
serverOnly: true
table: account_deletion_notice
fields:
### The account that went: the person's ID in every service.
account: UuidValue
### The member's web server, as the register's configuration names it.
member: String
### When the account went, in UTC.
createdAt: DateTime
### How often telling the member has failed so far.
attempts: int
### When it was last tried, in UTC; null before the first try.
lastAttemptAt: DateTime?
indexes:
account_deletion_notice_idx:
fields: account, member
unique: true

View file

@ -1,7 +1,10 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:serverpod/serverpod.dart';
/// The instance's register of accounts (ADR 0016): what one person is across
@ -13,12 +16,28 @@ import 'package:serverpod/serverpod.dart';
/// A person is named by their account ID, which is their `AuthUser`'s ID in
/// every service of the instance.
abstract interface class AccountRegister {
/// The register [pod] asks.
/// The register [pod] asks: its own database while it holds the register
/// (also unconfigured, as tests of a host's own code run), else the
/// register's server ([RemoteRegister]), which needs the instance's shared
/// secret.
static AccountRegister of(Serverpod pod) =>
_ofPod[pod] ??= const LocalRegister();
_ofPod[pod] ??
switch (HalkyonBase.maybeOf(pod)?.accounts) {
MemberOfRegister(:final register) => RemoteRegister(
register,
secret:
instanceSecretOf(pod) ??
(throw StateError(
'A member of an instance needs its shared secret: set '
'instanceSecret in the passwords (INSTANCE_SECRET).',
)),
),
HoldsRegister() || null => const LocalRegister(),
};
/// Puts [register] in place of the one [pod] asks: tests do.
static void use(Serverpod pod, AccountRegister register) =>
/// Puts [register] in place of the one [pod] asks, or with null the one its
/// configuration names back: tests do.
static void use(Serverpod pod, AccountRegister? register) =>
_ofPod[pod] = register;
static final _ofPod = Expando<AccountRegister>('Account register');
@ -84,4 +103,61 @@ abstract interface class AccountRegister {
/// first publish); nothing when it is fixed already or they have no
/// profile.
Future<void> fixHandle(Session session, UuidValue person);
/// Whether people can sign in with an email address: the register mails
/// the codes.
Future<bool> emailSignInOffered(Session session);
/// Mails a sign-in code to [address] in [language], the codes asked for
/// from [network] counted; the request it answers. Refusals as
/// `sendSignInCode`'s.
Future<UuidValue> sendSignInCode(
Session session, {
required String address,
required String language,
required String network,
});
/// Accepts [code] for [request] from [person]: the address becomes theirs
/// (an account of the register, under their ID) unless it is another
/// person's, whose switch token it then gives. Refusals as
/// `acceptSignInCode`'s.
Future<EmailCodeAccepted> verifySignInCode(
Session session, {
required UuidValue person,
required UuidValue request,
required String code,
});
/// The account a switch token names, once only. Refusals as
/// `completeEmailSwitch`'s.
Future<UuidValue> completeEmailSwitch(
Session session, {
required String switchToken,
});
/// Links the GitHub account [gitHubUserId] to [person] (an account of the
/// register, under their ID). Refusals as `linkGitHubAccount`'s.
Future<void> linkGitHub(
Session session, {
required UuidValue person,
required String gitHubUserId,
});
/// The account that signs in with the GitHub account [gitHubUserId]; null
/// for none.
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId);
/// How [person] signs in: with GitHub, an email address, both or neither.
Future<SignInWays> signInWaysOf(Session session, UuidValue person);
/// Deletes the account [person] with everything the register's server has
/// of them, and has every member of the instance delete theirs.
Future<void> deleteAccount(Session session, UuidValue person);
/// Those of [people] that are accounts of the register.
Future<Set<UuidValue>> knownAccounts(
Session session,
Set<UuidValue> people,
);
}

View file

@ -0,0 +1,99 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:serverpod/serverpod.dart';
/// How this server takes part in its instance's accounts (ADR 0016).
sealed class AccountsMode {
const AccountsMode();
/// The mode the operator's environment names: a member of the register at
/// `ACCOUNT_REGISTER_URL` where it is set, else the register, telling the
/// members at `INSTANCE_MEMBERS` (addresses separated by commas or spaces)
/// when an account goes. Empty values count as unset: Compose passes unset
/// variables on as empty strings.
factory AccountsMode.fromEnvironment([Map<String, String>? environment]) {
final env = environment ?? Platform.environment;
if (_address(env['ACCOUNT_REGISTER_URL'], 'ACCOUNT_REGISTER_URL')
case final register?) {
return MemberOfRegister(register);
}
return HoldsRegister(
members: [
for (final member in (env['INSTANCE_MEMBERS'] ?? '').split(
RegExp(r'[\s,]+'),
))
?_address(member, 'INSTANCE_MEMBERS'),
],
);
}
static Uri? _address(String? value, String variable) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
final uri = Uri.tryParse(trimmed);
if (uri == null ||
!(uri.isScheme('http') || uri.isScheme('https')) ||
uri.host.isEmpty) {
throw ArgumentError.value(trimmed, variable, 'is no HTTP(S) address');
}
return uri;
}
}
/// This server holds the instance's register: its database keeps the
/// accounts, their ways in, handles and profiles. With a single service, it
/// is its own register and has no [members].
final class HoldsRegister extends AccountsMode {
const HoldsRegister({this.members = const []});
/// The web servers of the instance's other services, told when an account
/// goes so they delete that person's data too.
final List<Uri> members;
}
/// This server is a member: it asks the instance's register at [register]
/// (its web server) for everything about accounts, and keeps only its own
/// data of each person, under the register's account ID.
final class MemberOfRegister extends AccountsMode {
const MemberOfRegister(this.register);
/// The register's web server, as this server reaches it (its address on
/// the instance's own network when both run on one host).
final Uri register;
}
/// The instance's shared secret (`instanceSecret` in the passwords;
/// `INSTANCE_SECRET` in a deploy's `.env`), which its services send with
/// every call between them; null without one, and then no such call is made
/// or taken.
String? instanceSecretOf(Serverpod pod) =>
_secretOf[pod] ??
switch (pod.getPassword('instanceSecret')) {
final secret? when secret.isNotEmpty => secret,
_ => null,
};
/// Puts [secret] in place of [pod]'s passwords' `instanceSecret`, or with
/// null the passwords' back: tests do, as the passwords of a test run hold
/// none.
void useInstanceSecret(Serverpod pod, String? secret) =>
_secretOf[pod] = secret;
final _secretOf = Expando<String>('Instance secret');
/// Whether [presented] is [secret], compared in constant time: both hashed,
/// so neither their length nor where they first differ shows in how long it
/// takes.
bool isInstanceSecret(String presented, String secret) {
final a = sha256.convert(utf8.encode(presented)).bytes;
final b = sha256.convert(utf8.encode(secret)).bytes;
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}

View file

@ -0,0 +1,154 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:http/http.dart' as http;
import 'package:serverpod/serverpod.dart';
/// The path on a member's web server where the register says an account is
/// gone (ADR 0016).
const accountGonePath = '/halkyon-base/member/account-gone';
/// Tells the members of an instance that an account is gone, so each deletes
/// that person's data too. Tests put their own ([use]).
abstract interface class MemberNotifier {
/// How [pod] tells its members: over HTTP with the instance's shared
/// secret.
static MemberNotifier of(Serverpod pod) =>
_ofPod[pod] ?? HttpMemberNotifier(secret: instanceSecretOf(pod));
/// Puts [notifier] in place of the one [pod] uses, or with null the one
/// over HTTP back: tests do.
static void use(Serverpod pod, MemberNotifier? notifier) =>
_ofPod[pod] = notifier;
static final _ofPod = Expando<MemberNotifier>('Member notifier');
/// Tells [member] (its web server) that [account] is gone; whether it
/// confirmed having deleted that person's data.
Future<bool> accountGone(Uri member, UuidValue account);
}
/// [MemberNotifier] over HTTP: a POST to the member's [accountGonePath] with
/// the account, the instance's [secret] as the bearer token; none without a
/// secret.
final class HttpMemberNotifier implements MemberNotifier {
HttpMemberNotifier({required this.secret, this.client});
/// The instance's shared secret; null: nobody is told.
final String? secret;
/// What the requests go through; a new one each time without.
final http.Client? client;
@override
Future<bool> accountGone(Uri member, UuidValue account) async {
final secret = this.secret;
if (secret == null) {
return false;
}
final client = this.client ?? http.Client();
try {
final response = await client
.post(
member.resolve(accountGonePath),
headers: {
'authorization': 'Bearer $secret',
'content-type': 'application/json',
},
body: jsonEncode({'account': account.uuid}),
)
.timeout(const Duration(seconds: 10));
return response.statusCode == 200;
} on Object {
return false;
} finally {
if (this.client == null) {
client.close();
}
}
}
}
/// Keeps a notice for each of [members] that [account] is gone, in
/// [transaction] (the deletion's): told at once once it holds, and again
/// hourly until each member confirmed ([deliverDeletionNotices]).
Future<void> keepDeletionNotices(
Session session,
UuidValue account,
List<Uri> members, {
required Transaction transaction,
}) async {
final now = clock.now().toUtc();
for (final member in members) {
// INSERT INTO "account_deletion_notice" ("account", "member",
// "createdAt", "attempts") VALUES ($1, $2, $3, 0)
// (a notice already kept for the pair stays as it is)
final kept = await AccountDeletionNotice.db.findFirstRow(
session,
where: (t) => t.account.equals(account) & t.member.equals('$member'),
transaction: transaction,
);
if (kept == null) {
await AccountDeletionNotice.db.insertRow(
session,
AccountDeletionNotice(
account: account,
member: '$member',
createdAt: now,
attempts: 0,
),
transaction: transaction,
);
}
}
}
/// Tells the members the accounts gone that they haven't confirmed yet, and
/// returns how many confirmed: the notices of those that did go, the others
/// stay, counted, for the next time (the register's hourly pruning, and at
/// once after each deletion), those tried least first, 500 at a time.
/// Notices to a member the operator no longer names (`INSTANCE_MEMBERS`) are
/// dropped: its own pruning finds whom the register no longer knows
/// (`forgetPeopleTheRegisterForgot`).
Future<int> deliverDeletionNotices(Session session, {DateTime? now}) async {
final members = switch (HalkyonBase.maybeOf(session.serverpod)?.accounts) {
HoldsRegister(:final members) => {for (final member in members) '$member'},
_ => const <String>{},
};
// DELETE FROM "account_deletion_notice" WHERE "member" <> ALL($1)
await AccountDeletionNotice.db.deleteWhere(
session,
where: (t) => t.member.notInSet(members),
);
final notifier = MemberNotifier.of(session.serverpod);
// SELECT … FROM "account_deletion_notice"
// ORDER BY "attempts", "id" LIMIT 500
final notices = await AccountDeletionNotice.db.find(
session,
orderByList: (t) => [t.attempts.asc(), t.id.asc()],
limit: 500,
);
var confirmed = 0;
for (final notice in notices) {
final member = Uri.parse(notice.member);
if (await notifier.accountGone(member, notice.account)) {
// DELETE FROM "account_deletion_notice" WHERE "id" = $1
await AccountDeletionNotice.db.deleteRow(session, notice);
confirmed++;
} else {
// UPDATE "account_deletion_notice" SET "attempts" = $1,
// "lastAttemptAt" = $2 WHERE "id" = $3
await AccountDeletionNotice.db.updateRow(
session,
notice.copyWith(
attempts: notice.attempts + 1,
lastAttemptAt: (now ?? clock.now()).toUtc(),
),
);
}
}
return confirmed;
}

Some files were not shown because too many files have changed in this diff Show more