Compare commits

..
20 Commits
Author SHA1 Message Date
tomdeboneandClaude Opus 5 b7ce114677 docs: Firehose, Follow-Lexicon und Phase 10
architecture.md zeigt den subscribeRepos-Pfad im Diagramm (er stand zuerst
irrtümlich am Pfeil zum externen Relay) und erklärt, warum es Push *und*
Firehose gibt: der eine ist schnell, der andere verlässlich. Dazu, wo die
Spec-Treue endet — die Frame-Hülle ist konform, die Blöcke darin nicht.

deployment.md bekommt einen Firehose-Abschnitt: Transaktionsgarantie, warum
die seq lückenfrei ist und was der globale Advisory-Lock an Durchsatz
kostet, Cursor-Semantik, WebSocket-Upgrade im Proxy, und die fehlende
Retention für firehose_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:46 +02:00
tomdeboneandClaude Opus 5 6fbea4fe6f fix: Follows waren über den Client nicht anlegbar
Der Client legt Follows über createRecord mit app.bsky.graph.follow an. Das
Lexicon war in der PDS aber nicht registriert, und createRecord validiert
per Default — jede Anfrage kam mit

  400 lex validation failed: unknown lexicon: app.bsky.graph.follow

zurück. Der Follow-Button kann also nie funktioniert haben, auch wenn der
Commit, der ihn eingeführt hat, "end-to-end follow / unfollow" heißt. Beim
Gegenprüfen des Firehose-Pfads aufgefallen: der Testaufbau scheiterte schon
am Anlegen des Follows.

Das Lexicon ist jetzt da (subject als DID-String, nicht als strongRef —
genau das, was der Client schickt und was follow_subject_did in der AppView
liest) und registriert. Live geprüft: anlegen, in der AppView indiziert,
löschen, Zeile weg.

Dazu ein zweiter Grund, warum das nie auffiel: create_record_with nahm einen
Parameter `_validate` entgegen und verwarf ihn. Der eine Aufrufer, der
`false` übergab, bekam trotzdem Validierung. Der Parameter wird jetzt
tatsächlich mitgeschickt; der Repost-Pfad steht auf `true`, weil genau das
bisher schon passiert ist und funktioniert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:46 +02:00
tomdeboneandClaude Opus 5 2b695d6892 fix(appview): Unfollows über den Firehose anwendbar machen
Ein Delete-Event trägt nur did + rkey, keinen Record-Body. `follows` hatte
aber nur (follower_did, subject_did) und speicherte den rkey nicht — es gab
also keinen Weg vom rkey zum subject_did, und der Indexer hat solche Ops
geloggt und übersprungen. Unfollows hingen damit allein am Best-Effort-Push,
genau der Abhängigkeit, die der Firehose beseitigen soll.

Migration 0011 ergänzt die rkey-Spalte plus einen partiellen Index für den
Lookup. Der Primärschlüssel bleibt (follower_did, subject_did), damit die
Upserts über Push, Firehose und Replay hinweg idempotent bleiben; ein rkey im
Schlüssel würde aus einem Re-Follow eine zweite Zeile machen und die
Follower-Zahl verdoppeln. Der Index ist bewusst nicht unique: sonst würde
ausgerechnet der Fall, für den das hier existiert — verlorener Delete, dann
ein neuer Create — zu einem abgebrochenen Write.

delete_follow_by_rkey löst und löscht in einem Statement (RETURNING), also
ohne Rennen zwischen Auflösen und Löschen. Findet es nichts — alte Zeile ohne
rkey, schon gelöscht, veralteter rkey — ist das kein Fehler. Der Push-Pfad
über subject_did bleibt unverändert.

Likes und Reposts haben die Lücke nicht: dort ist der rkey Teil der
Zeilenidentität.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:46 +02:00
tomdeboneandClaude Opus 5 124a90dc07 feat(appview): PDS-Firehose konsumieren
Gegenstück zum subscribeRepos-Endpoint: WebSocket-Consumer mit
persistiertem seq-Cursor, Reconnect-Backoff und Behandlung von
#info/OutdatedCursor.

Eigene Cursor-Tabelle statt einer Zeile in jetstream_cursor: dort steht ein
time_us in der Größenordnung 1.7e15, die seq ist ein kleiner Zähler ab 1.
Geteilt hätte GREATEST den PDS-Cursor sofort in eine Zukunft geschoben, die
die PDS nie erreicht.

Kein neuer Indexer-Pfad — jede Op wird in die Single-Op-Form übersetzt, die
apply_commit schon vom Jetstream kennt. Push und Firehose liefern denselben
Commit doppelt; das ist unkritisch, weil die Schreibpfade Upserts sind und
der Dedupe-Index der Notifications den Rest abfängt. Mit einem Test
festgehalten statt vorausgesetzt.

Der CAR-Reader ist neu (es gab nur einen Writer, und der liegt in einem
Binary-Crate ohne lib-Target). Der CBOR-Reader arbeitet mit explizitem
Offset, weil ein Frame zwei hintereinander geschriebene Werte sind, und
akzeptiert CID-Links in beiden Schreibweisen — die Blöcke tragen Strings.

/healthz meldet beide Ströme getrennt; sie fallen unabhängig voneinander
aus.

Verifiziert mit totem Push-Ziel: der Post kam trotzdem an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:23 +02:00
tomdeboneandClaude Opus 5 d6947c2576 fix(pds): CAR-Header-Roots mit Multibase-Identity-Prefix schreiben
Ein DAG-CBOR-Link ist tag(42) um einen Bytestring aus `0x00 || <CID>`. Der
CAR-Header taggte bisher die nackte CID ohne das 0x00 — keine
spec-konforme CAR-Bibliothek kann dem folgen: sie liest das erste Byte als
CID-Version und gibt auf. Betroffen war jede Antwort von getRepo,
getBlocks und getRecord.

Der Header ist nicht content-adressiert — nichts hasht ihn, keine CID hängt
an seinen Bytes. Die Korrektur ändert also ausschließlich, was über die
Leitung geht, und keinen einzigen Identifier. Deshalb ist sie hier gemacht
und nicht auf eine große Migration vertagt.

decode_header akzeptiert weiterhin beide Schreibweisen, damit ein
gespeicherter Repo-Export aus einem älteren Build lesbar bleibt. Das ist
eindeutig und kein Raten: eine echte CID beginnt nie mit 0x00, da steht das
Versions-Varint und Version 0 gibt es nicht.

Nebenbei: sync_list_repos_keyset_pagination lief von ganz vorn durch die
repos-Tabelle (inzwischen 4900 Zeilen) und riss bei zwei Zeilen pro Seite
den eigenen Iterationsdeckel — rot wegen Tabellengröße, nicht wegen
Paginierung. Der Test prüft jetzt die Invarianten, um die es geht:
Erreichbarkeit jedes DIDs über einen unmittelbar davor gesetzten Cursor,
streng aufsteigende Reihenfolge, keine Dubletten, und der zurückgegebene
Cursor ist der letzte DID der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:23 +02:00
tomdeboneandClaude Opus 5 0646fbeebe feat(pds): com.atproto.sync.subscribeRepos — lokaler Firehose
Bisher erreichten eigene Records die AppView nur über den Best-Effort-Push
/internal/ingest-commit. Ging der verloren (AppView kurz weg, Netzwerk-
fehler), war der Post dauerhaft weg: der öffentliche Jetstream kennt diese
PDS nicht, es gab also keinen zweiten Weg.

Jeder Commit schreibt sein Event in derselben Transaktion nach
firehose_events. Damit kann es keinen Commit ohne Event geben — und keine
Sequenz ohne Commit.

Die seq muss lückenfrei sein, sonst ist sie als Cursor wertlos: BIGSERIAL
vergibt Nummern bei INSERT, nicht bei COMMIT, also können zwei Schreiber 5
und 6 ziehen und in umgekehrter Reihenfolge sichtbar werden — ein Leser
dazwischen sieht 6, merkt sich das und erfährt von 5 nie. Ein globaler
pg_advisory_xact_lock unmittelbar vor dem INSERT erzwingt Commit-Reihenfolge
== seq-Reihenfolge. Er wird nach dem per-Repo-FOR-UPDATE genommen, überall in
derselben Reihenfolge, also ohne Deadlock-Risiko. Preis: das Ende jeder
schreibenden Transaktion ist global serialisiert; das steht im Modulkopf.

Der WebSocket-Handler abonniert den Broadcast, *bevor* er die Datenbank
liest, und filtert Live-Events auf seq > Wasserstand. Aus einem Rennen wird
so eine Dublette, die sich filtern lässt, statt einer Lücke, die es nicht
gibt. Ein zu langsamer Consumer bekommt #info/OutdatedCursor und fällt auf
den DB-Replay zurück, statt getrennt zu werden — die Events sind durabel,
also ist der Rückfall verlustfrei.

Frame-Hülle ist konformes DAG-CBOR mit Tag-42-Links (neues Modul dag_cbor,
aus car.rs herausgezogen statt dupliziert). Die Blöcke darin behalten die
Konvention dieses Repos: CIDs als Strings. Ein fremder Consumer liest die
Frames, scheitert aber an den Blockinhalten — das zu ändern hieße, jede CID
im System zu ändern, inklusive der did:plc-Ableitung. Steht so im Modulkopf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:02 +02:00
tomdeboneandClaude Opus 5 6fd046417a feat(auth): Audience der Access-Tokens prüfen
`verify_jwt` setzt `validate_aud = false` — es kann den Aufrufer nicht
kennen. Also blieb `aud` bisher ungeprüft, obwohl die PDS es setzt.

Was die Prüfung bringt: die PDS signiert Tokens für *ihre* AppView.
Ohne Audience-Check wäre ein Token, das an einen anderen Dienst mit
derselben PDS-Vertrauensbeziehung geht, hier wiederverwendbar — und
umgekehrt. Es ist der Unterschied zwischen "die PDS bürgt für diesen
Nutzer" und "die PDS bürgt für diesen Nutzer *im Gespräch mit uns*".

Dafür musste der Wert erst einmal etwas sein, das beide Seiten
berechnen können: die PDS setzte ihn hart auf
did:web:appview.maarcadetweet.local. Jetzt leiten ihn beide über
AppConfig::appview_did() aus APPVIEW_PUBLIC_URL ab — dieselbe
did:web-Regel wie schon für pds_did().

Ein Mismatch ist TokenInvalid, nicht Forbidden: das ist der Code, auf
den der Client seine Token-Erneuerung stützt. Eine Instanz, die ihre
APPVIEW_PUBLIC_URL ändert, heilt sich damit beim nächsten Refresh
selbst, statt jeden angemeldeten Nutzer auszusperren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 06:25:51 +02:00
tomdeboneandClaude Opus 5 f7b78fd5db docs: Auth-Abschnitt, korrigierte Test-Anleitung, Phase 9
deployment.md bekommt einen eigenen Abschnitt zur Authentifizierung
(Schlüsselweg, geschützte Endpoints, Fehlercodes, der Schalter für
VPN-Instanzen) und eine CORS-Beschreibung, die die Allowlist statt des
alten Wildcards erklärt — inklusive der Tauri-Origins, die sonst am
Preflight scheitern.

Im README steht jetzt der Hinweis, der diese Runde am meisten gekostet
hat: ohne DATABASE_URL_APPVIEW in der Umgebung überspringen sich die
DB-Tests selbst und `cargo test --workspace` meldet grün, ohne sie
ausgeführt zu haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 9ee717bbc7 fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren
Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications,
Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen
sprechenden Fehler statt eines leeren Bearer-Headers.

Dabei kam heraus, dass die automatische Token-Erneuerung noch nie
funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"`
sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit
einem blanken String ab — der Zweig war seit seiner Einführung tot.
Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen
Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer,
der Strings und Objekte behandelt.

Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig
scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable
gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein,
der Poll soll das überdauern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 ac18ff7a16 test(appview): handle-sync-Tests messen wieder, was ihr Name sagt
Mit gesetztem DATABASE_URL_APPVIEW liefen diese Tests zum ersten Mal
überhaupt (ohne die Variable überspringen sie sich still) — und fielen
um. Zwei Ursachen:

1. Sechs Integrationstests hingen wie zuvor die Unit-Tests am globalen
   run_once()-Batch. select_candidates/resolve_batch sind dafür jetzt
   pub, damit auch die Integrationstests ihren eigenen DID durchreichen
   können statt zu hoffen, dass er es in den Batch schafft.
2. Die Dispatch-Tests für did:web und did:plc verdrahteten den fremden
   Stub als pds_resolver — also eine lokale PDS, die behauptet, eine
   fremde DID zu kennen. Die Moduldoku sagt ausdrücklich, dass die PDS
   vor der Methodenverzweigung befragt wird, damit ein did:key-Nutzer
   der eigenen PDS ohne Umweg über plc.directory auflöst. Die Fixtures
   haben also gegen die dokumentierte Regel getestet statt gegen die
   Verzweigung, um die es ihnen ging. Jetzt kennt die PDS-Stub die DID
   nicht, wie es der Realität entspricht.

Neu: pds_resolves_did_key_before_method_dispatch pinnt die PDS-zuerst-
Regel selbst — dasselbe DID-Verfahren, umgekehrtes Ergebnis, und der
Unterschied ist allein, ob die PDS den Nutzer hostet.

sync_skips_already_resolved prüft weiter über select_candidates: dass
ein DID mit Handle gar nicht erst bei einem Resolver landet, ist der
Punkt des Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 73da8f0140 perf(appview): Profil, Cold-Start-Feed und Follow-Timeline entlasten
Gemessen gegen die Dev-Instanz (3,3 Mio. Posts):

* GET /api/profile/<handle>  9,5 s → 0,04 s
* Cold-Start-Timeline        7,4 s → 0,006 s
* Timeline mit 2300 Follows   28 s → 0,02 s

Drei unabhängige Ursachen, alle drei ein Seq-Scan über die posts-Tabelle:

1. resolve_profile sucht die DID über profiles.LOWER(handle) und, als
   Fallback, über posts.handle. Für beides gab es keinen Index. Auf
   profiles hatte Migration 0007 genau diesen Index entfernt, mit der
   Begründung, jeder Aufrufer leite ohnehin zuerst eine DID ab — das
   stimmt nicht mehr, seit resolve_profile den profiles-Cache zuerst
   befragt.
2. Der Cold-Start-Feed filtert `collection IN (…)` und sortiert nach
   indexed_at. Der vorhandene (collection, indexed_at, uri)-Index taugt
   dafür nicht: mit zwei führenden Werten liefert er keine
   indexed_at-Ordnung mehr. Ein partieller Index über genau das
   Prädikat schiebt den Filter in die Definition und lässt
   (indexed_at DESC, uri DESC) als Sortierschlüssel übrig.
3. Genau dieser neue Index wurde dann zur Falle für den Graph-Zweig:
   der Planer sah einen Index, der schon in indexed_at-Ordnung liefert,
   und nahm an, er treffe früh genug auf n passende Zeilen — bei dünn
   besetzten Followees hieß "früh" 2,87 Mio. verworfene Zeilen. Je nach
   Anzahl bisheriger Ausführungen des Prepared Statements kippte er
   zwischen diesem und dem guten Plan, was intermittierend aussah.

Der Graph-Zweig formuliert die Absicht jetzt aus: pro Followee die
neuesten Posts über ein LATERAL, dann mergen. Damit ist der globale
Scan kein wählbarer Plan mehr, und jede Iteration ist ein begrenzter
Range-Scan auf posts_did_indexed_at_uri_idx. Korrekt ist das, weil die
globalen Top-N immer eine Teilmenge der Vereinigung der Top-N je
Followee sind — deshalb wird pro Followee limit+1 geholt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:02:47 +02:00
tomdeboneandClaude Opus 5 a2a371b7d9 feat(appview): Bearer-Auth für Timeline und Notifications
Die AppView hatte keinerlei Authentifizierung: jeder konnte
/api/notifications?did=<beliebig> lesen und per /seen als gelesen
markieren. Mit Phase 8 sind das die ersten privaten Daten im System.

Das Access-JWT der PDS trug von Anfang an sub, scope
"com.atproto.access" und aud "did:web:appview…" — es war für die
AppView ausgestellt, nur hat sie es nie geprüft. Neu ist deshalb vor
allem die Schlüsselbeschaffung: auth.rs holt das DID-Dokument der PDS
(PDS_INTERNAL_URL, sonst PDS_PUBLIC_URL), cached den Schlüssel und lädt
ihn bei einem Verifikationsfehler nach — höchstens einmal pro Minute,
damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein
Schlüsselwechsel braucht damit keinen Neustart.

Ist die PDS beim Start weg, warnt die AppView nur und startet trotzdem
(sie indiziert den Firehose, der von der lokalen PDS unabhängig ist).
Ist der Schlüssel beim Prüfen eines Tokens nicht zu beschaffen, gibt es
503 — fail closed.

Geschützt: /api/timeline/home und die drei Notification-Endpoints, jeweils
mit sub == did. Öffentlich bleiben Profile, Suche, Posts, Threads und die
Follower-Listen; das sind in AT Proto öffentliche Records.

401 AuthMissing / 401 TokenInvalid / 403 Forbidden / 503 AuthUnavailable.
TokenInvalid ist ein Vertrag mit dem Client: daran erkennt er, dass er
sein Token erneuern und einmal wiederholen muss.

Dazu CORS: statt Any für alles jetzt eine Allowlist über
APPVIEW_CORS_ORIGINS (unset = altes Verhalten plus Warnung), und
/internal/ingest-commit liegt außerhalb der CORS-Schicht — die Route
wird server-zu-server aufgerufen, ein Allow-Origin darauf würde nur
einer Webseite helfen, in den Index zu schreiben.

APPVIEW_AUTH_REQUIRED=false stellt das alte Verhalten her (VPN-Instanz,
fail-open-Tests) und warnt beim Start in Großbuchstaben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:02:27 +02:00
tomdeboneandClaude Opus 5 786a892658 feat(pds): DID-Dokument unter /.well-known/did.json ausliefern
Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass
PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem
*öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS
jetzt als verificationMethod (Multikey) in ihrem DID-Dokument.

Damit fällt auch die hartkodierte Service-DID: describeServer gab stur
did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL.
Beide Endpoints leiten sie jetzt aus einer Quelle ab
(AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss`
des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen —
also in einer Form, der kein did:web-Resolver folgen könnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:01:16 +02:00
tomdeboneandClaude Opus 5 ec8fe187fe docs: Deployment-, Architektur- und Release-Doku; README aktualisiert
docs/ war leer. Jetzt drei Dateien, jede Behauptung am Code verifiziert:

* deployment.md — docker compose, Migrationsweg, vollständige Env-Referenz
  mit den Fallstricken (P-256-taugliches PDS_JWT_SECRET, Pflicht-aber-tot
  S3_BUCKET_APPVIEW), Release-Build, systemd-Units, Reverse-Proxy inkl. des
  Hinweises, dass die AppView CORS Any liefert und der Proxy den Header
  ersetzen statt ergänzen muss, Health-Checks und Cursor-Verhalten beim
  Neustart.
* architecture.md — Crate-Verantwortlichkeiten, ASCII-Datenfluss, beide
  DB-Schemata. Hält fest, was die Topologie erklärt: die eigene PDS speist
  keinen Firehose, eigene Records erreichen die AppView nur über den
  Best-Effort-Push.
* tauri-release.md — Signing-Keys, v2-Updater-Config, latest.json, Build pro
  Plattform. Der _comment in tauri.conf.json war irreführend: active/dialog
  sind v1-Reste, die der v2-Updater ignoriert; dass nichts passiert, liegt
  daran, dass niemand check() aufruft und das Plugin nicht installiert ist.

README bekommt Phase 8, eine Doku-Übersicht, korrigierte Testanleitung
(src-tauri ist ein eigener Workspace und wird von cargo test --workspace
nicht erfasst) und einen Abschnitt "Bekannte Lücken" statt der bisher
lückenlosen Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:12 +02:00
tomdeboneandClaude Opus 5 31880e1005 feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.

* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
  subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
  den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
  Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
  über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
  die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
  die jeweilige Liste inline.

Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.

Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.

Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:00 +02:00
tomdeboneandClaude Opus 5 bce4c7862f test(pds-server): listRepos-Test unabhängig von der Tabellengröße machen
sync_list_repos_includes_recent_user paginierte von vorn durch listRepos und
gab nach 50 Seiten à 50 Zeilen auf. Die repos-Tabelle der Dev-Instanz ist
inzwischen auf ~3800 Zeilen gewachsen, der frisch angelegte DID sortierte
dahinter — der Test war rot, obwohl der Endpoint korrekt antwortet
(manuell mit passendem Cursor verifiziert).

Der Cursor startet jetzt unmittelbar *vor* dem Ziel-DID. did_cursor_lt()
taugte dafür nicht: es dekrementiert das erste Byte und landet damit vor
jedem did:..., also wieder am Tabellenanfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 465a88e4e5 test(appview): handle-sync-Tests vom globalen DB-Zustand entkoppeln
Fünf handle_sync-Tests schlugen fehl, sobald der Indexer parallel lief oder
die Datenbank schon andere handle-lose Posts enthielt: run_once() scannt
global, geordnet nach did und gedeckelt auf BATCH_SIZE, also landete der
frisch geseedete Test-DID schlicht nicht im Batch — die Assertions über
report.resolved sagten dann etwas über fremde Zeilen aus.

run_once() ist jetzt select_candidates() + resolve_batch(dids); das
Verhalten in Produktion ist unverändert. Die Dispatch-Tests treiben
resolve_batch mit ihrem eigenen DID, der Batch-Limit-Test prüft den Deckel
dort, wo er sitzt (im SELECT), statt ihn aus einem globalen Report
abzuleiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 c4ca218d97 feat(appview): Notifications, Follower-/Following-Listen, Thread-Route
Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:32 +02:00
tomdeboneandClaude Opus 5 9d009bfcba feat(at-blob): InMemoryBlobStore echt implementieren
Der Store war ein Platzhalter aus vier unimplemented!() — jeder Codepfad,
der ihn statt S3BlobStore benutzt hätte, wäre gepaniced.

Backing store ist eine RwLock<HashMap<key, (Bytes, mime)>>, damit der Typ
Send + Sync bleibt und hinter Arc<dyn BlobStore> funktioniert. put()
berechnet die CID identisch zu s3.rs (sha256 → cid_for_raw(0x55, hash)),
get() liefert None statt Fehler, delete() ist idempotent, public_url()
zeigt auf die vorhandene PDS-Route /blob/:cid.

5 Tests: Roundtrip, fehlender Key, delete-dann-get, delete auf Unbekanntes,
gleiche Bytes → gleiche CID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:00 +02:00
tomdeboneandClaude Opus 5 3c6f4dd67c chore(config): Binaries laden .env selbst; .env.example korrigiert
`cp .env.example .env && cargo run` — der im README dokumentierte Ablauf —
schlug bisher mit `missing env: PDS_HOST` fehl: nichts im Prozess hat die
Datei je gelesen. Beide Bins rufen jetzt als erstes `dotenvy::dotenv()` auf;
echte Umgebungsvariablen gewinnen weiterhin.

Dazu .env.example am Code verifiziert:

* PDS_JWT_SECRET war weder Hex noch ein gültiger P-256-Skalar. jwt_issuer.rs
  macht hex::decode + p256::SecretKey::from_bytes; ein ungültiger Wert lässt
  den Server starten, aber jeder Pfad über server_p256_public_multibase
  antwortet 500 — also nicht nur create/refreshSession, sondern auch jeder
  Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
* JETSTREAM_COLLECTIONS fehlten app.twi.post (das eigene 160-Zeichen-Lexicon)
  und app.bsky.actor.profile, obwohl der Indexer beide verarbeitet.
* APP_ENV entfernt — wird nirgends gelesen.
* PDS_INTERNAL_URL, APPVIEW_INTERNAL_URL, APPVIEW_HANDLE_SYNC_INTERVAL_SECS
  und die MAARCADETWEET_*-Overrides des Clients ergänzt.
* S3_BUCKET_APPVIEW als das markiert, was es ist: Pflichtvariable ohne Leser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:35:53 +02:00
20 changed files with 43 additions and 3043 deletions
-8
View File
@@ -61,14 +61,6 @@ S3_BUCKET_APPVIEW=maarcadetweet-appview
PLC_DIRECTORY_URL=https://plc.directory
# PLC_DIRECTORY_URL=http://127.0.0.1:2582
# --- Registrierung ---
# Verlangt createAccount einen Einladungscode? Default false, damit Dev-
# Instanzen und die Integrationstests frei Konten anlegen können — eine
# öffentlich erreichbare PDS gehört auf true gestellt, sonst kann jeder
# beliebig viele Repos anlegen. Der Server warnt beim Start, solange es
# aus ist. Codes erzeugen: `pds-server invite create --count 5 --uses 1`
PDS_INVITE_REQUIRED=false
# --- AppView ingest auth (optional, dev ok if unset) ---
# Wenn gesetzt, muss die PDS denselben Wert als Header
# `X-Ingest-Secret` mitschicken; ist er nicht gesetzt, nimmt
-273
View File
@@ -1,273 +0,0 @@
name: Release Desktop Client
# Baut den Tauri-Client aus crates/tauri-app fuer Windows und Linux, laedt die
# Bundles als Job-Artefakte hoch und haengt sie an ein Gitea-Release zum Tag.
#
# ACHTUNG — Verzeichnis-Falle (hat im Nachbarprojekt lserver einen Tag CI
# gekostet): Sobald `.gitea/workflows/` existiert, ignoriert Gitea
# `.github/workflows/` KOMPLETT. In diesem Repo gibt es kein `.github/`, also
# ist heute nichts betroffen — wer aber spaeter einen Workflow unter
# `.github/workflows/` anlegt, bekommt keinen Lauf und auch keinen roten
# Fehler, sondern schlicht Stille. Alle Workflows gehoeren hierher.
#
# VOR DEM TAG VERSION BUMPEN (analog zu lserver, wo package.json gebumpt wird):
# Die Version im Release kommt aus dem Tag, die Version IM Artefaktnamen aus
# der Config. Beide muessen zusammenpassen, sonst heisst die Datei zu einem
# Tag v0.2.0 weiterhin `maarcadetweet_0.1.0_x64-setup.exe`:
# * crates/tauri-app/src-tauri/tauri.conf.json -> "version"
# * crates/tauri-app/src-tauri/Cargo.toml -> [package] version
# * crates/tauri-app/package.json -> "version" (Konsistenz)
# Details: docs/tauri-release.md, Abschnitt "Release-Checkliste".
#
# macOS: DAFUER GIBT ES KEINEN RUNNER. Weder Gitea-Instanz noch Infrastruktur
# haben einen macOS-Host; .dmg/.app werden lokal gebaut und von Hand an das
# hier erzeugte Release gehaengt:
# cd crates/tauri-app && npm ci && npm run tauri -- build --ci
# # Artefakte: src-tauri/target/release/bundle/dmg/*.dmg und macos/*.app
# # Universal-Build: npm run tauri -- build --ci --target universal-apple-darwin
# Danach im Gitea-Release "Edit release" -> Dateien anhaengen. Ohne
# Notarisierung meldet Gatekeeper die App als nicht verifiziert (siehe
# docs/tauri-release.md, Abschnitt 9 "Offene Punkte").
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
jobs:
windows:
name: Windows (MSI + NSIS)
# Label `windows` = act_runner auf der Build-VM winbuild (10.10.2.131, Server-Netz),
# Win11, cargo 1.98.1, Node 24, Tauri-CLI 2.11.4. Laut Infrastruktur-Doku
# on-demand — laeuft die VM nicht, wird der Job nie geplant (Gitea zeigt
# dann gar keinen Lauf an, keinen fehlgeschlagenen).
runs-on: windows
timeout-minutes: 60
defaults:
run:
shell: powershell
steps:
- name: Checkout
uses: actions/checkout@v4
# package-lock.json liegt unter crates/tauri-app/ -> npm ci (reproduzierbar).
# Kein `npm install`: das wuerde den Lock im Build veraendern.
- name: Install frontend dependencies
working-directory: crates/tauri-app
run: npm ci
# package.json definiert `"tauri": "tauri"` — der npm-Umweg nutzt die
# @tauri-apps/cli-devDependency aus dem Lock (2.x) statt einer global
# installierten `cargo tauri`-Version, ist also an das Repo gebunden.
# `cargo tauri build --ci` waere gleichwertig, haengt aber an dem, was
# gerade auf dem Runner installiert ist.
# bundle.targets in tauri.conf.json steht auf "all" -> unter Windows
# heisst das msi + nsis.
- name: Build Tauri bundles
working-directory: crates/tauri-app
run: npm run tauri -- build --ci
- name: Bundles auflisten
working-directory: crates/tauri-app
run: |
$bundle = "src-tauri\target\release\bundle"
Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe |
ForEach-Object { Write-Host "$($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" }
- name: Upload bundles
uses: actions/upload-artifact@v4
with:
name: maarcadetweet-windows
path: |
crates/tauri-app/src-tauri/target/release/bundle/msi/*.msi
crates/tauri-app/src-tauri/target/release/bundle/nsis/*.exe
retention-days: 30
- name: Gitea-Release anlegen und Bundles anhaengen
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$api = "$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY"
$tag = $env:GITHUB_REF_NAME
$auth = "Authorization: token $env:GITEA_TOKEN"
# Release-Notes aus dem passenden CHANGELOG-Abschnitt ziehen.
# CHANGELOG.md existiert in diesem Repo noch nicht — sobald es
# angelegt wird (Format `## [0.2.0] - ...` wie bei lserver), landet
# der Abschnitt automatisch im Release.
$notes = "Automatisch gebaut aus $env:GITHUB_SHA."
$version = $tag.TrimStart("v")
if (Test-Path CHANGELOG.md) {
# Ausdruecklich als UTF-8 lesen: Windows PowerShell 5.1 nimmt sonst
# die ANSI-Codepage und macht aus "Aenderungen" Buchstabensalat.
$lines = [IO.File]::ReadAllText((Resolve-Path CHANGELOG.md), [Text.UTF8Encoding]::new($false)) -split "`r?`n"
$start = ($lines | Select-String -Pattern "^## \[$([regex]::Escape($version))\]" | Select-Object -First 1)
if ($start) {
$from = $start.LineNumber
$rest = $lines[$from..($lines.Count - 1)]
$next = ($rest | Select-String -Pattern "^## \[" | Select-Object -First 1)
$take = if ($next) { $next.LineNumber - 2 } else { $rest.Count - 1 }
if ($take -ge 0) { $notes = ($rest[0..$take] -join "`n").Trim() }
}
}
$body = @{ tag_name = $tag; name = "maarcadetweet $tag"; body = $notes } | ConvertTo-Json -Depth 3
$bodyFile = Join-Path $env:RUNNER_TEMP "release.json"
[IO.File]::WriteAllText($bodyFile, $body, [Text.UTF8Encoding]::new($false))
# Beide Jobs (windows + linux) haengen an DASSELBE Release und laufen
# parallel. Deshalb: anlegen versuchen, und wenn das scheitert (der
# andere Job war schneller, oder es ist ein Re-Run), das vorhandene
# Release per Tag holen.
$created = curl.exe -s -X POST -H $auth -H "Content-Type: application/json" --data-binary "@$bodyFile" "$api/releases" | ConvertFrom-Json
if (-not $created.id) {
$created = curl.exe -s -H $auth "$api/releases/tags/$tag" | ConvertFrom-Json
}
if (-not $created.id) { throw "Konnte kein Release fuer $tag anlegen oder finden." }
$bundle = "crates\tauri-app\src-tauri\target\release\bundle"
$files = Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe
if (-not $files) { throw "Keine Windows-Bundles unter $bundle gefunden." }
foreach ($f in $files) {
$name = [Uri]::EscapeDataString($f.Name)
curl.exe -s -o NUL -w "Asset-Upload $($f.Name): HTTP %{http_code}`n" -X POST -H $auth -F "attachment=@$($f.FullName)" "$api/releases/$($created.id)/assets?name=$name"
}
Write-Host "Release: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/releases/tag/$tag"
linux:
name: Linux (deb + rpm + AppImage)
# NICHT `ubuntu-latest` — auf diesem Runner (VM ci-runner, 192.168.1.72)
# ist dieses Label auf `docker://node:22-bookworm` gemappt, also einen
# Container mit Node, aber ohne Rust und ohne GTK. Der Job braeche dort
# bei `cargo` ab. Nachgesehen in /var/lib/gitea-runner/.runner:
# labels: ['ubuntu-latest:docker://node:22-bookworm', 'linux-amd64:host']
# `linux-amd64` ist das Host-Label, und auf dem Host liegen Rust
# (/root/.cargo/bin, auch ohne Login-Shell im PATH), Node 22 und die
# Tauri-GTK-Deps. Der Runner-Dienst laeuft als root.
runs-on: linux-amd64
timeout-minutes: 60
defaults:
run:
shell: bash
env:
# linuxdeploy/appimagetool werden als AppImage aus ~/.cache/tauri
# gestartet und brauchen sonst FUSE, was in der VM nicht zuverlaessig
# funktioniert (siehe MAARCADE-INFRASTRUKTUR.md: "AppImage scheitert an
# linuxdeploy/FUSE"). Mit dieser Variable entpacken sie sich selbst.
APPIMAGE_EXTRACT_AND_RUN: "1"
# Das Release-Profil in src-tauri/Cargo.toml strippt bereits selbst;
# linuxdeploys eigener strip-Lauf ist dann nur eine weitere Fehlerquelle.
NO_STRIP: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain melden
run: |
node --version
npm --version
cargo --version || echo "cargo fehlt im PATH — ggf. ~/.cargo/env sourcen"
- name: Install frontend dependencies
working-directory: crates/tauri-app
run: npm ci
# bundle.targets in tauri.conf.json ist "all" -> unter Linux sind das
# deb, rpm und appimage. Bewusst in zwei Aufrufe getrennt: der
# AppImage-Schritt ist der fragile (Downloads von linuxdeploy +
# appimagetool beim ersten Lauf, FUSE), und wenn er faellt, sollen deb
# und rpm trotzdem im Release landen. Der zweite Aufruf ist billig — der
# Cargo-Release-Build ist dann schon im target/-Cache.
- name: Build Tauri bundles (deb + rpm)
working-directory: crates/tauri-app
run: npm run tauri -- build --ci --bundles deb,rpm
- name: Build Tauri bundle (AppImage)
working-directory: crates/tauri-app
continue-on-error: true
run: npm run tauri -- build --ci --bundles appimage
- name: Bundles auflisten
run: |
find crates/tauri-app/src-tauri/target/release/bundle \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
-printf '%p (%kK)\n' || true
- name: Upload bundles
uses: actions/upload-artifact@v4
with:
name: maarcadetweet-linux
path: |
crates/tauri-app/src-tauri/target/release/bundle/deb/*.deb
crates/tauri-app/src-tauri/target/release/bundle/rpm/*.rpm
crates/tauri-app/src-tauri/target/release/bundle/appimage/*.AppImage
# AppImage darf fehlen (siehe continue-on-error oben), deb/rpm nicht —
# ein komplett leerer Upload soll auffallen.
if-no-files-found: error
retention-days: 30
- name: Gitea-Release anlegen und Bundles anhaengen
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
tag="${GITHUB_REF_NAME}"
version="${tag#v}"
# Gleiche CHANGELOG-Logik wie im Windows-Job. Die Datei existiert
# heute nicht; ohne sie bleibt es beim Fallback-Text.
notes="Automatisch gebaut aus ${GITHUB_SHA}."
if [ -f CHANGELOG.md ]; then
section="$(awk -v v="$version" '
/^## \[/ { if (found) exit; if ($0 ~ "^## \\[" v "\\]") { found = 1; next } }
found { print }
' CHANGELOG.md)"
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
notes="$section"
fi
fi
# jq ist auf dem Runner nicht garantiert, Node 22 schon.
payload="${RUNNER_TEMP}/release.json"
NOTES="$notes" TAG="$tag" node -e '
const fs = require("fs");
fs.writeFileSync(process.argv[1], JSON.stringify({
tag_name: process.env.TAG,
name: `maarcadetweet ${process.env.TAG}`,
body: process.env.NOTES,
}));
' "$payload"
# Anlegen oder — falls der Windows-Job schneller war bzw. das Release
# vom Re-Run schon existiert — das vorhandene holen.
id="$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" --data-binary "@${payload}" \
"${api}/releases" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
if [ -z "$id" ]; then
id="$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${api}/releases/tags/${tag}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
fi
if [ -z "$id" ]; then
echo "Konnte kein Release fuer ${tag} anlegen oder finden." >&2
exit 1
fi
bundle="crates/tauri-app/src-tauri/target/release/bundle"
found=0
while IFS= read -r f; do
found=1
name="$(basename "$f")"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f}" \
"${api}/releases/${id}/assets?name=${name}")"
echo "Asset-Upload ${name}: HTTP ${code}"
done < <(find "$bundle" \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) | sort)
[ "$found" -eq 1 ] || { echo "Keine Linux-Bundles unter ${bundle} gefunden." >&2; exit 1; }
echo "Release: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${tag}"
+15 -99
View File
@@ -1,5 +1,6 @@
use anyhow::Result;
use futures::StreamExt;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
@@ -32,43 +33,6 @@ impl JetstreamConsumer {
}
}
/// The URL actually dialled: base URL plus `wantedCollections` and
/// `cursor` as query parameters.
///
/// This used to connect to the bare URL and then send
/// `{"type": "options", "wantedCollections": [...]}` as a text frame.
/// Jetstream ignores that, and silently: filters are query parameters,
/// and the only message-based path (`options_update`) requires the
/// connection to have been opened with `requireHello=true`. So every
/// deployment that thought it was subscribing to six collections was in
/// fact taking the entire public firehose — measured against
/// jetstream1.us-east: 3119 events in 8 s unfiltered versus 520 for a
/// single collection. On the dev database that quietly grew to 3.3 M
/// posts; on the production instance the AppView had to be switched off
/// to stop it filling the disk.
///
/// Note what fixing this does *not* solve: the collections this project
/// wants (`app.bsky.feed.post` / `like` / `repost` / `graph.follow`) are
/// ~97 % of the firehose by volume. Correct filtering is necessary, not
/// sufficient — an instance that does not want the whole public network
/// in its index wants `wantedDids`, or no Jetstream at all.
pub fn subscribe_url(&self) -> String {
let mut url = self.url.trim_end_matches('&').to_string();
let mut sep = if url.contains('?') { '&' } else { '?' };
for c in &self.collections {
url.push(sep);
url.push_str("wantedCollections=");
url.push_str(&urlencode(c));
sep = '&';
}
if self.cursor_us > 0 {
url.push(sep);
url.push_str("cursor=");
url.push_str(&self.cursor_us.to_string());
}
url
}
/// Build a consumer that shares a connection-state flag with the caller.
pub fn with_connected_flag(mut self, flag: Arc<AtomicBool>) -> Self {
self.connected = Some(flag);
@@ -117,13 +81,23 @@ impl JetstreamConsumer {
F: FnMut(JetstreamEvent) -> Fut + Send,
Fut: std::future::Future<Output = Result<()>> + Send,
{
let url = self.subscribe_url();
let (mut ws, _) = tokio_tungstenite::connect_async(&url).await?;
info!("connected to jetstream: {url}");
let (mut ws, _) = tokio_tungstenite::connect_async(&self.url).await?;
info!("connected to jetstream: {}", self.url);
if let Some(flag) = &self.connected {
flag.store(true, Ordering::Relaxed);
}
if !self.collections.is_empty() || self.cursor_us > 0 {
let mut options = json!({ "type": "options" });
if !self.collections.is_empty() {
options["wantedCollections"] = json!(self.collections);
}
if self.cursor_us > 0 {
options["cursor"] = json!(self.cursor_us);
}
ws.send(Message::Text(options.to_string())).await?;
}
while let Some(msg) = ws.next().await {
let msg = msg?;
if let Message::Text(text) = msg {
@@ -135,61 +109,3 @@ impl JetstreamConsumer {
Ok(())
}
}
/// Percent-encode everything outside the unreserved set. Collection NSIDs are
/// dots and letters today, but a `wantedDids` value carries `:` — encoding
/// unconditionally keeps this correct if the caller passes one.
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
other => {
let mut buf = [0u8; 4];
other
.encode_utf8(&mut buf)
.bytes()
.map(|b| format!("%{b:02X}"))
.collect()
}
})
.collect()
}
#[cfg(test)]
mod url_tests {
use super::*;
#[test]
fn collections_go_into_the_query_string() {
let c = JetstreamConsumer::new(
"wss://jetstream1.us-east.bsky.network/subscribe",
vec!["app.twi.post".into(), "app.bsky.feed.like".into()],
);
assert_eq!(
c.subscribe_url(),
"wss://jetstream1.us-east.bsky.network/subscribe\
?wantedCollections=app.twi.post&wantedCollections=app.bsky.feed.like"
.replace(' ', "")
);
}
#[test]
fn cursor_is_appended_and_respects_an_existing_query() {
let mut c = JetstreamConsumer::new("wss://host/subscribe?compress=false", vec![]);
c.cursor_us = 1234;
assert_eq!(c.subscribe_url(), "wss://host/subscribe?compress=false&cursor=1234");
}
#[test]
fn no_filters_leaves_the_url_alone() {
let c = JetstreamConsumer::new("wss://host/subscribe", vec![]);
assert_eq!(c.subscribe_url(), "wss://host/subscribe");
}
/// A DID contains `:`, which has to survive as `%3A` in a query value.
#[test]
fn values_are_percent_encoded() {
assert_eq!(urlencode("did:plc:abc"), "did%3Aplc%3Aabc");
assert_eq!(urlencode("app.bsky.feed.post"), "app.bsky.feed.post");
}
}
-52
View File
@@ -33,36 +33,6 @@ 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,
@@ -129,24 +99,6 @@ 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 {
@@ -195,10 +147,6 @@ 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),
})
}
-684
View File
@@ -1,684 +0,0 @@
//! Invite codes: minting, listing, and the single redeem operation that
//! `com.atproto.server.createAccount` calls.
//!
//! # Why this exists
//!
//! `createAccount` had no gate at all. That was fine while the PDS only
//! answered on `127.0.0.1:2583`; it is not fine on a public name, because
//! each accepted account allocates a repo head, a server-held key pair, an
//! MST that grows with every write, and a stream of firehose events every
//! subscribed AppView is obliged to index. Invite codes are the smallest
//! gate that turns "anyone with curl" into "anyone the operator handed a
//! string to".
//!
//! The gate is off by default (`PDS_INVITE_REQUIRED`, see
//! [`at_shared::config`]) so the existing test suites and dev instances
//! keep creating throwaway accounts; `main` warns loudly on every boot
//! where it is off.
//!
//! # Where codes come from
//!
//! Nowhere over HTTP. Minting lives in the `pds-server invite` subcommand
//! ([`run_cli`]), which the operator runs on the box. Adding a
//! `createInviteCode` endpoint would mean the thing that guards
//! registration is itself reachable by whoever can reach registration —
//! at which point it guards nothing, and the only question left is
//! whether *its* auth has a hole. A subcommand has no attack surface to
//! get wrong.
//!
//! # The one interesting piece of code in here
//!
//! [`redeem`] is a single conditional `UPDATE … RETURNING`, not a
//! `SELECT`-then-`UPDATE`. See its docs and
//! `migrations/pds/0004_invite_codes.sql` for why that distinction is the
//! whole feature.
use rand::rngs::OsRng;
use rand::RngCore;
use sqlx::{PgPool, Postgres, Transaction};
/// Alphabet the codes are drawn from: Crockford base32, lowercased.
///
/// Exactly 32 symbols, which is the property that matters — it lets each
/// character consume exactly 5 bits of entropy with no modulo bias, so
/// every code in the space is equally likely. A 31- or 36-character
/// "human friendly" alphabet would need rejection sampling to say the
/// same thing, and the usual `byte % len` shortcut would quietly make
/// some characters more probable than others.
///
/// The excluded letters are Crockford's: `i`, `l`, `o` and `u`. The
/// first three are the ones people mistype as `1`, `1` and `0` when
/// copying a code out of a chat message; `u` is dropped so a random draw
/// cannot spell something the operator has to apologise for.
const CODE_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// Characters per group, and groups per code. Two groups of five is
/// 50 bits of entropy — far past anything an online guesser can reach
/// against a database round-trip per attempt, and short enough to read
/// aloud.
const GROUP_LEN: usize = 5;
const GROUPS: usize = 2;
/// Fixed prefix so a code is recognisable as one when it turns up out of
/// context (a support ticket, a pasted log line) and so it cannot be
/// confused with a handle or a DID.
const CODE_PREFIX: &str = "mt";
/// Why a redemption was refused.
///
/// Deliberately coarse. The route maps [`RedeemError::Invalid`] to a
/// single `400 InvalidInviteCode` with one fixed message, so an
/// unauthenticated caller cannot use the error text to distinguish
/// "no such code" from "that code exists but is used up" — which would
/// turn the endpoint into an oracle for probing the code space.
#[derive(Debug)]
pub enum RedeemError {
/// Unknown, disabled, or already at its use limit. One variant on
/// purpose: see the type docs.
Invalid,
/// The database itself failed. Distinct from [`RedeemError::Invalid`]
/// because this is a `500`, not a `400` — refusing a legitimate code
/// because Postgres hiccuped would be a lie to the user.
Db(sqlx::Error),
}
impl std::fmt::Display for RedeemError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RedeemError::Invalid => write!(f, "invite code is not valid"),
RedeemError::Db(e) => write!(f, "invite lookup failed: {e}"),
}
}
}
/// Normalise a client-supplied code into the form stored in the table.
///
/// Trims surrounding whitespace (people paste codes with a trailing
/// newline out of a terminal) and lowercases. Every generated code is
/// already lowercase ASCII, so this is an exact normalisation — which is
/// what lets [`redeem`] look the code up with a plain `code = $1` and
/// hit the primary-key index, instead of `lower(code) = $1`, which
/// would force a sequential scan on the one query that runs per
/// registration attempt.
///
/// Returns `None` for a code that is empty after trimming, so "field
/// present but blank" and "field absent" reach the route as the same
/// case.
pub fn normalize(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_ascii_lowercase())
}
/// Generate one cryptographically random invite code, e.g.
/// `mt-7k3qw-z9d2m`.
///
/// Randomness comes from [`OsRng`] — the same source
/// `keys::generate_user_keys` and `password::hash_password` already use
/// in this crate, i.e. the OS CSPRNG, never a seeded or thread-local
/// generator. A code is a bearer credential for creating an account on
/// this server; a predictable one is the same bug as a predictable
/// password-reset token.
///
/// Entropy: [`GROUPS`] × [`GROUP_LEN`] characters × 5 bits = 50 bits.
pub fn generate_code() -> String {
let total = GROUPS * GROUP_LEN;
let mut bytes = vec![0u8; total];
OsRng.fill_bytes(&mut bytes);
let mut out = String::with_capacity(CODE_PREFIX.len() + total + GROUPS);
out.push_str(CODE_PREFIX);
for chunk in bytes.chunks(GROUP_LEN) {
out.push('-');
for b in chunk {
// Take the low 5 bits of a uniformly random byte. The
// alphabet is exactly 32 symbols, so this is a bijection
// from 5 bits onto it — no bias, no rejection loop.
out.push(CODE_ALPHABET[(*b & 0b0001_1111) as usize] as char);
}
}
out
}
/// Consume one use of `code` on behalf of the account `did` / `handle`.
///
/// **This must be called with the same transaction that creates the
/// account.** The whole point is that a code is spent if and only if an
/// account was actually created: if the caller's transaction rolls back
/// — handle taken, key generation failed, anything — the counter goes
/// back with it and the code is still redeemable. `create_account`
/// therefore begins its transaction, redeems here, inserts `users` and
/// `repos`, and only then commits.
///
/// # The race, and why there isn't one
///
/// The tempting implementation is: `SELECT used_count, max_uses …`,
/// compare in Rust, then `UPDATE`. That is a check-then-act. Two
/// registrations arriving together on a code with one use left both read
/// `used_count = 0`, both conclude they may proceed, and both write
/// `used_count = 1`. Two accounts, one use — and the row afterwards
/// claims it was redeemed once, so nothing even shows up as wrong.
///
/// Instead the check *is* the write:
///
/// ```sql
/// 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
/// ```
///
/// Postgres serialises the two statements on the row lock. The loser
/// blocks until the winner commits, and then — this is the part that
/// makes it work — does *not* continue with its old snapshot: it
/// re-fetches the committed row and re-evaluates the `WHERE` clause
/// against it (EvalPlanQual). `used_count` is now `1`, the predicate is
/// false, the row is dropped from the update set, and the statement
/// affects zero rows. Zero rows is the rejection. This function never
/// forms an opinion about validity that could be stale by the time it
/// acts on it, because it never looks before it writes.
///
/// The `invite_code_uses` insert that follows is inside the same
/// transaction and the same row lock, so the counter and the audit rows
/// cannot drift apart.
pub async fn redeem(
tx: &mut Transaction<'_, Postgres>,
code: &str,
did: &str,
handle: &str,
) -> Result<(), RedeemError> {
let normalized = match normalize(code) {
Some(c) => c,
None => return Err(RedeemError::Invalid),
};
// One statement, and its row count is the verdict.
let claimed: Option<(i32, i32)> = sqlx::query_as(
r#"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"#,
)
.bind(&normalized)
.fetch_optional(&mut **tx)
.await
.map_err(RedeemError::Db)?;
if claimed.is_none() {
return Err(RedeemError::Invalid);
}
// Audit trail: which account this code produced. Same transaction,
// so it lands exactly when the counter increment does.
//
// The `(code, did)` primary key makes a duplicate impossible; a
// conflict here would mean the same DID redeemed the same code
// twice in one registration, which cannot happen but would corrupt
// the counter/uses agreement if it did — so let it be an error
// rather than silently ignoring it.
sqlx::query(
r#"INSERT INTO invite_code_uses (code, did, handle)
VALUES ($1, $2, $3)"#,
)
.bind(&normalized)
.bind(did)
.bind(handle)
.execute(&mut **tx)
.await
.map_err(RedeemError::Db)?;
Ok(())
}
// =====================================================
// CLI: `pds-server invite …`
// =====================================================
/// One row of `invite list`, and the shape `create` hands back.
#[derive(Debug)]
pub struct InviteRow {
pub code: String,
pub max_uses: i32,
pub used_count: i32,
pub disabled: bool,
pub note: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// What a parsed `invite` command line asks for.
///
/// Parsed out of `std::env::args` by hand. The workspace has no
/// argument-parsing dependency and this subcommand is not worth adding
/// one for: three verbs, three flags, and a hand-rolled parser that is
/// small enough to unit-test exhaustively (which it is, below) beats a
/// derive macro plus a new crate in the dependency tree of a server
/// binary.
#[derive(Debug, PartialEq)]
pub enum InviteCommand {
/// `invite create [--count N] [--uses N] [--note TEXT]`
Create {
count: u32,
uses: i32,
note: Option<String>,
},
/// `invite list [--all]` — without `--all`, spent and disabled codes
/// are hidden, because the question the operator almost always has
/// is "what can I still hand out".
List { all: bool },
/// `invite disable <code>` — stop honouring a code without losing
/// the record of which accounts it already created.
Disable { code: String },
}
/// Usage text. Printed for `invite help`, and for anything that fails to
/// parse.
pub const INVITE_USAGE: &str = "\
usage: pds-server invite <command>
create [--count N] [--uses N] [--note TEXT]
Mint N codes (default 1), each good for `--uses` accounts
(default 1). Prints one code per line and nothing else, so the
output can be piped or pasted directly.
list [--all]
Show codes that can still be redeemed. --all includes spent and
disabled ones.
disable <code>
Stop honouring a code. The record of accounts it already created
is kept.
The database is the one named by DATABASE_URL_PDS (read from .env like
the server does). Codes are only meaningful while PDS_INVITE_REQUIRED
is true.";
/// Parse the arguments after the `invite` verb.
///
/// Returns `Err(message)` for anything malformed; the caller prints the
/// message plus [`INVITE_USAGE`] and exits non-zero. Unknown flags are
/// an error rather than being ignored — a typo'd `--uses` that silently
/// became `1` would hand out the wrong codes and the operator would only
/// find out when the second person to use one got a `400`.
pub fn parse_invite_args(args: &[String]) -> Result<InviteCommand, String> {
let verb = args
.first()
.map(|s| s.as_str())
.ok_or_else(|| "missing invite command".to_string())?;
let rest = &args[1..];
match verb {
"create" => {
let mut count: u32 = 1;
let mut uses: i32 = 1;
let mut note: Option<String> = None;
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--count" => {
let v = rest
.get(i + 1)
.ok_or_else(|| "--count needs a value".to_string())?;
count = v
.parse()
.map_err(|_| format!("--count: not a number: {v}"))?;
if count == 0 {
return Err("--count must be at least 1".to_string());
}
i += 2;
}
"--uses" => {
let v = rest
.get(i + 1)
.ok_or_else(|| "--uses needs a value".to_string())?;
uses = v
.parse()
.map_err(|_| format!("--uses: not a number: {v}"))?;
// Mirrors the table's CHECK (max_uses > 0). Caught
// here so the operator gets a sentence instead of a
// constraint-violation dump.
if uses < 1 {
return Err("--uses must be at least 1".to_string());
}
i += 2;
}
"--note" => {
let v = rest
.get(i + 1)
.ok_or_else(|| "--note needs a value".to_string())?;
note = Some(v.clone());
i += 2;
}
other => return Err(format!("unknown option for `create`: {other}")),
}
}
Ok(InviteCommand::Create { count, uses, note })
}
"list" => {
let mut all = false;
for a in rest {
match a.as_str() {
"--all" => all = true,
other => return Err(format!("unknown option for `list`: {other}")),
}
}
Ok(InviteCommand::List { all })
}
"disable" => {
let code = rest
.first()
.ok_or_else(|| "disable needs a code".to_string())?;
if rest.len() > 1 {
return Err("disable takes exactly one code".to_string());
}
let code = normalize(code).ok_or_else(|| "disable needs a code".to_string())?;
Ok(InviteCommand::Disable { code })
}
other => Err(format!("unknown invite command: {other}")),
}
}
/// Insert `count` freshly generated codes, each good for `uses`
/// accounts.
///
/// Retries on a primary-key collision. With 50 bits per code a
/// collision is not something that will happen, but "not something that
/// will happen" is exactly the class of event that turns into a
/// confusing `duplicate key` traceback at 2am, and the retry costs three
/// lines.
pub async fn create_codes(
db: &PgPool,
count: u32,
uses: i32,
note: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let mut attempt = 0;
loop {
let code = generate_code();
let inserted = sqlx::query(
r#"INSERT INTO invite_codes (code, max_uses, note)
VALUES ($1, $2, $3)
ON CONFLICT (code) DO NOTHING"#,
)
.bind(&code)
.bind(uses)
.bind(note)
.execute(db)
.await?
.rows_affected();
if inserted == 1 {
out.push(code);
break;
}
attempt += 1;
if attempt >= 5 {
anyhow::bail!("could not find a free invite code after 5 attempts");
}
}
}
Ok(out)
}
/// Read back codes for `invite list`.
pub async fn list_codes(db: &PgPool, all: bool) -> anyhow::Result<Vec<InviteRow>> {
// Two statements rather than one with a `$1`-toggled predicate:
// the redeemable filter is exactly the redeem query's `WHERE`
// clause, and keeping it spelled the same way makes it obvious that
// `list` shows what `redeem` would accept.
let sql = if all {
r#"SELECT code, max_uses, used_count, disabled, note, created_at
FROM invite_codes
ORDER BY created_at DESC"#
} else {
r#"SELECT code, max_uses, used_count, disabled, note, created_at
FROM invite_codes
WHERE NOT disabled AND used_count < max_uses
ORDER BY created_at DESC"#
};
let rows: Vec<(String, i32, i32, bool, Option<String>, chrono::DateTime<chrono::Utc>)> =
sqlx::query_as(sql).fetch_all(db).await?;
Ok(rows
.into_iter()
.map(
|(code, max_uses, used_count, disabled, note, created_at)| InviteRow {
code,
max_uses,
used_count,
disabled,
note,
created_at,
},
)
.collect())
}
/// Flip `disabled` on one code. Returns `false` if there is no such
/// code, so the CLI can say so instead of reporting a successful no-op.
pub async fn disable_code(db: &PgPool, code: &str) -> anyhow::Result<bool> {
let n = sqlx::query("UPDATE invite_codes SET disabled = TRUE WHERE code = $1")
.bind(code)
.execute(db)
.await?
.rows_affected();
Ok(n == 1)
}
/// Run the `invite` subcommand end to end: parse, connect, act, print.
///
/// Connects with the same `DATABASE_URL_PDS` and runs the same
/// migrations as [`crate::main`], so `invite create` works on a fresh
/// checkout before the server has ever been started — otherwise the
/// first thing an operator does after deploying would fail with
/// "relation invite_codes does not exist".
pub async fn run_cli(args: &[String]) -> anyhow::Result<()> {
if matches!(args.first().map(|s| s.as_str()), None | Some("help") | Some("-h") | Some("--help"))
{
println!("{INVITE_USAGE}");
return Ok(());
}
let cmd = match parse_invite_args(args) {
Ok(c) => c,
Err(msg) => {
eprintln!("pds-server invite: {msg}\n\n{INVITE_USAGE}");
std::process::exit(2);
}
};
let cfg = at_shared::config::AppConfig::from_env()?;
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect(&cfg.database_url_pds)
.await?;
sqlx::migrate!("../../migrations/pds").run(&db).await?;
match cmd {
InviteCommand::Create { count, uses, note } => {
let codes = create_codes(&db, count, uses, note.as_deref()).await?;
// Bare codes, one per line, nothing else on stdout — the
// operator pipes this into a message or a file. Anything
// decorative here would have to be stripped by hand.
for c in &codes {
println!("{c}");
}
if !cfg.pds_invite_required {
// Not an error: minting codes before flipping the switch
// is the correct order of operations. But an operator
// who thinks they have just closed registration should
// find out now.
eprintln!(
"note: PDS_INVITE_REQUIRED is not true — createAccount currently \
accepts requests without any code."
);
}
}
InviteCommand::List { all } => {
let rows = list_codes(&db, all).await?;
if rows.is_empty() {
eprintln!("no invite codes");
}
for r in rows {
let state = if r.disabled {
"disabled"
} else if r.used_count >= r.max_uses {
"spent"
} else {
"open"
};
println!(
"{} {}/{} {} {} {}",
r.code,
r.used_count,
r.max_uses,
state,
r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
r.note.as_deref().unwrap_or("")
);
}
}
InviteCommand::Disable { code } => {
if disable_code(&db, &code).await? {
println!("disabled {code}");
} else {
eprintln!("no such invite code: {code}");
std::process::exit(1);
}
}
}
Ok(())
}
// -- tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn generated_codes_use_only_the_safe_alphabet() {
let code = generate_code();
// `mt-xxxxx-xxxxx`
assert!(code.starts_with("mt-"), "code = {code}");
let groups: Vec<&str> = code.split('-').collect();
assert_eq!(groups.len(), GROUPS + 1, "code = {code}");
assert_eq!(groups[0], CODE_PREFIX);
for g in &groups[1..] {
assert_eq!(g.len(), GROUP_LEN, "group {g} in {code}");
for ch in g.chars() {
assert!(
CODE_ALPHABET.contains(&(ch as u8)),
"character {ch:?} in {code} is outside the alphabet"
);
}
}
// The letters people mistype must never appear.
for bad in ['i', 'l', 'o', 'u'] {
assert!(!code[3..].contains(bad), "{code} contains {bad}");
}
}
#[test]
fn generated_codes_do_not_repeat() {
// Not a randomness test — a 1000-draw collision would mean the
// generator is returning a constant or reusing a seeded RNG,
// which is the failure mode that actually happens when someone
// swaps `OsRng` for `thread_rng` with a fixed seed in a test
// helper.
let mut seen = HashSet::new();
for _ in 0..1000 {
assert!(seen.insert(generate_code()), "duplicate code in 1000 draws");
}
}
#[test]
fn normalize_trims_and_lowercases() {
assert_eq!(normalize(" MT-ABCDE-FGHJK \n").as_deref(), Some("mt-abcde-fghjk"));
assert_eq!(normalize("mt-abcde-fghjk").as_deref(), Some("mt-abcde-fghjk"));
// Absent and blank must be indistinguishable to the route.
assert_eq!(normalize(""), None);
assert_eq!(normalize(" "), None);
assert_eq!(normalize("\t\n"), None);
}
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_create_with_defaults_and_flags() {
assert_eq!(
parse_invite_args(&args(&["create"])).unwrap(),
InviteCommand::Create {
count: 1,
uses: 1,
note: None
}
);
assert_eq!(
parse_invite_args(&args(&["create", "--count", "5", "--uses", "1"])).unwrap(),
InviteCommand::Create {
count: 5,
uses: 1,
note: None
}
);
// Order must not matter, and --note takes the next argument
// verbatim (spaces included).
assert_eq!(
parse_invite_args(&args(&["create", "--note", "meetup 2026", "--uses", "3"])).unwrap(),
InviteCommand::Create {
count: 1,
uses: 3,
note: Some("meetup 2026".to_string())
}
);
}
#[test]
fn rejects_malformed_create_flags() {
// A typo'd flag must not be silently ignored — that would hand
// out codes with the default limits.
assert!(parse_invite_args(&args(&["create", "--use", "3"])).is_err());
assert!(parse_invite_args(&args(&["create", "--count"])).is_err());
assert!(parse_invite_args(&args(&["create", "--count", "x"])).is_err());
assert!(parse_invite_args(&args(&["create", "--count", "0"])).is_err());
// max_uses > 0 is a table constraint; catch it before Postgres does.
assert!(parse_invite_args(&args(&["create", "--uses", "0"])).is_err());
assert!(parse_invite_args(&args(&["create", "--uses", "-2"])).is_err());
}
#[test]
fn parses_list_and_disable() {
assert_eq!(
parse_invite_args(&args(&["list"])).unwrap(),
InviteCommand::List { all: false }
);
assert_eq!(
parse_invite_args(&args(&["list", "--all"])).unwrap(),
InviteCommand::List { all: true }
);
assert!(parse_invite_args(&args(&["list", "--everything"])).is_err());
// `disable` normalises the code the same way redeem does, so an
// operator pasting a shouted code still disables the right row.
assert_eq!(
parse_invite_args(&args(&["disable", " MT-ABCDE-FGHJK "])).unwrap(),
InviteCommand::Disable {
code: "mt-abcde-fghjk".to_string()
}
);
assert!(parse_invite_args(&args(&["disable"])).is_err());
assert!(parse_invite_args(&args(&["disable", "a", "b"])).is_err());
}
#[test]
fn rejects_unknown_verb_and_empty_args() {
assert!(parse_invite_args(&args(&[])).is_err());
assert!(parse_invite_args(&args(&["destroy"])).is_err());
}
}
+4 -81
View File
@@ -2,7 +2,6 @@ mod appview_push;
mod car;
mod dag_cbor;
mod firehose;
mod invite;
mod jwt_issuer;
mod keys;
mod password;
@@ -18,16 +17,6 @@ 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.
@@ -36,57 +25,11 @@ async fn main() -> anyhow::Result<()> {
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
// 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");
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
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)
@@ -128,21 +71,6 @@ 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))
@@ -313,12 +241,7 @@ async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerRe
.pds_handle_dns_zone
.trim_start_matches('.')
.to_string()],
// 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,
invite_code_required: false,
links: json!({
"termsOfService": null,
"privacyPolicy": null,
-71
View File
@@ -93,55 +93,6 @@ pub async fn create_account(
let mut tx = state.db.begin().await.map_err(|e| internal(e))?;
// Invite gate.
//
// Inside the transaction, and *first* inside it, for two reasons.
//
// Inside, because the code must be spent if and only if an account
// was really created. Redeeming before `begin()` (or in a
// transaction of its own) would burn a code every time the INSERT
// below hit the `users.handle` unique index — a user who lost a
// handle race would also lose their invite, with nothing to show
// for it. Everything from here to `tx.commit()` rolls back together.
//
// First, because `invite::redeem` takes the code row's lock, and
// holding it across the account INSERTs is what serialises two
// registrations that present the same last remaining use. See
// `invite::redeem` for how the conditional UPDATE turns that lock
// into a correct decision rather than a stale one.
//
// Note this runs after the handle/password validation above, so a
// malformed request is rejected without touching a code at all.
if state.cfg.pds_invite_required {
let supplied = req
.invite_code
.as_deref()
.and_then(crate::invite::normalize);
match supplied {
None => return Err(invalid_invite_code()),
Some(code) => {
if let Err(e) =
crate::invite::redeem(&mut tx, &code, &did, &req.handle).await
{
return match e {
crate::invite::RedeemError::Invalid => {
// Deliberately not logged with the code at
// info level: a public endpoint that echoes
// every guessed code into the log is a way
// to fill the disk from outside.
warn!(
handle = %req.handle,
"createAccount rejected: invite code invalid, disabled or spent"
);
Err(invalid_invite_code())
}
crate::invite::RedeemError::Db(db_err) => Err(internal(db_err)),
};
}
}
}
}
sqlx::query(
r#"INSERT INTO users (did, handle, email, password_hash, signing_key, rotation_key)
VALUES ($1, $2, $3, $4, $5, $6)"#,
@@ -355,28 +306,6 @@ pub async fn refresh_session(
}))
}
/// The one error a failed invite check produces.
///
/// Same `(StatusCode, Json<ErrorBody>)` shape every other route in this
/// module returns, so a client parses it with the code it already has:
/// `{"error": "InvalidInviteCode", "message": "..."}` under a `400`.
///
/// One message for every failure mode — missing, unknown, disabled,
/// spent — on purpose. A distinct "that code exists but is used up"
/// would let an unauthenticated caller walk the code space and learn
/// which strings are real, which is most of the work of stealing one.
/// The operator can tell the cases apart from `pds-server invite list`;
/// the internet cannot.
fn invalid_invite_code() -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
(
StatusCode::BAD_REQUEST,
Json(crate::routes::types::ErrorBody::new(
"InvalidInviteCode",
Some("a valid invite code is required to create an account on this server".into()),
)),
)
}
fn internal(e: impl std::fmt::Display) -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
-12
View File
@@ -6,18 +6,6 @@ pub struct CreateAccountReq {
pub email: Option<String>,
pub password: Option<String>,
pub did: Option<String>,
/// The invite code, when `PDS_INVITE_REQUIRED` is on.
///
/// The alias is not cosmetic. This struct — like every other type in
/// this module — is snake_case on the wire, which is what our own
/// clients send. The AT Protocol lexicon for
/// `com.atproto.server.createAccount` spells the field `inviteCode`,
/// so every off-the-shelf atproto client sends *that*, and without
/// the alias serde would drop it into `None` silently — the account
/// would be refused with "an invite code is required" while the user
/// is looking at the code they just pasted. Accepting both spellings
/// costs one attribute; debugging that report costs an afternoon.
#[serde(alias = "inviteCode")]
pub invite_code: Option<String>,
pub recovery_key: Option<String>,
}
+7 -20
View File
@@ -314,32 +314,19 @@ async fn upload_blob_rejects_oversized() {
// 413 from axum's body extractor.
let payload = vec![0u8; 2 * 1024 * 1024];
// Two legitimate outcomes, and which one happens is a race the test
// cannot win: the limit trips while the client is still writing the
// 2 MiB body. If the rejection reaches the socket first, the client
// reads `413`; if the server closes its side first, the client's
// write fails with a connection reset and never gets to read a
// status. Asserting only on `413` made this test fail roughly one run
// in three. What actually matters — and what both outcomes prove — is
// that the upload was refused rather than accepted.
match c
let resp = c
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
.bearer_auth(&jwt)
.header("Content-Type", "image/png")
.body(payload)
.send()
.await
{
Ok(resp) => assert_eq!(
resp.status().as_u16(),
413,
"oversized upload must be refused with 413"
),
Err(e) => assert!(
e.is_request(),
"the only acceptable error is the server hanging up mid-body, got {e:?}"
),
}
.unwrap();
assert_eq!(
resp.status().as_u16(),
413,
"oversized upload must return 413"
);
}
/// `com.atproto.uploadBlob` rejects requests with no `Authorization`
@@ -1,793 +0,0 @@
//! Invite-code enforcement on `com.atproto.server.createAccount`.
//!
//! # Why this file starts its own PDS
//!
//! Every other integration suite in this crate talks to whatever PDS the
//! developer already has running on `:2583` and skips itself when there
//! isn't one. That works because those tests only need *a* PDS. These
//! need a PDS with `PDS_INVITE_REQUIRED=true`, and the ambient one is
//! (correctly) started with the default `false` — otherwise every other
//! suite, which creates throwaway accounts with no code, would fail.
//!
//! Asking the developer to restart their PDS with a different flag
//! before this file passes would mean the flag's behaviour is only ever
//! tested by hand. So each test here spawns its own `pds-server` on a
//! free port with the flag set the way that test needs it, and kills it
//! on the way out ([`Pds`]'s `Drop`). `env!("CARGO_BIN_EXE_pds-server")`
//! is cargo's own path to the binary it just built for this test run, so
//! the process under test is always the current code.
//!
//! The suite still fails open, in the same spirit as its neighbours: if
//! the child never becomes healthy — no Postgres, no `.env`, no
//! `DATABASE_URL_PDS` — the tests print why and return green rather than
//! failing a workstation that simply isn't running the stack.
use serde_json::{json, Value};
use std::process::{Child, Command};
use std::time::Duration;
/// A `pds-server` child process bound to its own port, killed when the
/// test that started it goes out of scope.
///
/// The `Drop` impl is the reason this is a struct at all: a test that
/// panics mid-way must not leave a server holding a port and a pool of
/// Postgres connections for the rest of the run.
struct Pds {
child: Child,
port: u16,
http: reqwest::Client,
}
impl Drop for Pds {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Pds {
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{}", self.port, path)
}
async fn create_account(&self, body: Value) -> (u16, Value) {
let resp = self
.http
.post(self.url("/xrpc/com.atproto.server.createAccount"))
.json(&body)
.send()
.await
.expect("createAccount request");
let status = resp.status().as_u16();
let body: Value = resp.json().await.unwrap_or(Value::Null);
(status, body)
}
async fn describe(&self) -> Value {
self.http
.get(self.url("/xrpc/com.atproto.server.describeServer"))
.send()
.await
.expect("describeServer")
.json()
.await
.expect("describeServer json")
}
}
/// Ask the OS for a port nobody is using, then let go of it.
///
/// There is a window between the drop and the child's `bind` in which
/// something else could take the port; on a test machine that window is
/// theoretical, and the alternative (a fixed port) would make two
/// concurrently running tests in this file collide *reliably* instead of
/// never.
fn free_port() -> Option<u16> {
let l = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
let p = l.local_addr().ok()?.port();
drop(l);
Some(p)
}
/// Start a `pds-server` with `PDS_INVITE_REQUIRED` set to `required`.
///
/// Returns `None` when the stack this needs isn't available, which the
/// callers turn into a skip. The child inherits the ambient environment
/// (so `DATABASE_URL_PDS` and friends come from `.env` exactly as they
/// do for the real server — `dotenvy` does not override real variables,
/// so our overrides below win).
async fn start_pds(required: bool) -> Option<Pds> {
let port = free_port()?;
let child = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.env("PDS_HOST", "127.0.0.1")
.env("PDS_PORT", port.to_string())
.env("PDS_PUBLIC_URL", format!("http://127.0.0.1:{port}"))
.env("PDS_INVITE_REQUIRED", if required { "true" } else { "false" })
// Point the PLC submit at a closed port. `create_account`
// tolerates a failed submit by design (the DID is computed
// locally), and a connection refused on loopback fails in
// microseconds — whereas the real directory would add a network
// round-trip to every account this file creates, and might
// actually publish throwaway test DIDs.
.env("PLC_DIRECTORY_URL", "http://127.0.0.1:1")
.env("RUST_LOG", "warn")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.ok()?;
let mut pds = Pds { child, port, http };
for _ in 0..80 {
// If the child already exited (bad/missing env, no Postgres),
// stop waiting — there is nothing to become healthy.
if let Ok(Some(_)) = pds.child.try_wait() {
return None;
}
if let Ok(r) = pds.http.get(pds.url("/healthz")).send().await {
if r.status().is_success() {
return Some(pds);
}
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
None
}
async fn db_pool() -> Option<sqlx::PgPool> {
let url = std::env::var("DATABASE_URL_PDS")
.unwrap_or_else(|_| "postgres://pds:pds@127.0.0.1:5434/pds".to_string());
sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.acquire_timeout(Duration::from_secs(3))
.connect(&url)
.await
.ok()
}
/// Put a code straight into the table in whatever state the test needs.
///
/// Tests seed through SQL rather than through `pds-server invite create`
/// because the states that matter here — already spent, disabled — are
/// not states the CLI can mint directly, and because a test that had to
/// shell out to a second binary to arrange its fixture would be testing
/// two things at once.
async fn seed_code(db: &sqlx::PgPool, max_uses: i32, used: i32, disabled: bool) -> String {
let code = format!("mt-test-{}", uuid::Uuid::new_v4().simple());
sqlx::query(
"INSERT INTO invite_codes (code, max_uses, used_count, disabled) VALUES ($1, $2, $3, $4)",
)
.bind(&code)
.bind(max_uses)
.bind(used)
.bind(disabled)
.execute(db)
.await
.expect("seed invite code");
code
}
async fn used_count(db: &sqlx::PgPool, code: &str) -> i32 {
sqlx::query_scalar::<_, i32>("SELECT used_count FROM invite_codes WHERE code = $1")
.bind(code)
.fetch_one(db)
.await
.expect("read used_count")
}
async fn use_rows(db: &sqlx::PgPool, code: &str) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
"SELECT did, handle FROM invite_code_uses WHERE code = $1 ORDER BY used_at",
)
.bind(code)
.fetch_all(db)
.await
.expect("read invite_code_uses")
}
/// A unique throwaway handle.
///
/// `createAccount` caps handles at 64 characters, and
/// `<prefix>_<32 hex>.maarcadetweet.local` overshoots that for anything
/// but the shortest prefix — a limit that shows up as a confusing
/// `InvalidHandle` in a test that is about invite codes. Half the UUID
/// is 64 bits of uniqueness, which is plenty for a test fixture and
/// leaves room for a readable prefix.
fn handle(prefix: &str) -> String {
let uniq = uuid::Uuid::new_v4().simple().to_string();
format!("{}_{}.maarcadetweet.local", prefix, &uniq[..16])
}
/// Every invite rejection must look the same to a client: `400` with the
/// module's usual `{error, message}` body and the name
/// `InvalidInviteCode`.
fn assert_invalid_invite(status: u16, body: &Value, what: &str) {
assert_eq!(status, 400, "{what}: expected 400, body = {body}");
assert_eq!(
body["error"], "InvalidInviteCode",
"{what}: wrong error name, body = {body}"
);
assert!(
body["message"].is_string(),
"{what}: error body must carry a message, body = {body}"
);
// No DID may have been minted on a rejected request.
assert!(
body["did"].is_null(),
"{what}: rejected request returned a did, body = {body}"
);
}
// -- enforcement ------------------------------------------------------------
/// The happy path, and the property that makes a single-use code
/// single-use: after the account exists, the same code is dead.
///
/// Also checks the audit trail, which is the reason
/// `invite_code_uses` exists at all — "which account did this code
/// create" has to be answerable after the fact.
#[tokio::test]
async fn valid_code_admits_one_account_then_is_spent() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
// The switch must be advertised, not just enforced — a client reads
// this before it asks the user for anything.
assert_eq!(
pds.describe().await["invite_code_required"],
json!(true),
"describeServer must report the actual PDS_INVITE_REQUIRED value"
);
let code = seed_code(&db, 1, 0, false).await;
let h = handle("inv_ok");
let (status, body) = pds
.create_account(json!({
"handle": h,
"password": "hunter2hunter2",
"invite_code": code,
}))
.await;
assert_eq!(status, 200, "valid code must create an account: {body}");
let did = body["did"].as_str().expect("did").to_string();
assert_eq!(used_count(&db, &code).await, 1, "code must be counted as used");
let uses = use_rows(&db, &code).await;
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].0, did, "audit row must name the account it created");
assert_eq!(uses[0].1, h, "audit row must snapshot the handle");
// Second attempt on the now-spent code.
let (status2, body2) = pds
.create_account(json!({
"handle": handle("inv_second"),
"password": "hunter2hunter2",
"invite_code": code,
}))
.await;
assert_invalid_invite(status2, &body2, "spent code");
assert_eq!(
used_count(&db, &code).await,
1,
"a rejected attempt must not move the counter"
);
}
/// Every way a code can fail, and the missing-code case, all land on the
/// same `400 InvalidInviteCode`.
#[tokio::test]
async fn unknown_disabled_spent_and_missing_codes_are_rejected() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
// Unknown.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_unknown"),
"password": "hunter2hunter2",
"invite_code": "mt-zzzzz-zzzzz",
}))
.await;
assert_invalid_invite(s, &b, "unknown code");
// Disabled, with uses left — proves `disabled` is checked and not
// just the counter.
let disabled = seed_code(&db, 5, 0, true).await;
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_disabled"),
"password": "hunter2hunter2",
"invite_code": disabled,
}))
.await;
assert_invalid_invite(s, &b, "disabled code");
assert_eq!(used_count(&db, &disabled).await, 0);
// Already at its limit.
let spent = seed_code(&db, 2, 2, false).await;
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_spent"),
"password": "hunter2hunter2",
"invite_code": spent,
}))
.await;
assert_invalid_invite(s, &b, "exhausted code");
// No field at all.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_none"),
"password": "hunter2hunter2",
}))
.await;
assert_invalid_invite(s, &b, "missing code");
// Present but blank / whitespace — must be indistinguishable from
// absent, not an attempt to look up the empty string.
for blank in ["", " "] {
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_blank"),
"password": "hunter2hunter2",
"invite_code": blank,
}))
.await;
assert_invalid_invite(s, &b, "blank code");
}
}
/// The camelCase spelling from the atproto lexicon, and the multi-use
/// case the schema exists for.
#[tokio::test]
async fn camel_case_spelling_works_and_multi_use_codes_stop_at_the_limit() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
// `inviteCode` is what an off-the-shelf atproto client sends.
let code = seed_code(&db, 3, 0, false).await;
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_camel"),
"password": "hunter2hunter2",
"inviteCode": code,
}))
.await;
assert_eq!(s, 200, "inviteCode spelling must be accepted: {b}");
// Case and stray whitespace are normalised, so a code shouted or
// pasted out of a chat window still works.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_case"),
"password": "hunter2hunter2",
"invite_code": format!(" {} ", code.to_uppercase()),
}))
.await;
assert_eq!(s, 200, "normalised code must be accepted: {b}");
// Third and last use.
let (s, _) = pds
.create_account(json!({
"handle": handle("inv_third"),
"password": "hunter2hunter2",
"invite_code": code,
}))
.await;
assert_eq!(s, 200);
// Fourth is one too many.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_fourth"),
"password": "hunter2hunter2",
"invite_code": code,
}))
.await;
assert_invalid_invite(s, &b, "one past max_uses");
assert_eq!(used_count(&db, &code).await, 3);
assert_eq!(use_rows(&db, &code).await.len(), 3);
}
/// A failed account creation must not consume the code.
///
/// The cheapest way to make the account creation fail *after* the
/// redemption has already run is a handle that is already taken: the
/// redeem happens first inside the transaction, the `users` insert then
/// trips the unique index, and the whole transaction rolls back. If the
/// redemption had been done outside the transaction (or committed
/// separately) the user would have lost their code to someone else's
/// handle.
#[tokio::test]
async fn a_failed_registration_does_not_burn_the_code() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
let taken = handle("inv_taken");
let first = seed_code(&db, 1, 0, false).await;
let (s, b) = pds
.create_account(json!({
"handle": taken, "password": "hunter2hunter2", "invite_code": first,
}))
.await;
assert_eq!(s, 200, "{b}");
// Now a *different* code, used on a handle that cannot be created.
let code = seed_code(&db, 1, 0, false).await;
let (s, _b) = pds
.create_account(json!({
"handle": taken, "password": "hunter2hunter2", "invite_code": code,
}))
.await;
assert_eq!(s, 409, "duplicate handle is still a 409");
assert_eq!(
used_count(&db, &code).await,
0,
"the code must survive a registration that rolled back"
);
assert!(use_rows(&db, &code).await.is_empty());
// And it still works afterwards.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_retry"), "password": "hunter2hunter2", "invite_code": code,
}))
.await;
assert_eq!(s, 200, "unburned code must still be redeemable: {b}");
}
// -- the race ---------------------------------------------------------------
/// Two (here: eight) registrations arriving at the same instant on the
/// last remaining use of a code. Exactly one may get in.
///
/// This is the test the whole design is built around. A
/// `SELECT`-then-`UPDATE` implementation passes every other test in this
/// file and fails this one: all eight requests read `used_count = 0`,
/// all eight decide they are allowed, and the server hands out eight
/// accounts for a one-use code while the row afterwards claims a single
/// redemption. The fix is that `invite::redeem` never reads before it
/// writes — the `WHERE used_count < max_uses` is part of the `UPDATE`,
/// so Postgres re-evaluates it against the committed row after the
/// row lock is released and the losers match zero rows.
///
/// Every request uses a distinct handle, so nothing but the invite code
/// can be what serialises them.
#[tokio::test]
async fn concurrent_registrations_cannot_share_one_use() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
const N: usize = 8;
let code = seed_code(&db, 1, 0, false).await;
let mut tasks = Vec::with_capacity(N);
for i in 0..N {
let http = pds.http.clone();
let url = pds.url("/xrpc/com.atproto.server.createAccount");
let code = code.clone();
let h = handle(&format!("inv_race{i}"));
tasks.push(tokio::spawn(async move {
let resp = http
.post(url)
.json(&json!({
"handle": h,
"password": "hunter2hunter2",
"invite_code": code,
}))
.send()
.await
.expect("concurrent createAccount");
let status = resp.status().as_u16();
let body: Value = resp.json().await.unwrap_or(Value::Null);
(status, body)
}));
}
let mut ok = Vec::new();
let mut rejected = 0usize;
for t in tasks {
let (status, body) = t.await.unwrap();
match status {
200 => ok.push(body),
400 => {
assert_eq!(body["error"], "InvalidInviteCode", "body = {body}");
rejected += 1;
}
other => panic!("unexpected status {other}: {body}"),
}
}
assert_eq!(
ok.len(),
1,
"a one-use code let {} concurrent registrations through — the redeem is racy",
ok.len()
);
assert_eq!(rejected, N - 1);
assert_eq!(used_count(&db, &code).await, 1);
let uses = use_rows(&db, &code).await;
assert_eq!(uses.len(), 1, "counter and audit rows disagree: {uses:?}");
assert_eq!(uses[0].0, ok[0]["did"].as_str().unwrap());
}
/// The same race with room for more than one winner: a three-use code
/// hit by eight simultaneous registrations must admit exactly three.
///
/// Worth having next to the one-use case because an implementation can
/// be "safe" by accident for a single use (e.g. by serialising every
/// registration globally) and still lose count when several are
/// genuinely allowed to proceed.
#[tokio::test]
async fn concurrent_registrations_respect_a_multi_use_limit() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
return;
};
const N: usize = 8;
const USES: i32 = 3;
let code = seed_code(&db, USES, 0, false).await;
let mut tasks = Vec::with_capacity(N);
for i in 0..N {
let http = pds.http.clone();
let url = pds.url("/xrpc/com.atproto.server.createAccount");
let code = code.clone();
let h = handle(&format!("inv_mrace{i}"));
tasks.push(tokio::spawn(async move {
let resp = http
.post(url)
.json(&json!({
"handle": h,
"password": "hunter2hunter2",
"invite_code": code,
}))
.send()
.await
.expect("concurrent createAccount");
let status = resp.status().as_u16();
let body: Value = resp.json().await.unwrap_or(Value::Null);
(status, body)
}));
}
let mut ok = 0usize;
for t in tasks {
let (status, body) = t.await.unwrap();
match status {
200 => ok += 1,
400 => assert_eq!(body["error"], "InvalidInviteCode", "body = {body}"),
other => panic!("unexpected status {other}: {body}"),
}
}
assert_eq!(ok, USES as usize, "a {USES}-use code admitted {ok} accounts");
assert_eq!(used_count(&db, &code).await, USES);
assert_eq!(use_rows(&db, &code).await.len(), USES as usize);
}
// -- the switch off ---------------------------------------------------------
/// With `PDS_INVITE_REQUIRED=false` — the default, and what every other
/// test suite in this workspace relies on — nothing about `createAccount`
/// changes.
///
/// This is the regression test for the whole feature's blast radius: the
/// switch is off by default precisely so that the existing suites keep
/// creating accounts with no code, and if that ever stopped being true
/// the failure would show up as dozens of unrelated tests breaking. It
/// shows up here instead.
#[tokio::test]
async fn switch_off_leaves_create_account_untouched() {
let Some(_db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let Some(pds) = start_pds(false).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=false, skipping");
return;
};
assert_eq!(
pds.describe().await["invite_code_required"],
json!(false),
"describeServer must report the actual PDS_INVITE_REQUIRED value"
);
// No code at all: the historical behaviour.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_off"),
"password": "hunter2hunter2",
}))
.await;
assert_eq!(s, 200, "no-code registration must still work: {b}");
assert!(b["did"].as_str().unwrap().starts_with("did:"));
assert!(b["access_jwt"].is_string());
// A code that does not exist is simply ignored rather than becoming
// a new way to fail — a client that was talking to an invite-only
// PDS yesterday must not break when the operator opens the server up.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_off_bogus"),
"password": "hunter2hunter2",
"invite_code": "mt-does-notexist",
}))
.await;
assert_eq!(s, 200, "an ignored code must not fail the request: {b}");
// The other validations are untouched.
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_off_short"),
"password": "short",
}))
.await;
assert_eq!(s, 400);
assert_eq!(b["error"], "InvalidPassword");
}
// -- the CLI ----------------------------------------------------------------
/// `pds-server invite create` / `list` / `disable`, run as the operator
/// would run them, against the real database.
///
/// The point is not that the SQL works (the tests above cover that) but
/// that the *binary* exposes it: that `invite` short-circuits before the
/// server starts, that `create` prints bare codes one per line so they
/// can be pasted, and that a code it minted is actually redeemable.
#[tokio::test]
async fn invite_cli_mints_listable_redeemable_codes() {
let Some(db) = db_pool().await else {
eprintln!("no pds database, skipping");
return;
};
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invite", "create", "--count", "3", "--uses", "2", "--note", "cli test"])
.env("RUST_LOG", "warn")
.output()
.expect("run invite create");
if !out.status.success() {
eprintln!(
"invite create failed (no env/db?), skipping: {}",
String::from_utf8_lossy(&out.stderr)
);
return;
}
let stdout = String::from_utf8_lossy(&out.stdout);
let codes: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(codes.len(), 3, "one code per line, nothing else: {stdout:?}");
for c in &codes {
// Bare, paste-ready: no labels, no quotes, no indentation.
assert_eq!(*c, c.trim(), "code line has surrounding whitespace: {c:?}");
assert!(c.starts_with("mt-"), "unexpected code shape: {c}");
assert_eq!(
sqlx::query_scalar::<_, i32>("SELECT max_uses FROM invite_codes WHERE code = $1")
.bind(c)
.fetch_one(&db)
.await
.expect("minted code must be in the table"),
2,
"--uses must reach the row"
);
}
// All three distinct — a generator that returned a constant would
// otherwise only show up as a primary-key error.
let unique: std::collections::HashSet<&&str> = codes.iter().collect();
assert_eq!(unique.len(), 3);
// `list` must show what `redeem` would accept.
let listed = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invite", "list"])
.env("RUST_LOG", "warn")
.output()
.expect("run invite list");
assert!(listed.status.success());
let listed = String::from_utf8_lossy(&listed.stdout);
for c in &codes {
assert!(listed.contains(*c), "invite list omitted {c}");
}
// `disable` takes a code out without deleting it.
let disabled = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invite", "disable", codes[0]])
.env("RUST_LOG", "warn")
.output()
.expect("run invite disable");
assert!(disabled.status.success());
assert!(
sqlx::query_scalar::<_, bool>("SELECT disabled FROM invite_codes WHERE code = $1")
.bind(codes[0])
.fetch_one(&db)
.await
.unwrap()
);
let listed = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invite", "list"])
.env("RUST_LOG", "warn")
.output()
.expect("run invite list");
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
!listed.contains(codes[0]),
"a disabled code must not show in the default listing"
);
// And a minted code really lets an account through.
let Some(pds) = start_pds(true).await else {
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping redeem check");
return;
};
let (s, b) = pds
.create_account(json!({
"handle": handle("inv_cli"),
"password": "hunter2hunter2",
"invite_code": codes[1],
}))
.await;
assert_eq!(s, 200, "CLI-minted code must be redeemable: {b}");
}
/// An unknown subcommand must not silently boot a server, and `help`
/// must not need a database.
#[tokio::test]
async fn unknown_subcommand_fails_instead_of_starting_a_server() {
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invit"])
.env("RUST_LOG", "warn")
.output()
.expect("run bad subcommand");
assert!(!out.status.success(), "a typo'd subcommand must not exit 0");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("unknown command"), "stderr = {stderr}");
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
.args(["invite", "help"])
.env("RUST_LOG", "warn")
.output()
.expect("run invite help");
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains("pds-server invite"));
}
+1 -26
View File
@@ -44,32 +44,7 @@ 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());
// `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}"
);
}
assert_eq!(r["invite_code_required"], json!(false));
}
/// `GET /.well-known/did.json` — the document the AppView fetches to
+5 -152
View File
@@ -34,32 +34,15 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::V
state.pds.describe_server().await.map_err(|e| e.to_string())
}
/// `auth_register(handle, password, inviteCode?)` — create an account
/// on the configured PDS and store the resulting session.
///
/// `invite_code` arrives from the frontend as `inviteCode` (Tauri maps
/// camelCase JS argument keys onto snake_case Rust parameters, the same
/// way `mark_notifications_seen` receives `seenAt`). It is `Option`
/// because the invite gate is a *server* setting
/// (`PDS_INVITE_REQUIRED`): the public instance at
/// `https://tweet.maarcade.com` demands a code, a locally run dev PDS
/// usually does not, and the client has no business deciding which.
/// When no code is given the field is dropped from the request body
/// rather than sent empty — see [`pds_client::CreateAccountReq`].
///
/// A refused code surfaces as the PDS's `400
/// {"error":"InvalidInviteCode", …}` inside the stringified error, and
/// `errorMessage()` in `client.ts` turns that into German copy.
#[tauri::command]
async fn auth_register(
state: tauri::State<'_, AppState>,
handle: String,
password: String,
invite_code: Option<String>,
) -> Result<AccountSession, String> {
let sess = state
.pds
.create_account(&handle, &password, invite_code.as_deref())
.create_account(&handle, &password)
.await
.map_err(|e| e.to_string())?;
let s = AccountSession {
@@ -785,74 +768,16 @@ async fn show_notification(
Ok(())
}
/// Default PDS base URL — the public instance.
///
/// This is what a *shipped* build talks to. It used to be
/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to
/// anyone but the developer pointed at a server that does not exist on
/// their machine: every call failed with a connection error and the
/// login screen could not even render `describeServer`. A default is
/// the configuration of the people who never set one, so it has to be
/// the production deployment.
///
/// No trailing slash: [`PdsHttpClient`] builds its endpoints as
/// `{base}/xrpc/com.atproto.…`, so the base must end at the host.
const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com";
/// Default AppView base URL. Same host as the PDS — the reverse proxy
/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…`
/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends
/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)`
/// calls in `appview_client.rs`), so the two clients can and must
/// share the one origin.
const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com";
/// Resolve one base URL from its environment variable, falling back to
/// the compiled-in default.
///
/// **The environment always wins.** Development runs against a local
/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and
/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what
/// `scripts/` and the dev docker-compose set up — and pointing the
/// desktop client at it must stay a matter of exporting two variables,
/// never of rebuilding. Only an *unset* variable takes the production
/// default.
///
/// A variable set to whitespace (or the empty string) counts as unset:
/// an empty base URL would silently produce request URLs like
/// `/xrpc/…` with no host, and `reqwest` would reject them as a
/// relative-URL error far away from the actual mistake. Trailing
/// slashes are trimmed because both clients append an absolute path to
/// this string, and `https://host//api/x` is not the same route to
/// every proxy.
///
/// Takes the already-performed lookup rather than the variable name so
/// it stays a pure function — testable without mutating the process
/// environment, and without a Tauri runtime.
fn base_url_or_default(from_env: Result<String, std::env::VarError>, default: &str) -> String {
let configured = from_env.ok();
let trimmed = configured
.as_deref()
.map(|v| v.trim().trim_end_matches('/'))
.filter(|v| !v.is_empty());
trimmed.unwrap_or(default).to_string()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let pds_url = base_url_or_default(
std::env::var("MAARCADETWEET_PDS_URL"),
DEFAULT_PDS_URL,
);
let appview_url = base_url_or_default(
std::env::var("MAARCADETWEET_APPVIEW_URL"),
DEFAULT_APPVIEW_URL,
);
tracing::info!(%pds_url, %appview_url, "resolved backend base URLs");
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
let state = AppState {
pds: PdsHttpClient::new(pds_url.clone()),
@@ -1131,75 +1056,3 @@ async fn profile_set(
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::VarError;
/// The whole point of the change: a build with nothing configured
/// must talk to the public instance, not to a loopback port that
/// only exists on a developer's laptop. Pinned as a literal so a
/// well-meant "let's default back to localhost for dev" has to
/// argue with a red test first.
#[test]
fn unset_env_falls_back_to_the_public_instance() {
assert_eq!(
base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL),
"https://tweet.maarcade.com"
);
assert_eq!(
base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL),
"https://tweet.maarcade.com"
);
// Both services live behind the same origin — the proxy splits
// them by path prefix (`/xrpc/` vs `/api/`), which the clients
// append themselves.
assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL);
assert!(!DEFAULT_PDS_URL.ends_with('/'));
}
/// Development against a local stack has to keep working by
/// exporting a variable, so a set value always beats the default.
#[test]
fn env_var_overrides_the_default() {
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL),
"http://127.0.0.1:2583"
);
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL),
"http://127.0.0.1:2584"
);
}
/// An empty or whitespace-only variable is a misconfiguration, not
/// a request for an empty base URL: `reqwest` would answer the
/// resulting host-less URL with a relative-URL error nowhere near
/// the cause. Trailing slashes go because both clients append an
/// absolute path (`{base}/xrpc/…`, `{base}/api/…`).
#[test]
fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() {
assert_eq!(
base_url_or_default(Ok("".into()), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
assert_eq!(
base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
assert_eq!(
base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL),
"http://127.0.0.1:2584"
);
assert_eq!(
base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL),
"https://tweet.maarcade.com"
);
// A non-UTF-8 variable is as unusable as an unset one.
assert_eq!(
base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL),
DEFAULT_PDS_URL
);
}
}
@@ -26,27 +26,6 @@ pub struct CreateAccountReq {
pub handle: String,
pub email: Option<String>,
pub password: String,
/// Invite code, required by the PDS whenever it runs with
/// `PDS_INVITE_REQUIRED=true` (the public instance at
/// `https://tweet.maarcade.com` does). Rejected codes come back as
/// `400 {"error":"InvalidInviteCode", …}`.
///
/// **Wire name.** The server's `CreateAccountReq`
/// (`crates/pds-server/src/routes/types.rs`) is snake_case with an
/// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto
/// clients. We are not one of those: every body this client sends
/// is snake_case (`refresh_jwt` in `refresh_session`, `handle` /
/// `password` right here), so `invite_code` is the field name that
/// matches the rest of the file. The alias exists for other people.
///
/// **Skipped when `None`.** Same reasoning as `validate` below —
/// a PDS with the invite gate *off* must keep accepting our
/// registrations, and sending `"invite_code": null` (let alone
/// `""`) would be claiming the user supplied something. Omitting
/// the key leaves the server's `Option` at `None`, which is exactly
/// "the user gave no code".
#[serde(skip_serializing_if = "Option::is_none")]
pub invite_code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -151,31 +130,15 @@ impl PdsHttpClient {
Ok(r)
}
/// `com.atproto.server.createAccount`.
///
/// `invite_code` is `None` when the user left the field empty; the
/// key is then left out of the body entirely (see
/// [`CreateAccountReq::invite_code`]) so a PDS running without the
/// invite gate still registers the account. A code that only
/// contains whitespace is treated as absent for the same reason —
/// the server's `invite::normalize` trims before looking it up, so
/// `" "` could never match a real code anyway, and passing it on
/// would only turn "you forgot the field" into "your code is
/// wrong".
pub async fn create_account(
&self,
handle: &str,
password: &str,
invite_code: Option<&str>,
) -> Result<AccountSession> {
let body = CreateAccountReq {
handle: handle.to_string(),
email: None,
password: password.to_string(),
invite_code: invite_code
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string),
};
let r = self
.client
+2 -8
View File
@@ -545,23 +545,17 @@
// Used in the Settings view to show which backends the client is
// talking to. Kept as plain helpers so they can be swapped for a
// `pds_describe`/`appview_describe` Tauri command later.
// The fallbacks mirror `DEFAULT_PDS_URL` / `DEFAULT_APPVIEW_URL` in
// `src-tauri/src/lib.rs`: both services sit behind the one public
// origin, split by path prefix (`/xrpc/` → PDS, `/api/` → AppView).
// If those constants ever move, move these with them — a Settings
// pane that names the wrong backend is worse than one that names
// none.
function pdsBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
return (import.meta as any).env.VITE_PDS_URL as string;
}
return "https://tweet.maarcade.com";
return "http://127.0.0.1:2583";
}
function appviewBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
return (import.meta as any).env.VITE_APPVIEW_URL as string;
}
return "https://tweet.maarcade.com";
return "http://127.0.0.1:2584";
}
</script>
+2 -56
View File
@@ -142,21 +142,6 @@ export function isAuthFailure(e: unknown): boolean {
);
}
/// True when the PDS refused a registration because of the invite
/// code. The public instance runs with `PDS_INVITE_REQUIRED=true` and
/// answers `400 {"error":"InvalidInviteCode","message":…}` — the same
/// code for a missing, misspelled, disabled and already-spent code, on
/// purpose: the server does not tell an unauthenticated caller which
/// of those it was, since that would make invite codes enumerable.
///
/// Matched on the string for the same reason as [`isTokenInvalid`]:
/// `pds_client.rs` bails with `createAccount failed: {status} {body}`
/// and `lib.rs` stringifies that into the command's `Err(String)`, so
/// the code travels verbatim across the IPC boundary.
function isInvalidInviteCode(e: unknown): boolean {
return errorText(e).includes("InvalidInviteCode");
}
/// User-facing copy for a failed call, in the app's German UI voice.
///
/// An auth failure gets a sentence naming the actual remedy. The raw
@@ -170,18 +155,6 @@ export function errorMessage(e: unknown): string {
if (isAuthFailure(e)) {
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
}
if (isInvalidInviteCode(e)) {
// Deliberately covers "no code given" too: the raw body a user
// would otherwise read is `createAccount failed: 400 Bad Request
// {"error":"InvalidInviteCode","message":"a valid invite code is
// required to create an account on this server"}`. Since the
// server refuses to say *which* way the code was wrong, the copy
// names both plausible fixes rather than guessing one.
return (
"Einladungscode ungültig oder bereits verbraucht — " +
"bitte prüfen oder einen neuen Code anfordern."
);
}
return String(e);
}
@@ -235,38 +208,11 @@ function createSessionStore() {
set(s);
return s;
},
/// Create an account on the configured PDS.
///
/// `inviteCode` is optional because the invite gate lives on the
/// *server* (`PDS_INVITE_REQUIRED`): the public instance at
/// `https://tweet.maarcade.com` requires a code, a local dev PDS
/// normally does not. The client therefore never refuses a
/// registration for a missing code on its own — it would break
/// development against localhost — it just forwards what the user
/// typed and lets the PDS decide.
///
/// An empty (or whitespace-only) field is *omitted*, not sent as
/// `""`. Those are two different statements: "I gave no code" vs.
/// "my code is the empty string". The first is legitimate against
/// an open server; the second is never true and would only turn
/// into a confusing `InvalidInviteCode` on a server that has the
/// gate switched off. Dropping the key leaves the Rust
/// `Option<String>` at `None`, and `create_account` then leaves
/// the field out of the JSON body entirely.
async register(handle: string, password: string, inviteCode?: string) {
async register(handle: string, password: string) {
if (!isTauri()) {
throw new Error("register requires the Tauri desktop runtime");
}
const code = inviteCode?.trim();
const s = await safeInvoke<Session>("auth_register", {
handle,
password,
// Explicit `null` rather than a dropped key, matching how
// `createPost` passes its optional `embed` / `reply`: it
// deserialises into the Rust `Option<String>` as `None`
// without depending on how `invoke` treats `undefined`.
inviteCode: code ? code : null,
});
const s = await safeInvoke<Session>("auth_register", { handle, password });
set(s);
return s;
},
-138
View File
@@ -1,138 +0,0 @@
// Unit tests for the invite-code half of the registration path.
//
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
// mocked so no Tauri shell is needed, and every assertion is about the
// exact command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — `invoke` maps camelCase JS keys onto
// the snake_case Rust command parameters (`inviteCode` → `invite_code`
// on `auth_register`), so a typo here surfaces at runtime as a null
// argument, not at compile time.
//
// Covered:
// * `session.register` — the code is forwarded as `inviteCode`,
// alongside the unchanged `handle` / `password`;
// * the empty / whitespace-only field — must reach the shell as
// `null` ("no code given"), never as `""` ("my code is the empty
// string"), because a PDS without the invite gate has to keep
// accepting registrations;
// * `errorMessage` — the PDS's `InvalidInviteCode` body becomes
// German copy instead of the raw wire string.
//
// Run with:
// npx vitest run src/lib/api/invite.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
/// What the Rust `auth_register` command answers with on success.
const SESSION = {
did: "did:plc:alice",
handle: "alice.tweet.maarcade.com",
access_jwt: "acc",
refresh_jwt: "ref",
};
beforeEach(() => {
invokeMock.mockReset();
});
describe("session.register", () => {
it("forwards the invite code as `inviteCode`", async () => {
const { session } = await import("./client");
invokeMock.mockResolvedValueOnce(SESSION);
const s = await session.register(
"alice.tweet.maarcade.com",
"hunter2hunter2",
"mt-7k3qw-z9d2m",
);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("auth_register", {
handle: "alice.tweet.maarcade.com",
password: "hunter2hunter2",
inviteCode: "mt-7k3qw-z9d2m",
});
expect(s.did).toBe("did:plc:alice");
});
it("trims the surrounding whitespace off a pasted code", async () => {
const { session } = await import("./client");
invokeMock.mockResolvedValueOnce(SESSION);
// Copying a code out of a chat message routinely drags a space or
// a newline along. The server trims too (`invite::normalize`), but
// sending the untrimmed string would mean the *client* and the
// server disagree about whether the field is empty.
await session.register("alice.test", "pw", " mt-7k3qw-z9d2m\n");
expect(invokeMock.mock.calls[0][1]).toMatchObject({
inviteCode: "mt-7k3qw-z9d2m",
});
});
it("sends null — never an empty string — when the field is blank", async () => {
const { session } = await import("./client");
// Three ways the UI can hand us "nothing": the argument omitted
// entirely (login-shaped call), an untouched input, and an input
// holding only whitespace. All three mean "the user gave no code"
// and must arrive at the Rust `Option<String>` as `None`, so that
// a PDS running without `PDS_INVITE_REQUIRED` still registers the
// account instead of rejecting a blank code.
for (const blank of [undefined, "", " "]) {
invokeMock.mockReset();
invokeMock.mockResolvedValueOnce(SESSION);
await session.register("alice.test", "pw", blank);
const args = invokeMock.mock.calls[0][1] as Record<string, unknown>;
expect(args.inviteCode).toBeNull();
expect(args.inviteCode).not.toBe("");
// The rest of the bag is unaffected.
expect(args.handle).toBe("alice.test");
expect(args.password).toBe("pw");
}
});
});
describe("errorMessage for InvalidInviteCode", () => {
/// Exactly what crosses the IPC boundary when the PDS refuses the
/// code: `pds_client.rs` bails with `createAccount failed: {status}
/// {body}` and `lib.rs` stringifies that into `Err(String)`, which
/// `invoke` rejects with as a bare JS string.
const RAW =
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
'"message":"a valid invite code is required to create an account on this server"}';
it("replaces the raw 400 body with copy the user can act on", async () => {
const { errorMessage } = await import("./client");
const msg = errorMessage(RAW);
expect(msg).toContain("Einladungscode");
// None of the wire noise survives into the UI.
expect(msg).not.toContain("InvalidInviteCode");
expect(msg).not.toContain("400");
// Both shapes a rejected `invoke` can produce — a bare string and
// an Error — go through the same `errorText` normalisation.
expect(errorMessage(new Error(RAW))).toBe(msg);
});
it("leaves unrelated registration failures verbatim", async () => {
const { errorMessage } = await import("./client");
// A taken handle is a different 400 and has its own message worth
// showing; the invite branch must not swallow it.
expect(
errorMessage(
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
),
).toContain("HandleNotAvailable");
expect(errorMessage("createAccount failed: 500 db down")).toContain("500");
});
});
@@ -1,11 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import {
session,
describeServer,
errorMessage,
type Session,
} from "../api/client";
import { session, describeServer, type Session } from "../api/client";
let { onLogin }: { onLogin: (s: Session) => void } = $props();
@@ -15,9 +10,6 @@
let mode: "login" | "register" = $state("login");
let handle: string = $state("");
let password: string = $state("");
// Only meaningful in "register" mode — the field below is rendered
// solely there, and `submit()` only forwards it on that branch.
let inviteCode: string = $state("");
let busy = $state(false);
let error: string | null = $state(null);
let serverInfo: any = $state(null);
@@ -35,40 +27,16 @@
busy = true;
error = null;
try {
// The invite code is deliberately *not* part of the guard above.
// Whether one is required is a server setting
// (`PDS_INVITE_REQUIRED`): the public instance demands a code, a
// dev PDS on localhost usually does not. Refusing to submit
// without one would make the client unusable against the second
// kind of server for a rule it cannot see. So we forward what
// the user typed — `session.register` drops an empty string
// instead of sending a blank code — and let the PDS answer.
const s = mode === "register"
? await session.register(handle, password, inviteCode)
? await session.register(handle, password)
: await session.login(handle, password);
onLogin(s);
} catch (e) {
// `errorMessage` translates the failures worth naming — an
// expired session, and a rejected `InvalidInviteCode` — into
// German copy, and passes everything else through verbatim.
error = errorMessage(e);
error = String(e);
} finally {
busy = false;
}
}
/// Clear the form's mode-specific state when switching sides.
///
/// Without this, a code typed while registering would linger in the
/// hidden field: switch to login, switch back, and the stale value
/// is silently submitted again. The error goes too — the message
/// from a failed registration says nothing about the login the user
/// is now attempting.
function toggleMode() {
mode = mode === "register" ? "login" : "register";
inviteCode = "";
error = null;
}
</script>
<div class="login">
@@ -107,30 +75,6 @@
autocomplete={mode === "register" ? "new-password" : "current-password"}
/>
</label>
{#if mode === "register"}
<!--
Registration only. Logging in never carries a code, and a
field that is present but meaningless invites people to
fill it in. `{#if}` removes it from the DOM rather than
hiding it, so it also drops out of the tab order.
-->
<label class="field">
<span class="key">einladungscode</span>
<input
type="text"
bind:value={inviteCode}
placeholder="mt-xxxxx-xxxxx"
disabled={busy}
onkeydown={(e) => e.key === "Enter" && submit()}
autocomplete="off"
autocapitalize="none"
spellcheck="false"
/>
<span class="hint">
// von dieser Instanz verlangt — ohne Code keine Registrierung
</span>
</label>
{/if}
</form>
{#if error}
<div class="err">err: {error}</div>
@@ -139,7 +83,7 @@
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
{busy ? "..." : mode === "register" ? "create account" : "log in"}
</button>
<button class="btn btn--ghost" onclick={toggleMode} disabled={busy}>
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
{mode === "register" ? "have an account? log in" : "no account? register"}
</button>
</div>
@@ -228,15 +172,6 @@
font-size: var(--fs-50);
letter-spacing: var(--tracking-label);
}
/* Sub-label under the invite field. Same dim mono voice as the
`// pds: …` server meta line above the form, so it reads as a
comment on the field rather than as a second input label. */
.hint {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
opacity: 0.8;
}
.form input {
background: var(--bg);
border: 1px solid var(--line-2);
@@ -1,231 +0,0 @@
// Regression guard for the invite-code field on the login screen.
//
// The public instance runs the PDS with `PDS_INVITE_REQUIRED=true`, so
// `createAccount` without a code is refused with `400
// {"error":"InvalidInviteCode", …}`. Three things have to hold for the
// screen to be usable against it:
//
// 1. the field exists in "register" mode and *not* in "login" mode —
// a code is meaningless when signing in, and an input that is
// present but ignored invites people to fill it in;
// 2. what the user typed reaches `session.register` as its third
// argument, and an untouched field does not become a blank code;
// 3. a rejected code renders as German copy, not as the raw wire
// body, which is where the user would otherwise read
// `createAccount failed: 400 Bad Request {"error":…}`.
//
// Setup follows `NotificationsView.test.ts`: the component is mounted
// against jsdom with `../api/client` partially mocked — the real
// `errorMessage` is kept, since the error copy is part of what we are
// asserting on, and only the calls that would need a Tauri runtime are
// stubbed.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const registerMock = vi.fn();
const loginMock = vi.fn();
const describeServerMock = vi.fn();
vi.mock("../api/client", async () => {
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
// Keep the real `errorMessage` from `actual` — the German copy for
// `InvalidInviteCode` is exactly what test 3 checks.
describeServer: (...args: unknown[]) => describeServerMock(...args),
session: {
...actual.session,
register: (...args: unknown[]) => registerMock(...args),
login: (...args: unknown[]) => loginMock(...args),
},
};
});
import LoginScreen from "./LoginScreen.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
const SESSION = {
did: "did:plc:alice",
handle: "alice.tweet.maarcade.com",
access_jwt: "acc",
refresh_jwt: "ref",
};
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
registerMock.mockReset();
loginMock.mockReset();
describeServerMock.mockReset();
// `onMount` calls this; a resolved stub keeps the meta line quiet.
describeServerMock.mockResolvedValue({ did: "did:web:tweet.maarcade.com" });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let `onMount`, the mocked promises and Svelte's flush settle.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
/// The screen has no test ids; the inputs are addressed the way a user
/// would, by the label text beside them.
function fieldByLabel(label: string): HTMLInputElement | null {
for (const el of target.querySelectorAll("label.field")) {
if (el.querySelector(".key")?.textContent?.trim() === label) {
return el.querySelector("input");
}
}
return null;
}
function typeInto(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
/// Flip login ⇄ register via the ghost button under the form.
async function toggleMode() {
const buttons = [...target.querySelectorAll("button.btn--ghost")];
buttons[buttons.length - 1].dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await tick();
}
async function submitForm() {
target
.querySelector("button.btn--primary")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
}
async function mountScreen() {
app = mount(LoginScreen, { target, props: { onLogin: vi.fn() } });
await flush();
}
describe("LoginScreen invite field", () => {
it("shows the code field only while registering", async () => {
await mountScreen();
// "login" is the default mode — no invite field, and nothing in
// the tab order either, since `{#if}` removes it from the DOM.
expect(fieldByLabel("einladungscode")).toBeNull();
await toggleMode();
expect(fieldByLabel("einladungscode")).not.toBeNull();
await toggleMode();
expect(fieldByLabel("einladungscode")).toBeNull();
});
it("passes the typed code to session.register as the third argument", async () => {
registerMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.tweet.maarcade.com");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-7k3qw-z9d2m");
await submitForm();
expect(loginMock).not.toHaveBeenCalled();
expect(registerMock).toHaveBeenCalledTimes(1);
expect(registerMock).toHaveBeenCalledWith(
"alice.tweet.maarcade.com",
"hunter2hunter2",
"mt-7k3qw-z9d2m",
);
});
it("does not turn an untouched field into a blank code", async () => {
registerMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
// Invite field deliberately left alone. The submit must still go
// through: whether a code is required is the *server's* call
// (`PDS_INVITE_REQUIRED`), and a dev PDS on localhost runs without
// the gate. What must not happen is a blank code travelling on as
// if the user had entered one.
await submitForm();
expect(registerMock).toHaveBeenCalledTimes(1);
const code = registerMock.mock.calls[0][2];
expect(code === "" || code === undefined).toBe(true);
expect(code?.trim?.() ?? "").toBe("");
});
it("clears a typed code when switching back to login", async () => {
loginMock.mockResolvedValue(SESSION);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-stale-code0");
// Back to login, then to register again: a stale code lingering in
// the hidden field would be submitted silently on the next try.
await toggleMode();
await toggleMode();
expect(fieldByLabel("einladungscode")!.value).toBe("");
});
it("renders German copy when the PDS rejects the code", async () => {
// Verbatim what reaches the component: `pds_client.rs` bails with
// `createAccount failed: {status} {body}`, `lib.rs` stringifies it
// into the command's `Err(String)`, and `invoke` rejects with that
// bare string.
registerMock.mockRejectedValue(
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
'"message":"a valid invite code is required to create an account on this server"}',
);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
typeInto(fieldByLabel("einladungscode")!, "mt-wrong-code0");
await submitForm();
const err = target.querySelector(".err");
expect(err).not.toBeNull();
expect(err!.textContent).toContain("Einladungscode");
// The wire noise the user would otherwise be shown is gone.
expect(err!.textContent).not.toContain("InvalidInviteCode");
expect(err!.textContent).not.toContain("400 Bad Request");
});
it("still shows an unrelated failure verbatim", async () => {
// The invite branch must not swallow every registration error —
// a taken handle has its own message worth reading.
registerMock.mockRejectedValue(
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
);
await mountScreen();
await toggleMode();
typeInto(fieldByLabel("handle")!, "alice.test");
typeInto(fieldByLabel("password")!, "hunter2hunter2");
await submitForm();
expect(target.querySelector(".err")!.textContent).toContain(
"HandleNotAvailable",
);
});
});
+3 -47
View File
@@ -296,57 +296,13 @@ beiden.
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
Update findet und die Signaturprüfung durchgeht.
## 8. CI: Tag-Release über Gitea Actions
`.gitea/workflows/release.yml` baut Schritt 2 der Checkliste für **Windows und
Linux** automatisch. Trigger ist ein Tag `v*.*.*` (zusätzlich manuell per
`workflow_dispatch`, dann ohne Release-Anlage).
| Job | `runs-on` | Runner | Bundles |
|---|---|---|---|
| `windows` | `windows` | winbuild, 10.10.2.131 (on-demand) | `msi/*.msi`, `nsis/*-setup.exe` |
| `linux` | `ubuntu-latest` | ci-runner, 192.168.1.72 | `deb/*.deb`, `rpm/*.rpm`, `appimage/*.AppImage` |
Beide Jobs laufen `npm ci` (es gibt eine `package-lock.json`) und danach den
npm-Skript-Umweg `npm run tauri -- build --ci` aus `crates/tauri-app/`, damit
die im Lock gepinnte `@tauri-apps/cli` benutzt wird und nicht die zufällig auf
dem Runner installierte. Die Pfade sind die aus Abschnitt 5. Beide laden ihre
Bundles als Job-Artefakt hoch **und** hängen sie an dasselbe Gitea-Release zum
Tag (anlegen, und falls der andere Job schneller war, das vorhandene per Tag
holen). Die Release-Beschreibung kommt aus dem passenden
`## [<version>]`-Abschnitt einer `CHANGELOG.md`, sobald es eine gibt — bis
dahin steht dort der Commit-SHA.
Zwei Dinge, die der Workflow *nicht* tut:
* **macOS.** Es gibt keinen macOS-Runner. `.dmg`/`.app` werden lokal nach
Abschnitt 4 gebaut und im Gitea-Release von Hand angehängt.
* **Signierte Updater-Artefakte.** Der Workflow baut ohne Release-Overlay und
ohne `TAURI_SIGNING_PRIVATE_KEY*`; es entstehen also keine `.sig`-Dateien
(Abschnitt 2 und 5). Für ein echtes Auto-Update müssen Overlay-Datei und
Secrets ergänzt und der Build-Aufruf um
`--config src-tauri/tauri.release.conf.json` erweitert werden.
**Vor dem Tag zu bumpen** (Schritt 1 der Checkliste): Die Release-Version kommt
aus dem Tag, die Version im *Dateinamen* aus `src-tauri/tauri.conf.json`. Ohne
Bump heißt das Artefakt zu `v0.2.0` weiterhin
`maarcadetweet_0.1.0_x64-setup.exe`. `src-tauri/Cargo.toml` und `package.json`
mitziehen — alle drei stehen aktuell auf `0.1.0`.
> **Falle:** Sobald `.gitea/workflows/` existiert, ignoriert Gitea
> `.github/workflows/` vollständig — kommentarlos, ohne roten Lauf. Im
> Nachbarprojekt `lserver` waren Tests dadurch einen Tag lang still
> abgeschaltet. Dieses Repo hat kein `.github/`, und das soll so bleiben: neue
> Workflows gehören nach `.gitea/workflows/`.
## 9. Offene Punkte
## 8. Offene Punkte
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
Config ist ein Platzhalter und existiert nicht.
* Kein Update-Server und kein Skript, das `latest.json` erzeugt (`scripts/` ist
leer). Der CI-Workflow aus Abschnitt 8 baut und veröffentlicht Installer,
aber keine Updater-Artefakte.
* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt
(`scripts/` ist leer).
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
-186
View File
@@ -1,186 +0,0 @@
-- PDS database schema 0004: invite codes for `com.atproto.server.createAccount`.
--
-- Why
--
-- Until now `createAccount` had no gate of any kind: no invite code, no rate
-- limit, and `describeServer` advertised `invite_code_required: false`. That
-- was survivable while the only reachable instance was `127.0.0.1:2583`. It
-- stops being survivable the moment the PDS answers on a public name, because
-- every accepted account is not just a row in `users` — it creates a `repos`
-- head, a key pair the server has to keep, an MST that grows with every write,
-- and firehose events that every subscribed AppView is obliged to index. A
-- single script could mint accounts until the disk filled up, and nothing in
-- the write path would consider that abnormal.
--
-- This migration adds the smallest gate that actually closes that hole: an
-- account may only be created by presenting a code the operator handed out.
-- The gate is opt-in via `PDS_INVITE_REQUIRED` (default `false`, so the dozens
-- of integration tests that create throwaway accounts keep working); the
-- tables below exist unconditionally so that switching the flag on is a
-- restart, not a migration.
--
-- Two tables, not one
-- -------------------
-- A code can be worth more than one account (`--uses 5` for a group of
-- friends, a conference badge, a family). That means "who redeemed this code"
-- is a *set*, not a single column, so it cannot live on the code row. Putting
-- a `redeemed_by TEXT` column on `invite_codes` would have forced either
-- one-code-one-account (losing the multi-use case the operator actually wants)
-- or an array column that no foreign key, index or `COUNT(*)` can reason
-- about. `invite_code_uses` is that set, one row per redemption.
--
-- The redemption *counter* still lives on the code row even though it is
-- derivable from `COUNT(*)` over `invite_code_uses`. That duplication is
-- deliberate and is the entire concurrency story — see below.
--
-- invite_codes
-- ------------
--
-- code TEXT PRIMARY KEY — the code itself, and the natural key. No
-- surrogate `id`: the code is what the user types, what the
-- operator pastes into a chat window, and what the redeem query
-- looks up, so a second identifier would only add a join.
-- Codes are generated lowercase from a 32-character
-- Crockford-style alphabet (`crates/pds-server/src/invite.rs`),
-- and the server lowercases and trims what the client sends
-- before looking it up. Because every stored code is already
-- lowercase ASCII, that normalisation happens in Rust rather
-- than as `WHERE lower(code) = …`, which would throw away this
-- primary-key index on the hottest lookup this table has.
--
-- created_at when the operator minted it. Purely for the `invite list`
-- output and for answering "where did this wave of signups come
-- from" after the fact.
--
-- note free-text label the operator can attach at creation time
-- (`--note "meetup 2026-09"`). Nullable, never interpreted.
-- It exists because a bare list of random strings is unusable
-- a month later.
--
-- max_uses how many accounts this code may create. `CHECK (max_uses > 0)`
-- because a zero-use code is not a thing you would ever mean to
-- create — it is a typo that would silently hand out a code that
-- can never work.
--
-- used_count how many it has already created. Kept in sync with
-- `invite_code_uses` inside the same transaction that writes
-- both.
--
-- disabled a code the operator wants to stop honouring *without* losing
-- the audit trail. Deleting the row would work for the future
-- but would take the `invite_code_uses` rows with it (see the
-- FK below) and with them the record of which accounts came
-- from that code — which is the one question you ask when a
-- code leaks. A boolean keeps the history and is checked in the
-- same `WHERE` clause as the counter, so disabling costs nothing
-- at redeem time.
--
-- CHECK (used_count <= max_uses) — the belt to the redeem query's braces.
-- The application never over-redeems (the conditional UPDATE
-- below makes that impossible), but this constraint means that
-- *no* future query — a hand-written `UPDATE` during an
-- incident, a bug in a later refactor — can hand out more
-- accounts than the operator authorised. The database refuses.
--
-- How the redeem race is closed
-- -----------------------------
-- The obvious implementation is "SELECT the code, check `used_count <
-- max_uses` in Rust, then UPDATE". That is a check-then-act, and two
-- registrations arriving at the same instant with the same last remaining use
-- both read `used_count = 0`, both decide they are allowed, and both write
-- `used_count = 1` — two accounts from a one-use code, with the row still
-- claiming a single redemption.
--
-- So the check and the act are one statement, and the database performs both:
--
-- 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;
--
-- Under Postgres's READ COMMITTED isolation the second transaction to reach
-- this row blocks on the row lock the first one took. When the first commits,
-- the second does not proceed with its stale snapshot: it re-reads the updated
-- row and re-evaluates the `WHERE` clause against it (EvalPlanQual). The
-- counter is now `1`, `used_count < max_uses` is false, the row no longer
-- matches, and the statement returns zero rows. Zero rows returned *is* the
-- rejection — the route turns it into `400 InvalidInviteCode` without ever
-- having formed an opinion of its own about whether the code was still valid.
--
-- If the first transaction instead rolls back — the handle turned out to be
-- taken, key generation failed, anything — the lock is released with the
-- counter back at `0` and the waiting transaction's re-check succeeds. The
-- code is only consumed by a registration that actually completed, which is
-- why the redemption is issued inside `create_account`'s existing
-- transaction rather than before it.
--
-- This is also why `used_count` is stored rather than computed. A
-- `COUNT(*) FROM invite_code_uses` has no row to lock — concurrent counters
-- both see the same pre-insert count and both pass. The counter column gives
-- the conditional UPDATE a single row to serialise on.
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT invite_codes_max_uses_positive CHECK (max_uses > 0),
CONSTRAINT invite_codes_used_count_sane CHECK (used_count >= 0 AND used_count <= max_uses)
);
-- `invite list` shows the newest codes first, and that is the only listing
-- this table has. Small table, but the operator runs it interactively and an
-- ordered index keeps the output instant even after a few thousand codes.
CREATE INDEX IF NOT EXISTS invite_codes_created_at_idx
ON invite_codes (created_at DESC);
-- =====================================================
-- invite_code_uses — which account came from which code
-- =====================================================
--
-- code FK to `invite_codes(code)` ON DELETE CASCADE. Cascading is the
-- right call *here* (unlike `firehose_events.did` in 0003, which
-- deliberately has no FK) because these rows are meaningless
-- without the code they describe: they exist to answer "which
-- accounts did code X create", and a use-row whose code has been
-- deleted answers nothing. The operator who wants to stop a code
-- but keep the trail sets `disabled` instead of deleting — which
-- is precisely why that column exists.
--
-- did the account that was created. Intentionally NOT a foreign key
-- to `users(did)`: this is an audit record of something that
-- happened, and it has to survive the account being deleted. If
-- it cascaded from `users`, deleting a spam account would erase
-- the evidence linking it to the code that let it in — the exact
-- moment the link matters most. The trade-off is that a `did`
-- here may point at a user that no longer exists; that is
-- accepted and is what an audit log looks like.
--
-- handle the handle as it was at creation time, denormalised on
-- purpose. Handles can change, and `users` may be gone entirely
-- (see above); this column is a snapshot so the listing stays
-- readable without a join that may find nothing.
--
-- used_at when the redemption happened.
--
-- PRIMARY KEY (code, did) — one account can only consume a given code once.
-- This is not the mechanism that enforces the use limit (the
-- conditional UPDATE is), it is a guard against a redemption
-- being recorded twice for one account, which would make
-- `used_count` and this table disagree.
CREATE TABLE IF NOT EXISTS invite_code_uses (
code TEXT NOT NULL REFERENCES invite_codes(code) ON DELETE CASCADE,
did TEXT NOT NULL,
handle TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (code, did)
);
-- The reverse lookup: "which code let this account in?". Asked per-account
-- during abuse triage, so it needs its own index — the (code, did) primary
-- key cannot serve a query whose only predicate is `did`.
CREATE INDEX IF NOT EXISTS invite_code_uses_did_idx
ON invite_code_uses (did);