feat(pds): Einladungscodes für createAccount

Die Instanz soll öffentlich erreichbar werden. createAccount hatte bis
jetzt keinerlei Schranke: kein Code, kein Rate-Limit, describeServer
meldete invite_code_required hartkodiert false. Jeder hätte beliebig viele
Konten anlegen können, jedes mit eigenem Repo, Blöcken und
Firehose-Events.

Zwei Tabellen: invite_codes trägt Zähler und Sperre, invite_code_uses
protokolliert, welcher DID welchen Code eingelöst hat — ein Code kann
mehrere Konten wert sein, also ist "wer hat ihn benutzt" eine Menge. Die
DID hat bewusst keinen Fremdschlüssel: das Protokoll soll das Konto
überleben.

Der Kern ist das Einlösen ohne Rennen. Geprüft wird nicht vorher, sondern
im Schreiben selbst:

  UPDATE invite_codes SET used_count = used_count + 1
   WHERE code = $1 AND NOT disabled AND used_count < max_uses
  RETURNING used_count, max_uses

Der Verlierer zweier gleichzeitiger Registrierungen blockiert auf der
Zeilensperre, liest danach die committete Zeile neu, wertet die
WHERE-Klausel erneut aus, trifft nichts und bekommt 400. Ein
Lesen-dann-Schreiben hätte beide durchgelassen — ich habe genau das
probeweise eingebaut, woraufhin die Nebenläufigkeitstests umfielen
("a one-use code let 5 concurrent registrations through").

Das Einlösen ist die erste Anweisung in der bestehenden Transaktion von
create_account: die Zeilensperre hält über die Konto-Inserts, und ein
Rollback gibt den Code wieder frei. Wer ein Handle-Rennen verliert,
verliert nicht auch noch seine Einladung.

Alle Fehlerfälle — fehlend, leer, unbekannt, gesperrt, aufgebraucht —
liefern dieselbe Meldung, damit der unauthentifizierte Endpoint kein
Orakel zum Abtasten des Code-Raums wird.

Codes erzeugt das Binary selbst, statt dafür einen Endpoint zu öffnen:
`pds-server invite create --count 5 --uses 1`, dazu list und disable.

PDS_INVITE_REQUIRED steht auf false per Default — sonst brechen die
Integrationstests und jede Dev-Instanz. Der Server warnt beim Start
deutlich, solange es aus ist, und describeServer meldet jetzt den
tatsächlichen Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-10 21:06:45 +02:00
co-authored by Claude Opus 5
parent b58cb75cfe
commit f04d63dd7b
9 changed files with 1913 additions and 5 deletions
+52
View File
@@ -33,6 +33,36 @@ fn default_pds_firehose_enabled() -> bool {
true
}
/// Default for `PDS_INVITE_REQUIRED`.
///
/// `false` — `com.atproto.server.createAccount` stays open unless the
/// operator says otherwise. This is the one security switch in this file
/// that fails *open*, and it does so for a concrete reason: dozens of
/// integration tests across `pds-server` and `appview` create throwaway
/// accounts against a locally running PDS, and every dev instance is
/// bootstrapped the same way. Defaulting to `true` would break all of
/// them on the next `cargo test`, and the usual reflex to a suite that
/// suddenly fails is to switch the new thing off — which lands you at
/// `false` anyway, only with the flag now looking like the thing that
/// was in the way rather than the thing that protects the server.
///
/// The cost of that choice is that an operator who exposes the PDS
/// publicly without setting the variable gets an open registration
/// endpoint. That is paid for at startup: `pds-server` logs a loud
/// warning on every boot where this is `false`, in the same spirit as
/// `appview`'s `log_startup_posture`. A warning you have to read once
/// per restart is the trade for a test suite that keeps working.
///
/// That warning also covers the other way this fails open:
/// [`parse_bool_env`] reads anything it doesn't recognise as `false`, so
/// `PDS_INVITE_REQUIRED=ture` leaves registration open. The operator
/// who typed it sees the same startup warning as the operator who never
/// set the variable at all, which is the only signal that distinguishes
/// "I meant to leave it open" from "I thought I had closed it".
fn default_pds_invite_required() -> bool {
false
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub pds_host: String,
@@ -99,6 +129,24 @@ pub struct AppConfig {
/// [`default_pds_firehose_enabled`] for why.
#[serde(default = "default_pds_firehose_enabled")]
pub pds_firehose_enabled: bool,
/// Whether `com.atproto.server.createAccount` demands a valid invite
/// code. Default `false` — see [`default_pds_invite_required`] for
/// why this switch, alone among the security switches here, fails
/// open.
///
/// When `true`, a request without an `invite_code` (or its
/// camelCase `inviteCode` spelling), or with one that is unknown,
/// disabled or already used up, is rejected with
/// `400 InvalidInviteCode`. The value is also what
/// `describeServer` reports as `invite_code_required`, so a client
/// can find out before it asks the user for a handle.
///
/// Codes are minted out of band with `pds-server invite create`;
/// there is no HTTP endpoint that creates them, on purpose — an
/// open PDS's registration gate should not come with a second
/// public surface that hands out keys to it.
#[serde(default = "default_pds_invite_required")]
pub pds_invite_required: bool,
}
impl AppConfig {
@@ -147,6 +195,10 @@ impl AppConfig {
.ok()
.map(|s| parse_bool_env(&s))
.unwrap_or_else(default_pds_firehose_enabled),
pds_invite_required: std::env::var("PDS_INVITE_REQUIRED")
.ok()
.map(|s| parse_bool_env(&s))
.unwrap_or_else(default_pds_invite_required),
})
}