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:
parent
4107cc3087
commit
65c5cd740d
39 changed files with 41906 additions and 167 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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] ??
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue