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
9 changed files with 50068 additions and 37 deletions

View file

@ -16,11 +16,13 @@ COPY tool/workspace_only.sh tool/
COPY apps/learn/halkyon_learn_server apps/learn/halkyon_learn_server
COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client
COPY packages/halkyon_core packages/halkyon_core
COPY packages/halkyon_oidc_server packages/halkyon_oidc_server
COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# The workspace root with only the server and the modules it builds on (halkyon_base,
# and the OpenID Connect module halkyon_base signs in with, ADR 0023): neither the
# and the OpenID Connect module halkyon_base signs in with, ADR 0023), with
# halkyon_core, whose BIP-39 list halkyon_base takes a matching code from: neither the
# other apps nor Learn's client package are needed to build the server, and the Flutter
# packages would need Flutter. The base module's client comes along only because the
# module's tests depend on it (SRP's two halves, held to each other), and pub resolves
@ -28,7 +30,7 @@ COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# client depends on it; the server never imports either. Everything else stays as the
# repository has it, above all the dependency overrides, which bring the server our
# fork of the offline sync package (fetched with the git the Dart image has).
RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN sh tool/workspace_only.sh apps/learn/halkyon_learn_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN dart pub get
WORKDIR /app/apps/learn/halkyon_learn_server
RUN dart build cli --target bin/main.dart --output build

View file

@ -11,13 +11,17 @@ import 'package:serverpod/serverpod.dart';
/// goes too.
class PruningFutureCall extends FutureCall {
/// Prunes one batch of anonymous accounts and logs how many people went,
/// and forgets the resets that lapsed (ADR 0017).
/// forgets the resets that lapsed (ADR 0017), and deletes the new accounts
/// left unapproved for 14 days, with the waiting passes past their time
/// (ADR 0021, D25).
Future<void> pruneAccounts(Session session) async {
final now = clock.now();
final pruned = await pruneAnonymousPeople(session);
final resets = await forgetLapsedResets(session, now: now);
final unapproved = await forgetUnapprovedSignUps(session, now: now);
session.log(
'Pruned $pruned anonymous people and $resets lapsed resets.',
'Pruned $pruned anonymous people, $resets lapsed resets and '
'$unapproved accounts never approved.',
);
}

View file

@ -283,7 +283,9 @@ class FilePruningPruneFilesFutureCall extends _is.FutureCall
}
/// Prunes one batch of anonymous accounts and logs how many people went,
/// and forgets the resets that lapsed (ADR 0017).
/// forgets the resets that lapsed (ADR 0017), and deletes the new accounts
/// left unapproved for 14 days, with the waiting passes past their time
/// (ADR 0021, D25).
class PruningPruneAccountsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {
@override

View file

@ -0,0 +1,157 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "waiting_account",
"dartName": "WaitingAccount",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "since",
"fieldName": "since",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "words",
"fieldName": "words",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "suspendedAt",
"fieldName": "suspendedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "waiting_account_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "waiting_pass",
"dartName": "WaitingPass",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "passHash",
"fieldName": "passHash",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "madeAt",
"fieldName": "madeAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "waiting_pass_hash_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "passHash"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "waiting_pass_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,89 @@
BEGIN;
--
-- ACTION CREATE TABLE
--
CREATE TABLE "waiting_account" (
"id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL,
"since" timestamp without time zone NOT NULL,
"words" text NOT NULL,
"suspendedAt" timestamp without time zone
);
-- Indexes
CREATE UNIQUE INDEX "waiting_account_person_idx" ON "waiting_account" USING btree ("authUserId");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "waiting_pass" (
"id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL,
"passHash" text NOT NULL,
"madeAt" timestamp without time zone NOT NULL
);
-- Indexes
CREATE UNIQUE INDEX "waiting_pass_hash_idx" ON "waiting_pass" USING btree ("passHash");
CREATE INDEX "waiting_pass_person_idx" ON "waiting_pass" USING btree ("authUserId");
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261025135744595-waiting-accounts', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261025135744595-waiting-accounts', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261025135744270-waiting-accounts', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261025135744270-waiting-accounts', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_oidc
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_oidc', '20261023180644988', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -48,3 +48,4 @@
20261023131636437
20261023181810112-oidc-module
20261023192913178-simplelogin-keys
20261025135744595-waiting-accounts