feat(base)!: sign-ups as the operator says, waiting accounts, operators (ADR 0021, step 1)

SIGN_UP takes open (unset), approval or closed; anything else stops the
server as it starts. Closed refuses a SimpleLogin sign-up before its
handle is counted, an address sign-up only once the code has proved the
address, so nothing tells whether an address has an account. Approval
lets a new account wait, at most ten at once: no session is issued for a
waiting or suspended account, only a pass (32 random bytes, kept as its
SHA-256, 30 days, five per account) and four BIP-39 words the operator
reads out to match it. The pass looks, forgets, and deletes an account
that was never approved; nothing else.

Operators hold the scope operator, given first by SQL (D32), then named
in the app: InstanceOperatorEndpoint lists, approves and refuses waiting
accounts, finds accounts, suspends them (ending their sessions), and
names and takes back operators, never the last. account.operating()
counts the waiting for the badge. Members ask the register (five new
operations, a refusal kind signUp), and say closed while it is out of
reach. Never-approved accounts go after 14 days in the daily pruning.

The migration only adds the tables waiting_account and waiting_pass, so
the server before it keeps working on the new schema.

BREAKING CHANGE: a register and its members must run this version
together: a member asks the register operations an older one doesn't
know.

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-25 15:04:21 +01:00
commit a060fbc21d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
57 changed files with 50068 additions and 37 deletions

View file

@ -21,8 +21,10 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys, whether its password signs in too and has the keys' proof key (D34), when a reset of its keys takes effect (D31) and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the password and the phrase lost (D31–D33), `startReset` with such a sign-in, `completeReset` 72 hours later with another (new keys, every other session ending), `callOffResets` with the password's proof, which any proof of the password or the phrase also does; only an account that signs in with SimpleLogin alone changes here, one whose password signs in only unlocking (D34); the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
| `instance` | `basics`: the app it serves, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays`, `operating`: how many accounts wait, for an operator (Halkyon Learn's ADR 0021, D34; null for anyone else) | signed in |
| `waiting` | with a waiting account's pass (Halkyon Learn's ADR 0021, D31): `look` (waiting, suspended, approved, which takes the pass, or unknown), `deleteAccount` (a new account never approved), `forget` | without a session |
| `instanceOperator` | `waiting`, `approve`, `refuse` (a new account, deleted), `accounts` (by the start of a handle, at most 50), `suspend` (every session ending, the account waiting anew; never an operator), `operators`, `appoint` (by handle, approving one that waits), `takeBack` (never the last operator) | `identified` and `operator`, on the register |
| `instance` | `basics`: the app it serves, whether codes by mail, password sign-in and SimpleLogin are on, who may sign up (`SIGN_UP`), the source link, the oldest app build, where data and backups are | anyone |
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
@ -90,6 +92,26 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
`simpleLogin.begin` takes the way back as a `SimpleLoginReturn`), the rest 1 to 512
characters of base64url; anything else gets a page saying the sign-in can't go on,
repeating nothing it was given, in English or German. It keeps and logs nothing.
- **Sign-ups and operators** (Halkyon Learn's ADR 0021, D19 to D26 and D31 to D33), on
the register: `SIGN_UP` is `open` (unset), `approval` or `closed`. Closed, nobody new
signs up (`SignUpRefusedException`, `closed`), SimpleLogin's sign-up refused before
anything is counted, an address's once its code proved it (only that tells whether it
has an account, which still takes its password). With approval, a new account waits as
it is made (`waiting_account`), at most 10 at once (`tooManyWaiting`), with a matching
code of four words of BIP-39's English list (halkyon_core's). Every session in every
service is made by `issueSession`, which asks the register first: an account that
waits gets none, and a pass for that device is thrown in its place
(`AccountWaitingException`: the pass, the words, the handle, whether suspended),
before a member makes it a person. The register keeps the pass's SHA-256
(`waiting_pass`), 30 days, at most five per account. Approved, the pass says so once
and goes, and the person signs in once more as before, which opens the keys; a pass is
never worth a session. An operator is an account with the scope `operator` (on its
sessions too, as scopes are copied there), the first made by SQL (the ADR gives it),
others appointed in the app; an operator never waits. Suspending ends the account's
sessions everywhere and makes it wait anew, its data kept, deleted by its pass never:
deleting it takes the password and a code. The daily pruning deletes new accounts
unapproved after 14 days and forgets passes past their time
(`forgetUnapprovedSignUps`).
- **No anonymous accounts** (Halkyon Learn's ADR 0020, D25): every account has a way
in. An anonymous account left from before signs into nothing new and keeps no
password or SimpleLogin; the app deletes its own as it starts (`account.delete`), and
@ -101,14 +123,17 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
itself. Never the `Forwarded` header, which Caddy passes on as the visitor wrote it,
so never Serverpod's `remoteInfo`. An IPv6 visitor counts by their /64
(`networkKeyOf`), which one line or server holds whole.
- Refusals are `PasswordRefusedException`, `EmailSignInRefusedException` and
`ProfileRefusedException`, each with a reason; `ProfileNotFoundException` where there
is no profile.
- Refusals are `PasswordRefusedException`, `EmailSignInRefusedException`,
`ProfileRefusedException`, `SignUpRefusedException` and `OperatorRefusedException`,
each with a reason; `ProfileNotFoundException` where there is no profile;
`AccountWaitingException` in a session's place.
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
`grantIdentified`, `deletePerson`, the pruning (`pruneAnonymousPeople`,
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`, `forgetUnconfirmedAuthenticators`,
`forgetLapsedResets`), short codes, `sourceCodeUrl`,
`grantIdentified`, the `instanceOperator` scope, `deletePerson`, the pruning
(`pruneAnonymousPeople`, `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`,
`forgetUnconfirmedAuthenticators`, `forgetLapsedResets`, `forgetUnapprovedSignUps`),
the sign-up mode (`signUpModeOf`, `useSignUpMode` for tests), short codes,
`sourceCodeUrl`,
`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
@ -478,7 +503,8 @@ one; `SIMPLELOGIN_CALLBACK_URL`, the https address the SimpleLogin app registere
it isn't the register's own page (`/simplelogin-callback` on its web server's public
address; development names learn.halkyon.eu's); `SIGN_IN_WAYS`, the ways in the
register offers of those set up, `simplelogin`, `password` or both (unset: every one), a
name it doesn't know stopping the server (`addInstanceRoutes`);
name it doesn't know stopping the server (`addInstanceRoutes`); `SIGN_UP`, who may make
an account, `open` (unset), `approval` or `closed`, anything else stopping the server;
`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,
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs;

View file

@ -7,6 +7,14 @@
/// (ADR 0020), the ways in an operator offers, and the module's models.
library;
export 'src/admission/admission.dart'
show
forgetUnapprovedSignUps,
maxWaitingPasses,
maxWaitingSignUps,
unapprovedSignUpLifetime,
waitingPassLifetime;
export 'src/admission/sign_up_config.dart';
export 'src/auth/account.dart';
export 'src/auth/auth_config.dart';
export 'src/auth/code_mail.dart';

View file

@ -0,0 +1,17 @@
### The account signed in to waits for an operator (Halkyon Learn's ADR
### 0021, D23 and D31): a new one not approved yet, or one an operator
### suspended. It gets no session; its device keeps [pass] for the wait
### screen's calls (`waiting`), and shows [words].
exception: AccountWaitingException
fields:
### The waiting pass: a random secret the instance keeps only the hash
### of, for this device alone.
pass: String
### The matching code, four words of BIP-39's English list (D33), which
### the operator sees beside the handle and reads out.
words: List<String>
### The account's handle; null for an account without one.
handle: String?
### Whether an operator suspended the account (D22), which was approved
### before; otherwise it is new.
suspended: bool

View file

@ -0,0 +1,307 @@
import 'dart:convert';
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/admission/sign_up_config.dart';
import 'package:halkyon_base_server/src/auth/account.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_core/halkyon_core.dart' show bip39English;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// Who signs up, and the accounts that wait for an operator (Halkyon Learn's
// ADR 0021, D19 to D26 and D31), kept by the register alone. A waiting
// account gets no session in any service: every session is made by
// `issueSession` (`people.dart`), which asks the register first ([waitOf])
// and hands the device a pass in its place.
/// At most this many new accounts wait at once (D25), so strangers can't
/// flood the operator's list; suspended ones aren't counted.
const maxWaitingSignUps = 10;
/// How long a new account waits unapproved before it is deleted (D25).
const unapprovedSignUpLifetime = Duration(days: 14);
/// How long a waiting pass holds, approved or not: a device that hasn't
/// looked since signs in anew.
const waitingPassLifetime = Duration(days: 30);
/// At most this many passes per account: each sign-in while it waits makes
/// one, the oldest going first.
const maxWaitingPasses = 5;
final _random = Random.secure();
/// The key of the lock that sign-ups waiting take while they count and add
/// themselves, so that two at once never make 11 (`pg_advisory_xact_lock`).
const _signUpsLock = 0x68616c6b0001;
/// Refused ([SignUpRefusal.closed]) where [pod]'s operator takes no new
/// accounts: called where nothing tells whether an account exists, before
/// anything is counted or used.
void refuseClosedSignUps(Serverpod pod) {
if (signUpModeOf(pod) == SignUpMode.closed) {
throw SignUpRefusedException(reason: SignUpRefusal.closed);
}
}
/// What [account], new, gets as it is made in [transaction] (D19): nothing
/// more where sign-ups are open; a wait for approval, with its matching
/// code, where they need it, refused ([SignUpRefusal.tooManyWaiting]) while
/// [maxWaitingSignUps] wait; refused ([SignUpRefusal.closed]) where they are
/// closed. A refusal rolls the account back with the transaction.
Future<void> admitNewAccount(
Session session,
UuidValue account, {
required Transaction transaction,
}) async {
switch (signUpModeOf(session.serverpod)) {
case SignUpMode.open:
return;
case SignUpMode.closed:
throw SignUpRefusedException(reason: SignUpRefusal.closed);
case SignUpMode.approval:
await session.db.unsafeExecute(
'SELECT pg_advisory_xact_lock($_signUpsLock)',
transaction: transaction,
);
// SELECT count(*) FROM "waiting_account" WHERE "suspendedAt" IS NULL
if (await WaitingAccount.db.count(
session,
where: (t) => t.suspendedAt.equals(null),
transaction: transaction,
) >=
maxWaitingSignUps) {
throw SignUpRefusedException(reason: SignUpRefusal.tooManyWaiting);
}
// INSERT INTO "waiting_account" ("authUserId", "since", "words")
// VALUES ($1, $2, $3)
await WaitingAccount.db.insertRow(
session,
WaitingAccount(
authUserId: account,
since: clock.now().toUtc(),
words: newMatchingWords().join(' '),
),
transaction: transaction,
);
}
}
/// A new matching code (D33): four words of BIP-39's English list, each
/// chosen at random (44 bits).
List<String> newMatchingWords() => [
for (var i = 0; i < 4; i++)
bip39English[_random.nextInt(bip39English.length)],
];
/// The wait [account] is in, with a new pass for the device signing in:
/// null where it may have a session, as it doesn't wait, or it is an
/// operator, who never waits (D32) and whose wait goes now.
Future<AccountWaitingException?> waitOf(
Session session,
UuidValue account,
) => session.db.transaction((transaction) async {
// SELECT … FROM "waiting_account" WHERE "authUserId" = $1
final waiting = await WaitingAccount.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
if (waiting == null) {
return null;
}
if (await _isOperator(session, account, transaction: transaction)) {
// DELETE FROM "waiting_account" WHERE "id" = $1
await WaitingAccount.db.deleteRow(
session,
waiting,
transaction: transaction,
);
return null;
}
final pass = base64Url
.encode([
for (var i = 0; i < 32; i++) _random.nextInt(256),
])
.replaceAll('=', '');
// INSERT INTO "waiting_pass" ("authUserId", "passHash", "madeAt")
// VALUES ($1, $2, $3)
await WaitingPass.db.insertRow(
session,
WaitingPass(
authUserId: account,
passHash: _hashOf(pass),
madeAt: clock.now().toUtc(),
),
transaction: transaction,
);
// DELETE FROM "waiting_pass" WHERE "id" IN (SELECT "id" FROM
// "waiting_pass" WHERE "authUserId" = $1
// ORDER BY "madeAt" DESC, "id" DESC OFFSET 5)
final beyond = await WaitingPass.db.find(
session,
where: (t) => t.authUserId.equals(account),
orderByList: (t) => [t.madeAt.desc(), t.id.desc()],
offset: maxWaitingPasses,
transaction: transaction,
);
if (beyond.isNotEmpty) {
await WaitingPass.db.delete(session, beyond, transaction: transaction);
}
// SELECT "handle" FROM "public_profile" WHERE "authUserId" = $1
final profile = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
return AccountWaitingException(
pass: pass,
words: waiting.words.split(' '),
handle: profile?.handle,
suspended: waiting.suspendedAt != null,
);
});
/// Where the account [pass] was handed out for stands (D31): approved, the
/// pass going with this answer, as an operator's account is (D32); unknown
/// for a pass nobody has (forgotten, past its time, or gone with its
/// account).
Future<WaitingState> lookWhileWaiting(Session session, String pass) =>
session.db.transaction((transaction) async {
final held = await _passOf(session, pass, transaction: transaction);
if (held == null) {
return WaitingState.unknown;
}
final waiting = await _waitingOf(
session,
held.authUserId,
transaction: transaction,
);
if (waiting != null &&
!await _isOperator(
session,
held.authUserId,
transaction: transaction,
)) {
return waiting.suspendedAt == null
? WaitingState.waiting
: WaitingState.suspended;
}
if (waiting != null) {
// DELETE FROM "waiting_account" WHERE "id" = $1
await WaitingAccount.db.deleteRow(
session,
waiting,
transaction: transaction,
);
}
// DELETE FROM "waiting_pass" WHERE "id" = $1
await WaitingPass.db.deleteRow(session, held, transaction: transaction);
return WaitingState.approved;
});
/// Deletes the account [pass] was handed out for, new and waiting, with all
/// it has (D23: a refusal would lose nothing either); whether it did. Not a
/// suspended one (D22), whose deletion takes its owner's password and a
/// code, as every account's does (ADR 0017), nor for a pass nobody has.
Future<bool> deleteWaitingAccount(Session session, String pass) async {
final held = await _passOf(session, pass);
if (held == null) {
return false;
}
final waiting = await _waitingOf(session, held.authUserId);
if (waiting == null || waiting.suspendedAt != null) {
return false;
}
await deletePerson(session, held.authUserId);
return true;
}
/// Forgets [pass], as its device no longer waits with it.
Future<void> forgetWaitingPass(Session session, String pass) async {
// DELETE FROM "waiting_pass" WHERE "passHash" = $1
await WaitingPass.db.deleteWhere(
session,
where: (t) => t.passHash.equals(_hashOf(pass)),
);
}
/// Deletes the new accounts unapproved for [unapprovedSignUpLifetime] at
/// [now] (D25), at most [limit], each with all it has ([deletePerson]), and
/// forgets the passes past [waitingPassLifetime]; how many accounts went.
/// Suspended accounts stay, with their data, until an operator approves
/// them. Daily, with the pruning.
Future<int> forgetUnapprovedSignUps(
Session session, {
required DateTime now,
int limit = 500,
}) async {
// SELECT … FROM "waiting_account"
// WHERE "suspendedAt" IS NULL AND "since" < $1 ORDER BY "since" LIMIT $2
final unapproved = await WaitingAccount.db.find(
session,
where: (t) =>
t.suspendedAt.equals(null) &
(t.since < now.toUtc().subtract(unapprovedSignUpLifetime)),
orderBy: (t) => t.since,
limit: limit,
);
for (final waiting in unapproved) {
await deletePerson(session, waiting.authUserId);
}
// DELETE FROM "waiting_pass" WHERE "madeAt" < $1
await WaitingPass.db.deleteWhere(
session,
where: (t) => t.madeAt < now.toUtc().subtract(waitingPassLifetime),
);
return unapproved.length;
}
/// [account]'s wait; null where it doesn't wait.
Future<WaitingAccount?> _waitingOf(
Session session,
UuidValue account, {
Transaction? transaction,
}) =>
// SELECT … FROM "waiting_account" WHERE "authUserId" = $1
WaitingAccount.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
/// The pass held as [pass], while it holds; null for none.
Future<WaitingPass?> _passOf(
Session session,
String pass, {
Transaction? transaction,
}) =>
// SELECT … FROM "waiting_pass" WHERE "passHash" = $1 AND "madeAt" >= $2
WaitingPass.db.findFirstRow(
session,
where: (t) =>
t.passHash.equals(_hashOf(pass)) &
(t.madeAt >= clock.now().toUtc().subtract(waitingPassLifetime)),
transaction: transaction,
);
/// Whether [account] is an operator.
Future<bool> _isOperator(
Session session,
UuidValue account, {
required Transaction transaction,
}) async =>
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
(await AuthUser.db.findById(
session,
account,
transaction: transaction,
))?.scopeNames.contains(instanceOperator.name) ??
false;
/// What the instance keeps of [pass]: its SHA-256, in hex. The pass is 256
/// random bits, so nothing slower is needed.
String _hashOf(String pass) => sha256.convert(utf8.encode(pass)).toString();

View file

@ -0,0 +1,36 @@
import 'dart:io';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
/// Who may make an account on [pod]'s instance, as its operator says
/// (`SIGN_UP`, Halkyon Learn's ADR 0021, D19): open where unset. The
/// register's alone counts: a member asks the register. Throws a
/// [StateError] for a value it doesn't know ([signUpModeFrom]), which hosts
/// check as they start (`addInstanceRoutes`).
SignUpMode signUpModeOf(Serverpod pod) => switch (_modeOf[pod]) {
(:final mode?) => mode,
_ => signUpModeFrom(Platform.environment['SIGN_UP']),
};
/// The mode [value] (`SIGN_UP`) names, whatever its case: `open`,
/// `approval` or `closed`; open for none (unset or empty, as Compose passes
/// an unset variable). Throws a [StateError] for anything else.
SignUpMode signUpModeFrom(String? value) {
final name = (value ?? '').trim().toLowerCase();
if (name.isEmpty) {
return SignUpMode.open;
}
return SignUpMode.values.asNameMap()[name] ??
(throw StateError(
'SIGN_UP is "$value": it is '
'${SignUpMode.values.map((mode) => mode.name).join(', ')}.',
));
}
/// Puts [mode] in place of the operator's `SIGN_UP` on [pod], null meaning
/// what the environment says: tests do.
void useSignUpMode(Serverpod pod, SignUpMode? mode) =>
_modeOf[pod] = (mode: mode);
final _modeOf = Expando<({SignUpMode? mode})>('Sign-up mode');

View file

@ -0,0 +1,11 @@
### Who may make an account on this instance (Halkyon Learn's ADR 0021,
### D19), as its operator says (SIGN_UP).
enum: SignUpMode
serialized: byName
values:
### Anyone: a new account signs in at once.
- open
### Anyone, but a new account waits until an operator approves it.
- approval
### Nobody new: only the accounts there are sign in.
- closed

View file

@ -0,0 +1,8 @@
### Why a sign-up was refused (Halkyon Learn's ADR 0021, D19 and D25).
enum: SignUpRefusal
serialized: byName
values:
### The instance takes no new accounts (SIGN_UP=closed).
- closed
### As many new accounts wait for an operator as may wait at once.
- tooManyWaiting

View file

@ -0,0 +1,6 @@
### A sign-up was refused before anything was made; the identity or the
### code it came with stays as it was.
exception: SignUpRefusedException
fields:
### Why it was refused.
reason: SignUpRefusal

View file

@ -0,0 +1,23 @@
### An account that gets no session until an operator approves it (Halkyon
### Learn's ADR 0021, D22 to D25, D31): new where the instance's sign-ups
### wait for approval, or suspended. Kept by the register alone.
class: WaitingAccount
serverOnly: true
table: waiting_account
fields:
### The account: its person's ID (deleted with them, `deletePerson`).
authUserId: UuidValue
### When it signed up, or was suspended.
since: DateTime
### The matching code (D33): four words of BIP-39's English list, by
### spaces.
words: String
### When an operator suspended it, approved before (D22); null for an
### account new here, which is deleted unapproved after 14 days (D25) and
### which an operator may refuse.
suspendedAt: DateTime?
indexes:
### Once per account.
waiting_account_person_idx:
fields: authUserId
unique: true

View file

@ -0,0 +1,25 @@
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart';
/// The wait screen's calls (Halkyon Learn's ADR 0021, D23 and D31), with
/// the pass a waiting account's device was handed in place of a session
/// (`AccountWaitingException`), and nothing else: no session opens them.
/// The instance's register keeps the passes (ADR 0016).
class WaitingEndpoint extends Endpoint {
/// Where the account [pass] was handed out for stands: waiting, suspended,
/// approved (the pass gone with this answer: the person signs in once
/// more, as before), or unknown.
Future<WaitingState> look(Session session, {required String pass}) =>
AccountRegister.of(session.serverpod).lookWhileWaiting(session, pass);
/// Deletes the new account [pass] was handed out for, with all it has;
/// whether it did: not a suspended one, whose deletion takes its owner's
/// password and a code (ADR 0017), nor for a pass unknown.
Future<bool> deleteAccount(Session session, {required String pass}) =>
AccountRegister.of(session.serverpod).deleteWaitingAccount(session, pass);
/// Forgets [pass], as its device no longer waits with it.
Future<void> forget(Session session, {required String pass}) =>
AccountRegister.of(session.serverpod).forgetWaitingPass(session, pass);
}

View file

@ -0,0 +1,19 @@
### A waiting account's pass on one device (Halkyon Learn's ADR 0021, D31):
### handed out where a session would have been, it opens the wait screen's
### calls alone. Only its hash is kept.
class: WaitingPass
serverOnly: true
table: waiting_pass
fields:
### The account: its person's ID (deleted with them, `deletePerson`).
authUserId: UuidValue
### SHA-256 of the pass, in hex.
passHash: String
### When it was handed out; it holds for 30 days.
madeAt: DateTime
indexes:
waiting_pass_hash_idx:
fields: passHash
unique: true
waiting_pass_person_idx:
fields: authUserId

View file

@ -0,0 +1,15 @@
### Where a waiting account stands, as its wait screen asks with its pass
### (Halkyon Learn's ADR 0021, D31).
enum: WaitingState
serialized: byName
values:
### New, not approved yet.
- waiting
### Suspended by an operator, waiting to be approved anew.
- suspended
### Approved: the person signs in once more, as they signed in before,
### which opens their keys. The pass is gone with this answer.
- approved
### The pass is unknown: forgotten, past its time, or its account gone
### (refused by an operator, unapproved for too long, or deleted).
- unknown

View file

@ -12,8 +12,8 @@ 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, their
/// password with their keys, authenticators and recovery codes, and the
/// person with their other identities. On the instance's register, an account (an
/// password with their keys, authenticators and recovery codes, their wait
/// for an operator, and the person 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.
@ -127,6 +127,19 @@ Future<void> deletePerson(
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Their wait for an operator and its passes (Halkyon Learn's ADR 0021).
// DELETE FROM "waiting_pass" WHERE "authUserId" = $1
await WaitingPass.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "waiting_account" WHERE "authUserId" = $1
await WaitingAccount.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Their SimpleLogin identity goes with them, and what is left from
// before, an anonymous account or a GitHub ID (ON DELETE CASCADE).
// Gone already on a member the register told first (ADR 0016).

View file

@ -1,6 +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/operator/operators.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/password/password_change.dart';
@ -96,4 +98,17 @@ class AccountEndpoint extends Endpoint {
Future<SignInWays> signInWays(Session session) => AccountRegister.of(
session.serverpod,
).signInWaysOf(session, session.authenticated!.authUserId);
/// How many accounts wait for an operator, for the badge on an operator's
/// Settings (Halkyon Learn's ADR 0021, D34); null where the caller isn't
/// one. It reads the caller's own role from their own session, which every
/// operator's call requires (`InstanceOperatorEndpoint`), so the app knows
/// whether to show the operator's card.
Future<int?> operating(Session session) async {
if (isMember(session.serverpod) ||
!session.authenticated!.scopes.contains(instanceOperator)) {
return null;
}
return await waitingCount(session);
}
}

View file

@ -7,3 +7,8 @@ import 'package:serverpod/serverpod.dart';
/// Endpoints that involve other people or several devices require it through
/// `requiredScopes`, never through checks of their own.
const identified = Scope('identified');
/// An instance's operators (Halkyon Learn's ADR 0021, D20 and D32), on the
/// register: they approve, refuse and suspend accounts, and name other
/// operators. The first is made by SQL; an operator never waits.
const instanceOperator = Scope('operator');

View file

@ -0,0 +1,136 @@
/* 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:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
/// The account signed in to waits for an operator (Halkyon Learn's ADR
/// 0021, D23 and D31): a new one not approved yet, or one an operator
/// suspended. It gets no session; its device keeps [pass] for the wait
/// screen's calls (`waiting`), and shows [words].
abstract class AccountWaitingException
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
AccountWaitingException._({
required this.pass,
required this.words,
this.handle,
required this.suspended,
});
factory AccountWaitingException({
required String pass,
required List<String> words,
String? handle,
required bool suspended,
}) = _AccountWaitingExceptionImpl;
factory AccountWaitingException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return AccountWaitingException(
pass: jsonSerialization['pass'] as String,
words: _ibewtqgw.Protocol().deserialize<List<String>>(
jsonSerialization['words'],
),
handle: jsonSerialization['handle'] as String?,
suspended: _is.BoolJsonExtension.fromJson(jsonSerialization['suspended']),
);
}
/// The waiting pass: a random secret the instance keeps only the hash
/// of, for this device alone.
String pass;
/// The matching code, four words of BIP-39's English list (D33), which
/// the operator sees beside the handle and reads out.
List<String> words;
/// The account's handle; null for an account without one.
String? handle;
/// Whether an operator suspended the account (D22), which was approved
/// before; otherwise it is new.
bool suspended;
/// Returns a shallow copy of this [AccountWaitingException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
AccountWaitingException copyWith({
String? pass,
List<String>? words,
String? handle,
bool? suspended,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.AccountWaitingException',
'pass': pass,
'words': words.toJson(),
if (handle != null) 'handle': handle,
'suspended': suspended,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.AccountWaitingException',
'pass': pass,
'words': words.toJson(),
if (handle != null) 'handle': handle,
'suspended': suspended,
};
}
@override
String toString() {
return 'AccountWaitingException(pass: $pass, words: $words, handle: $handle, suspended: $suspended)';
}
}
class _Undefined {}
class _AccountWaitingExceptionImpl extends AccountWaitingException {
_AccountWaitingExceptionImpl({
required String pass,
required List<String> words,
String? handle,
required bool suspended,
}) : super._(
pass: pass,
words: words,
handle: handle,
suspended: suspended,
);
/// Returns a shallow copy of this [AccountWaitingException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
AccountWaitingException copyWith({
String? pass,
List<String>? words,
Object? handle = _Undefined,
bool? suspended,
}) {
return AccountWaitingException(
pass: pass ?? this.pass,
words: words ?? this.words.map((e0) => e0).toList(),
handle: handle is String? ? handle : this.handle,
suspended: suspended ?? this.suspended,
);
}
}

View file

@ -0,0 +1,47 @@
/* 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;
/// Who may make an account on this instance (Halkyon Learn's ADR 0021,
/// D19), as its operator says (SIGN_UP).
enum SignUpMode implements _is.SerializableModel {
/// Anyone: a new account signs in at once.
open,
/// Anyone, but a new account waits until an operator approves it.
approval,
/// Nobody new: only the accounts there are sign in.
closed;
static SignUpMode fromJson(String name) {
switch (name) {
case 'open':
return SignUpMode.open;
case 'approval':
return SignUpMode.approval;
case 'closed':
return SignUpMode.closed;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignUpMode"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,41 @@
/* 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;
/// Why a sign-up was refused (Halkyon Learn's ADR 0021, D19 and D25).
enum SignUpRefusal implements _is.SerializableModel {
/// The instance takes no new accounts (SIGN_UP=closed).
closed,
/// As many new accounts wait for an operator as may wait at once.
tooManyWaiting;
static SignUpRefusal fromJson(String name) {
switch (name) {
case 'closed':
return SignUpRefusal.closed;
case 'tooManyWaiting':
return SignUpRefusal.tooManyWaiting;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignUpRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,78 @@
/* 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;
import '../admission/sign_up_refusal.dart' as _ib0mzqlk;
/// A sign-up was refused before anything was made; the identity or the
/// code it came with stays as it was.
abstract class SignUpRefusedException
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
SignUpRefusedException._({required this.reason});
factory SignUpRefusedException({required _ib0mzqlk.SignUpRefusal reason}) =
_SignUpRefusedExceptionImpl;
factory SignUpRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return SignUpRefusedException(
reason: _ib0mzqlk.SignUpRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why it was refused.
_ib0mzqlk.SignUpRefusal reason;
/// Returns a shallow copy of this [SignUpRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SignUpRefusedException copyWith({_ib0mzqlk.SignUpRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SignUpRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.SignUpRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'SignUpRefusedException(reason: $reason)';
}
}
class _SignUpRefusedExceptionImpl extends SignUpRefusedException {
_SignUpRefusedExceptionImpl({required _ib0mzqlk.SignUpRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [SignUpRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SignUpRefusedException copyWith({_ib0mzqlk.SignUpRefusal? reason}) {
return SignUpRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,668 @@
/* 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;
/// An account that gets no session until an operator approves it (Halkyon
/// Learn's ADR 0021, D22 to D25, D31): new where the instance's sign-ups
/// wait for approval, or suspended. Kept by the register alone.
abstract class WaitingAccount
implements _is.TableRow<int?>, _is.ProtocolSerialization {
WaitingAccount._({
this.id,
required this.authUserId,
required this.since,
required this.words,
this.suspendedAt,
});
factory WaitingAccount({
int? id,
required _is.UuidValue authUserId,
required DateTime since,
required String words,
DateTime? suspendedAt,
}) = _WaitingAccountImpl;
factory WaitingAccount.fromJson(Map<String, dynamic> jsonSerialization) {
return WaitingAccount(
id: jsonSerialization['id'] as int?,
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
since: _is.DateTimeJsonExtension.fromJson(jsonSerialization['since']),
words: jsonSerialization['words'] as String,
suspendedAt: jsonSerialization['suspendedAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['suspendedAt'],
),
);
}
static final t = WaitingAccountTable();
static const db = WaitingAccountRepository._();
@override
int? id;
/// The account: its person's ID (deleted with them, `deletePerson`).
_is.UuidValue authUserId;
/// When it signed up, or was suspended.
DateTime since;
/// The matching code (D33): four words of BIP-39's English list, by
/// spaces.
String words;
/// When an operator suspended it, approved before (D22); null for an
/// account new here, which is deleted unapproved after 14 days (D25) and
/// which an operator may refuse.
DateTime? suspendedAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [WaitingAccount]
/// with some or all fields replaced by the given arguments.
@_is.useResult
WaitingAccount copyWith({
int? id,
_is.UuidValue? authUserId,
DateTime? since,
String? words,
DateTime? suspendedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.WaitingAccount',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'since': since.toJson(),
'words': words,
if (suspendedAt != null) 'suspendedAt': suspendedAt?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static WaitingAccountInclude include() {
return WaitingAccountInclude._();
}
static WaitingAccountIncludeList includeList({
_is.WhereExpressionBuilder<WaitingAccountTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
WaitingAccountInclude? include,
}) {
return WaitingAccountIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _WaitingAccountImpl extends WaitingAccount {
_WaitingAccountImpl({
int? id,
required _is.UuidValue authUserId,
required DateTime since,
required String words,
DateTime? suspendedAt,
}) : super._(
id: id,
authUserId: authUserId,
since: since,
words: words,
suspendedAt: suspendedAt,
);
/// Returns a shallow copy of this [WaitingAccount]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
WaitingAccount copyWith({
Object? id = _Undefined,
_is.UuidValue? authUserId,
DateTime? since,
String? words,
Object? suspendedAt = _Undefined,
}) {
return WaitingAccount(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
since: since ?? this.since,
words: words ?? this.words,
suspendedAt: suspendedAt is DateTime? ? suspendedAt : this.suspendedAt,
);
}
}
class WaitingAccountUpdateTable extends _is.UpdateTable<WaitingAccountTable> {
WaitingAccountUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
_is.UuidValue value,
) => _is.ColumnValue(
table.authUserId,
value,
);
_is.ColumnValue<DateTime, DateTime> since(DateTime value) => _is.ColumnValue(
table.since,
value,
);
_is.ColumnValue<String, String> words(String value) => _is.ColumnValue(
table.words,
value,
);
_is.ColumnValue<DateTime, DateTime> suspendedAt(DateTime? value) =>
_is.ColumnValue(
table.suspendedAt,
value,
);
}
class WaitingAccountTable extends _is.Table<int?> {
WaitingAccountTable({super.tableRelation})
: super(tableName: 'waiting_account') {
updateTable = WaitingAccountUpdateTable(this);
authUserId = _is.ColumnUuid(
'authUserId',
this,
);
since = _is.ColumnDateTime(
'since',
this,
);
words = _is.ColumnString(
'words',
this,
);
suspendedAt = _is.ColumnDateTime(
'suspendedAt',
this,
);
}
late final WaitingAccountUpdateTable updateTable;
/// The account: its person's ID (deleted with them, `deletePerson`).
late final _is.ColumnUuid authUserId;
/// When it signed up, or was suspended.
late final _is.ColumnDateTime since;
/// The matching code (D33): four words of BIP-39's English list, by
/// spaces.
late final _is.ColumnString words;
/// When an operator suspended it, approved before (D22); null for an
/// account new here, which is deleted unapproved after 14 days (D25) and
/// which an operator may refuse.
late final _is.ColumnDateTime suspendedAt;
@override
List<_is.Column> get columns => [
id,
authUserId,
since,
words,
suspendedAt,
];
}
class WaitingAccountInclude extends _is.IncludeObject {
WaitingAccountInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => WaitingAccount.t;
}
class WaitingAccountIncludeList extends _is.IncludeList {
WaitingAccountIncludeList._({
_is.WhereExpressionBuilder<WaitingAccountTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(WaitingAccount.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => WaitingAccount.t;
}
class WaitingAccountRepository {
const WaitingAccountRepository._();
/// Returns a list of [WaitingAccount]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<WaitingAccount>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<WaitingAccountTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<WaitingAccount>(
where: where?.call(WaitingAccount.t),
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [WaitingAccount] 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<WaitingAccount?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<WaitingAccountTable>? where,
int? offset,
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<WaitingAccount>(
where: where?.call(WaitingAccount.t),
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [WaitingAccount] by its [id] or null if no such row exists.
Future<WaitingAccount?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<WaitingAccount>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [WaitingAccount]s in the list and returns the inserted rows.
///
/// The returned [WaitingAccount]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<WaitingAccount>> insert(
_is.DatabaseSession session,
List<WaitingAccount> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<WaitingAccount>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [WaitingAccount] and returns the inserted row.
///
/// The returned [WaitingAccount] will have its `id` field set.
Future<WaitingAccount> insertRow(
_is.DatabaseSession session,
WaitingAccount row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<WaitingAccount>(
row,
transaction: transaction,
);
}
/// Upserts all [WaitingAccount]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 [WaitingAccount]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<WaitingAccount>> upsert(
_is.DatabaseSession session,
List<WaitingAccount> rows, {
required _is.ColumnSelections<WaitingAccountTable> conflictColumns,
_is.ColumnSelections<WaitingAccountTable>? updateColumns,
_is.WhereExpressionBuilder<WaitingAccountTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<WaitingAccount>(
rows,
conflictColumns: conflictColumns(WaitingAccount.t),
updateColumns: updateColumns?.call(WaitingAccount.t),
updateWhere: updateWhere?.call(WaitingAccount.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [WaitingAccount] 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 [WaitingAccount] will have its `id` field set.
Future<WaitingAccount?> upsertRow(
_is.DatabaseSession session,
WaitingAccount row, {
required _is.ColumnSelections<WaitingAccountTable> conflictColumns,
_is.ColumnSelections<WaitingAccountTable>? updateColumns,
_is.WhereExpressionBuilder<WaitingAccountTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<WaitingAccount>(
row,
conflictColumns: conflictColumns(WaitingAccount.t),
updateColumns: updateColumns?.call(WaitingAccount.t),
updateWhere: updateWhere?.call(WaitingAccount.t),
transaction: transaction,
);
}
/// Updates all [WaitingAccount]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<WaitingAccount>> update(
_is.DatabaseSession session,
List<WaitingAccount> rows, {
_is.ColumnSelections<WaitingAccountTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<WaitingAccount>(
rows,
columns: columns?.call(WaitingAccount.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [WaitingAccount]. 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<WaitingAccount> updateRow(
_is.DatabaseSession session,
WaitingAccount row, {
_is.ColumnSelections<WaitingAccountTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<WaitingAccount>(
row,
columns: columns?.call(WaitingAccount.t),
transaction: transaction,
);
}
/// Updates a single [WaitingAccount] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<WaitingAccount?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<WaitingAccountUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<WaitingAccount>(
id,
columnValues: columnValues(WaitingAccount.t.updateTable),
transaction: transaction,
);
}
/// Updates all [WaitingAccount]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<WaitingAccount>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<WaitingAccountUpdateTable> columnValues,
required _is.WhereExpressionBuilder<WaitingAccountTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<WaitingAccount>(
columnValues: columnValues(WaitingAccount.t.updateTable),
where: where(WaitingAccount.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [WaitingAccount]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<WaitingAccount>> delete(
_is.DatabaseSession session,
List<WaitingAccount> rows, {
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<WaitingAccount>(
rows,
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [WaitingAccount].
Future<WaitingAccount> deleteRow(
_is.DatabaseSession session,
WaitingAccount row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<WaitingAccount>(
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<WaitingAccount>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<WaitingAccountTable> where,
_is.OrderByBuilder<WaitingAccountTable>? orderBy,
_is.OrderByListBuilder<WaitingAccountTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<WaitingAccount>(
where: where(WaitingAccount.t),
orderBy: orderBy?.call(WaitingAccount.t),
orderByList: orderByList?.call(WaitingAccount.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<WaitingAccountTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<WaitingAccount>(
where: where?.call(WaitingAccount.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [WaitingAccount] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<WaitingAccountTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<WaitingAccount>(
where: where(WaitingAccount.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

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