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
+81 -4
View File
@@ -2,6 +2,7 @@ mod appview_push;
mod car;
mod dag_cbor;
mod firehose;
mod invite;
mod jwt_issuer;
mod keys;
mod password;
@@ -17,6 +18,16 @@ use serde_json::json;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
/// Usage line for the binary itself. The subcommands are operator
/// tooling; the no-argument form is the server, which is what every
/// deploy script and systemd unit invokes.
const USAGE: &str = "\
usage: pds-server [command]
(no command) run the PDS server
invite … manage invite codes (see `pds-server invite help`)
help show this message";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load `.env` from the working directory (and upwards) if present.
@@ -25,11 +36,57 @@ async fn main() -> anyhow::Result<()> {
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
// Argument dispatch, by hand.
//
// The workspace carries no argument-parsing crate and this does not
// justify adding one: exactly one subcommand exists, and the
// overwhelmingly common invocation is the bare binary. Anything we
// do not recognise is an error rather than being ignored — a
// mistyped `pds-server invit create` that silently booted a server
// would look like it worked and mint no codes.
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(|s| s.as_str()) {
None => {}
Some("invite") => {
// CLI output is meant to be read and pasted, so keep the
// log stream quiet unless the operator asked for it. Without
// this, `sqlx::migrate` chatters over the codes.
init_tracing("warn");
return invite::run_cli(&args[1..]).await;
}
Some("help") | Some("-h") | Some("--help") => {
println!("{USAGE}");
return Ok(());
}
Some(other) => {
eprintln!("pds-server: unknown command: {other}\n\n{USAGE}");
std::process::exit(2);
}
}
init_tracing("info");
let cfg = at_shared::config::AppConfig::from_env()?;
// Announce the relaxed security posture before we bind a port.
//
// `PDS_INVITE_REQUIRED` is the only switch in `AppConfig` that
// defaults to *open* (so the integration suites and dev instances
// can keep creating throwaway accounts), which makes this warning
// the only thing standing between "we made the PDS public" and
// "anyone on the internet can mint repos on our disk". Mirrors
// `appview`'s `auth::log_startup_posture`.
if !cfg.pds_invite_required {
warn!(
"PDS_INVITE_REQUIRED is not true — com.atproto.server.createAccount accepts \
ANY caller, and every accepted account allocates a repo, a server-held key \
pair and firehose events. Fine on a private/dev instance; on a publicly \
reachable PDS set PDS_INVITE_REQUIRED=true and hand out codes with \
`pds-server invite create`."
);
} else {
info!("PDS_INVITE_REQUIRED=true — createAccount requires a valid invite code");
}
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(32)
.min_connections(2)
@@ -71,6 +128,21 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Install the tracing subscriber, with `default_filter` as the level
/// when `RUST_LOG` says nothing.
///
/// Factored out because the two entry points want different defaults:
/// the server wants `info`, the `invite` subcommand wants `warn` so that
/// migration chatter does not land in the middle of a list of codes the
/// operator is about to copy.
fn init_tracing(default_filter: &str) {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)),
)
.init();
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(root))
@@ -241,7 +313,12 @@ async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerRe
.pds_handle_dns_zone
.trim_start_matches('.')
.to_string()],
invite_code_required: false,
// The real switch, not a hardcoded `false`. A client reads this
// to decide whether to ask the user for a code *before*
// collecting a handle and password — advertising `false` on a
// server that then answers `400 InvalidInviteCode` sends the
// user back to the start of a form they already filled in.
invite_code_required: state.cfg.pds_invite_required,
links: json!({
"termsOfService": null,
"privacyPolicy": null,