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
+26 -1
View File
@@ -44,7 +44,32 @@ async fn describe_server() {
let did = r["did"].as_str().expect("describeServer must return a did");
assert!(did.starts_with("did:web:"), "did = {did}");
assert!(r["available_user_domains"].is_array());
assert_eq!(r["invite_code_required"], json!(false));
// `invite_code_required` used to be a hardcoded `false` here. It is
// now whatever `PDS_INVITE_REQUIRED` says, so this suite — which
// talks to whatever PDS the developer happens to be running — can
// only assert the type. That the value actually tracks the switch is
// pinned in `invite_integration.rs`, which starts a PDS with the
// flag set both ways and checks both answers.
assert!(
r["invite_code_required"].is_boolean(),
"invite_code_required = {}",
r["invite_code_required"]
);
// If this test process shares the server's environment (the
// documented way to run the suite is
// `set -a; . ./.env; set +a; cargo test`), hold it to the exact
// value too.
if let Ok(raw) = std::env::var("PDS_INVITE_REQUIRED") {
let expected = matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
);
assert_eq!(
r["invite_code_required"],
json!(expected),
"describeServer disagrees with PDS_INVITE_REQUIRED={raw}"
);
}
}
/// `GET /.well-known/did.json` — the document the AppView fetches to