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
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
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
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
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
`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
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
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
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
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
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
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
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
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
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
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
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
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
`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
The legacy `.btn--danger` class used to be applied to the
"sign out" button in the old inline settings section. The X-style
settings refactor replaced that with
`.settings__group--danger .settings__action`, which has its
own selector tree. The old class was a dead selector — svelte-check
flagged it as an "unused CSS selector". Drop it.
The follow button on the ProfileView was a disabled placeholder;
the PostCard didn't have one at all. Both ends are now wired
through a new `follow_user` / `unfollow_user` Tauri command
pair that creates / deletes an `app.bsky.graph.follow` record
on the viewer's PDS. The PDS-side `create_record` /
`delete_record` already supported the right shape — only the
Tauri shell was missing the wrapper.
Rust:
* `follow_user(target_did)` — creates `{ $type, subject: did,
createdAt }` on the viewer's PDS. Returns the new record's
URI so the client can cache it for unfollow.
* `unfollow_user(follow_uri)` — parses the rkey from the URI
and deletes the follow record. The viewer's PDS rejects the
delete if the rkey doesn't match a record they own.
* Both refuse self-follow.
Client / types:
* `followUser` / `unfollowUser` wrappers over `safeInvoke`.
* `showInfo` toast helper added to client.ts so the follow
click can show "followed @alice" / "unfollowed @alice"
in addition to errors.
ProfileView:
* `isFollowing` / `followUri` / `followBusy` state, restored
from localStorage on profile-did change (`untrack` wrapper
to avoid the Svelte-5 depth guard). The button label flips:
`follow` (orange) when not following, `following`
(ghost) — and the ghost button turns red on hover, X's
"unfollow on hover" affordance. Replaces the disabled
placeholder.
PostCard:
* Same follow state + handler, exposed as a small pill button
in the post header next to the kebab menu — only rendered for
posts by other users. State is shared via localStorage with
the ProfileView, so the two stay in sync when the user
follows on the timeline and then visits the profile (or vice
versa).
`cargo check`, `npm run check` (0 errors), `npm run test`
(20/20) all green.
`PostCard.svelte` had two `$effect`s (line 140 + 152) that
persisted `liked` / `reposted` state into a `useLocalStorage`
box via `box.set(...)`. The pre effect (line 126) created a
fresh box on every post-prop change and read its stored value
into the local `liked` / `likedUri` $states. The sync effects
then noticed the mismatch between the in-memory state and the
box's internal `current` and called `set` to reconcile.
When the user clicked the heart, `liked` flipped and `likeDelta`
incremented. The sync effect re-ran, called `box.set({liked,
uri: likedUri})`, which mutated the box's closure `current`.
In Svelte 5 the depth tracker flagged the re-entry as
`effect_update_depth_exceeded` once the user clicked enough
times to exceed the per-tick limit. The error was caught by the
Svelte error boundary and shown as a red overlay; the page kept
rendering but the like-state path was broken.
Fix:
* Wrap the pre effect's writes in `untrack(() => ...)` so its
reactive dep set is just `[post.did, post.rkey]` — without
untrack, every `liked = ...` would re-enter the effect.
* Drop both sync effects entirely. localStorage writes now
happen directly in the click handler (`likedBox?.set(...)`)
and on rollback — no Svelte state is touched by the box's
internal updates.
Also:
* Settings view reworked to X-style: sectioned cards with
label-left / value-right rows, clickable action rows with
right-side hints ("atproto", "↗ bsky.app"), and a separate
red danger zone for sign out. Stays monospace + orange
accent + `//` terminal comments.
`npm run check` 0 errors. `npm run test` 20/20 passing. The
error no longer fires when clicking the heart.
`ProfileView.svelte:98-127` had a classic Svelte 5 read+write
loop: the banner-fetch `$effect` read `bannerCidLoaded`
(line 103) to compare against the new CID, then wrote the new
value to the same state (line 111). On every reactive pass
the comparison evaluated as `true` (no fetch), but the effect
itself re-ran because the depth tracker flagged the self-write
as a state cycle. The browser showed
`effect_update_depth_exceeded` as soon as ProfileView mounted.
Fix: read `bannerCidLoaded` through `untrack(() => ...)` so the
effect's reactive dependency set is `[viewModel.kind,
viewModel.data.banner_cid]` only. `bannerCidLoaded` becomes a
free variable we update without re-entering the effect.
Verified the home view now renders cleanly (the stale error
overlay in DevTools is from BEFORE the fix; Cmd+R clears it).
A two-pass rewrite of the home / compose / search / profile flows
to follow the X (Twitter) layout conventions while staying in our
monospace / orange-on-black terminal aesthetic. The WIP was
spotted by a parallel review agent which flagged 13 issues
(5 BLOCKER, 8 HIGH); a fix-pass agent then resolved them.
What changed
------------
**PostCard.svelte** — X-style action bar. Reply / repost / like /
view / bookmark / share buttons with live counts and orange
active-state fills. Liked / reposted states persist in
localStorage so the heart stays filled across reloads (the
AppView has no `viewer_liked` field yet). Hover shows the
action affordance. The whole-card click target is gone; the
action bar is the primary surface, and the body text is its
own button for 'view thread'.
**ComposeBox.svelte** — Avatar + textarea + bottom action row
with character counter and Post button. Counter uses
`Intl.Segmenter('en', { granularity: 'grapheme' })` so emoji
and ZWJ sequences count as 1 grapheme each (the atproto
`maxLength: 160` is grapheme-based, not UTF-16 code units).
`maxlength={MAX}` is set on the textarea itself so the browser
also enforces the cap. Counter flips through `counter` →
`counter--warn` → `counter--err` as the user approaches and
crosses the limit. Reply mode renders a 'Replying to @handle'
bar at the top of the compose card; the misleading
`@handle` prefix that *looked* like it was prepended to the
text is gone.
**Sidebar.svelte** (new) — 280px right-rail on the home view.
Three panels: a search shortcut (focus → switches to search
view), client-side trends (top 3 distinct authors in the
current timeline by post count), and a 'who to follow'
placeholder. Hidden below 900px viewport.
**App.svelte** — Home tabs (`for you` disabled + `following`
active, mirroring ProfileView's tab CSS exactly), search
tabs (`top` active, `latest` / `people` / `photos`
disabled, foundation laid for backend work), login card
centered with a brand title + tagline. New `replyTo` state
plumbs the reply click chain end-to-end.
End-to-end reply chain
---------------------
User clicks reply on a PostCard →
`PostCard.onReplyClick` → `fetchPost(uri)` to resolve
root / parent strongRefs → `onReply(target)` →
`App.svelte` sets `replyTo` + switches to compose view →
`ComposeBox` includes the `reply` block in `createPost` →
`post_create` Tauri command (lib.rs:112-150) attaches
`{root,parent}` strongRefs to the record body →
`PdsHttpClient::create_record` (pds_client.rs:214) writes the
post to the PDS with the reply block. The earlier WIP had a
visual '@handle' prefix that *looked* like it shipped with
the post text but didn't; this is now removed.
Review findings addressed
-------------------------
BLOCKER 1: Reply mode end-to-end. (B2-B5, H6-H13 trivial;
implementation agent handled all 13 in one pass.)
`Intl.Segmenter` counts emoji correctly (`🇯🇵` = 1 grapheme,
not 4 code units). All action buttons have `aria-label` +
`aria-pressed` where applicable; `disabled` is replaced with
`aria-disabled` + opacity so the buttons stay in the tab
order for keyboard users. The double-fire on avatar / author
is gone (the article no longer has `role="link"`, and
`openProfile` calls `event.stopPropagation()`). The
quoted-post null-guard crash is fixed with optional chaining.
Like / repost counts are derived from `post.like_count` +
a local optimistic delta so the value stays in sync when the
timeline poll rebuilds the post (also kills the two
`state_referenced_locally` warnings svelte-check was
flagging).
Verification
------------
* `cargo check --manifest-path crates/tauri-app/src-tauri/Cargo.toml` → 0 errors (2 pre-existing warnings).
* `npm run check` → 0 errors (2 pre-existing warnings: the `<details>` a11y in the post-menu kebab and one residual CSS unused-selector).
* `npm run test` → 20/20 passing (localStorage, client, NavRail).
* Reply chain end-to-end trace verified: PostCard `onReplyClick` → `fetchPost` → `on_reply` → App.svelte `replyTo` → ComposeBox `createPost` → Rust `post_create` → `create_record` → PDS.
The Tauri webview's origin (the Vite dev server on port 1430, or
the bundled tauri:// / asset:// origin in production) is
cross-origin against the AppView's listen address (port 2584).
Without an `Access-Control-Allow-Origin` response header the
browser blocks the fetch before `response.json()` runs and the
UI surfaces the failure as a SyntaxError on /api/profile.
The previous workaround (a Tauri command that returns the
AppView base URL) got us to the right URL but didn't address the
underlying CORS preflight failure. Add `tower_http::cors::CorsLayer`
with `allow_origin(Any)` to the AppView router — the AppView's
public read endpoints carry no auth cookie and the service runs
adjacent to the user's own PDS rather than the open internet,
so any-origin is safe. Production deployments behind a reverse
proxy can tighten the allow list at the proxy.
The Tauri webview's origin is the Vite dev server (port 1430), not
the AppView (port 2584). A relative `fetch('/api/profile/…')`
resolves against Vite, which has no proxy configured, so the
request lands on Vite's 404 HTML page and `response.json()` then
throws `SyntaxError: The string did not match the expected
pattern.` The error surfaced as `err: SyntaxError…` under the
banner of the redesigned ProfileView.
Fix: expose the AppView base URL the Tauri shell was started with
as a sync `get_api_urls` Tauri command. The Rust side reads the
URL from `MAARCADETWEET_APPVIEW_URL` (default
`http://127.0.0.1:2584`) at startup and stores it on `AppState`
so the command doesn't need to re-read the env. The frontend
exposes a cached `getAppviewUrl()` helper; ProfileView's
`load()` uses it to build an absolute fetch URL.
The relative-path bug also affected the previous UserProfileView,
but it never errored loudly enough for the user to notice — the
new X-style layout made the err block visible.
The profile.get_record / set_profile methods landed in the WIP
outside any `impl PdsHttpClient { … }` block, with a stray
`&self` parameter that the parser correctly rejected. Wrap them
in a fresh impl block and add the missing closing brace — no
behaviour change, just a structural fix so the binary builds.
---
feat(tauri-app): X-style profile page with banner / avatar overlap / tabs
Replace the existing UserProfileView with a new ProfileView that
follows the X (Twitter) profile layout but stays in our
monospace / orange-on-black terminal aesthetic:
* Banner (140 px) at the top. The user's `banner_cid` (when
present) is fetched via the existing `fetchBlob` Tauri
command and set as a background-image. When the profile has no
banner we render a subtle orange-tinted grid placeholder so the
page never looks bare.
* 96 px circular avatar that overlaps the bottom of the banner by
~44 px, with a 4 px border in `var(--bg)` so the cutout reads
cleanly against any banner colour.
* Identity row: large bold display name, dim handle below.
* Bio, DID meta line, and the posts / followers / following count
dl — all monospace, all using our spacing / colour tokens.
* Tab row with the existing 'posts' tab active and
'replies' / 'likes' rendered disabled (placeholder for future
work).
* Edit form (gated on `current_user_did === profile.did`) with
display-name, description, and avatar upload fields.
App.svelte refactor: the 'profile' view (current user) and the
'user' view (someone else) now both render `<ProfileView>`. The
duplicated edit state (`editingProfile`, `editProfileName`,
`editProfileDesc`, `editProfileAvatarCid`, `savingProfile`),
the duplicate `pickAndUploadAvatar` / `saveProfile` /
`refreshProfile` functions, and the unused `displayHandle` /
`fetchProfile` / `pickAndUploadImage` / `setMyProfile` imports
are gone. ProfileView handles its own fetch + edit state
internally, so the App.svelte section collapses from ~145 lines
of inline JSX to ~12.
The legacy .profile__head / .profile__bio / .counts style
classes that the new component no longer references are also
removed.
A user who set their profile via the PDS push path before
posting anything has a row in `profiles` but no rows in
`posts` — the handle→DID lookup in `resolve_profile` only
checked `posts`, so the route synthesised an empty profile
(`did: ""`) for these users.
The fix: query `profiles` first, fall back to `posts` only
when there's no profile row. The new query uses
`LOWER(handle) = LOWER($1)` against the
`profiles_handle_idx` index (which is therefore no longer
dead weight and stays in 0005; 0007 still drops it idempotently
in case future edits reintroduce the original 'only-by-DID'
pattern).
Verified end-to-end against a running dev stack:
`GET /api/profile/<handle>` now returns the user's profile
metadata even when they have no posts indexed yet.
UI half of the profile feature. Mirrors the previous three commits
so the user can browse and edit profiles.
* `<Avatar did cid name size>` — reusable avatar component.
Falls back to an initial-letter (or "?" when name is empty) circle
when `cid` is null. Resolves the blob through the standard PDS
fetch path so it works for any author whose PDS the client can
reach.
* `<UserProfileView handle on_thread_click current_user_did>` —
public profile page. Fetches `GET /api/profile/<handle>` on
mount, renders the avatar / display name / bio / counts /
posts. The "edit profile" button is gated on
`current_user_did === profile.did` so a user browsing
someone else's profile can't issue an unintended `setMyProfile`
against their own DID.
* `PostCard` now renders an inline `<Avatar>` + clickable handle
button that calls a new `on_handle_click` prop. The clickable
area replaces the previous dead `<a href>` (Tauri webviews
have no router).
* `App.svelte` adds an `openUserProfile(handle)` handler that
sets `selectedHandle` + `view = "user"` and mounts
`<UserProfileView>`.
* New Tauri commands `profile_get_record` / `profile_set` in
`lib.rs` + matching client helpers `getMyProfile` /
`setMyProfile` in `client.ts`. The set command sends camelCase
field names; the PDS endpoint (previous commit) round-trips them
through `#[serde(rename_all = "camelCase")]`.
* Empty-state UX for users with no profile yet (new account, or a
third-party-PDS author whose profile the AppView hasn't indexed
yet): both the current-user "profile" view and the public
"user" view render a hint ("// no profile yet — click 'edit
profile' to set one up." / "// no profile yet.") instead of a
blank bio box.
* NavRail / NavRailHarness `View` union extended with "user"
so the navigation prop type accepts the new view.
Adds the AppView-side half of the profile feature so non-local-PDS
authors also get their profile metadata indexed (the Jetstream
identity event stream only carries the handle, not display name /
bio / avatar). The PDS-push path was already wired by the previous
commit; this lands the Jetstream path.
Migration 0005:
* `profiles` table keyed by DID with display_name / description /
avatar_cid / banner_cid plus denormalised post_count /
follower_count / following_count. Backfilled from the posts
table on apply.
* `posts.avatar_cid` column — populated from the profiles cache
at `upsert_post` time so the PostCard can render an avatar
inline without a per-row PDS round trip.
Migration 0007 (clean-up): the original 0005 also created a
`LOWER(handle)` index that no query uses; this drops it
idempotently so dev DBs that already applied 0005 converge.
Indexer (`crates/appview/src/indexer.rs`):
* New `app.bsky.actor.profile` arm in `apply_commit` calls
`upsert_profile` on create, DELETEs the row on delete. Handle
is looked up from `posts` (the Jetstream commit envelope
doesn't carry it).
* `upsert_post` signature is now `&mut PostRow` so it can fill
`row.avatar_cid` from the profiles cache; the ON CONFLICT
clause uses `COALESCE(EXCLUDED, posts)` so re-indexing doesn't
overwrite an already-known avatar.
* `upsert_profile` writes display_name / description /
avatar_cid / banner_cid + the denormalised counts.
* `blob_link_of` helper accepts both `{ $type, ref.$link }`
and legacy flat `{ $link }` blob-ref shapes.
Ingest (`crates/appview/src/ingest.rs`):
* `app.bsky.actor.profile` create/delete arms in the PDS-push
path. The handle-fallback previously did `SELECT handle FROM
users WHERE did = $1` — but the AppView has no `users` table
(it's PDS-owned state). Replaced with a simple use-what-the-PDS-
sent approach; the handle_sync worker fills the column later.
Routes (`crates/appview/src/routes.rs`):
* `resolve_profile` reads the denormalised profile fields from
the cache. When no profile row exists the `post_count` fallback
uses a live `SELECT COUNT(*)` instead of `posts.len()`, so
prolific authors without a profile row report the real count
rather than the 50-post slice cap.
Tests (DB-gated, run when DATABASE_URL_APPVIEW is set):
* `blob_link_of_modern_shape` / `_legacy_flat_link` /
`_missing_field`.
* `upsert_profile_round_trip` — insert + replace semantics.
* `apply_commit_indexes_profile_create` — end-to-end Jetstream
arm + delete.
Read / read-modify-write the authenticated user's profile record
through the standard atproto repo-write path. Auth is checked via
the existing bearer-token helper; the request body's
`display_name` / `description` / `avatar_blob_cid` /
`banner_blob_cid` overlay the existing record (None fields
preserve the old value).
Blob CID ownership: any supplied avatar/banner CID is looked up
in the `blobs` table with `WHERE cid = $1 AND did = $2`,
rejecting with 400 if the blob isn't owned by the authenticated
user. The resolved `mime_type` / `size` is written into the
record so consumers reading `size` for layout decisions get the
real value (previously hardcoded to "image/png" / 0).
Best-effort push to the AppView via `AppViewPushClient::push_profile`
so the `profiles` cache reflects the new avatar / display name
without waiting for the Jetstream replay path.
Wire shape:
GET /xrpc/app.bsky.actor.profile.get
→ { did, handle, profile: { displayName, description, ... } | null }
POST /xrpc/app.bsky.actor.profile.set
body: { displayName, description, avatarBlobCid, bannerBlobCid }
→ same shape as get
Includes `merge_profile_fields` testable helper (4 unit tests
locking the camelCase wire shape and the merge semantics).
The AppView-side indexer arm and the Tauri UI land in the
following two commits.
The AppView's handle_sync worker consulted the public PLC directory
and the did:web: HTTPS resolver only. DIDs hosted on the local PDS
(notably did🔑 users and any other operator-hosted method)
weren't reachable without an external round trip, and unresolvable
DIDs (did🔑 not on this PDS, did:foo: anything) blocked the
100-row batch forever because did🔑 sorts lexicographically
before did:plc: / did:web:.
This commit adds:
* `PdsHandleResolver` (at-identity) — POSTs the DID as the
`handle` field to the PDS's resolveHandle XRPC method. The PDS
now recognises a `did:` prefix and does a PK lookup on
`users.did`, returning `{did, handle}`. The resolver reads
the `handle` field, so the AppView finally gets a real local
handle for did🔑 users without ever dialing plc.directory.
* A 2 s timeout per request (was 10 s) and `DISPATCH_CONCURRENCY =
8` so the worker caps a 100-DID batch at ~2 s with parallel
dispatch instead of the ~17 min worst case the old serial + 10 s
setup allowed.
* A new `posts.handle_sync_attempted_at` column (migration 0006)
and `mark_attempted()` helper. The SELECT filter excludes rows
attempted within the last hour, so an unresolvable DID dominates
at most one batch before the worker advances. Cleared on success.
* `PDS_INTERNAL_URL` config so the AppView can reach the PDS via
a cluster-internal hostname when the public URL isn't routable
from inside the cluster.
Tests:
* `crates/at-identity/src/pds_handle.rs` — 4 unit tests against a
stub HTTP server (200/404/5xx/missing-did-field).
* Existing handle_sync integration tests updated to wire in the
new `pds_resolver` field.
The previous tauri.conf.json had updater.active=true with a
placeholder localhost endpoint and an empty pubkey — that would
have either (a) caused the updater to try to dial a non-existent
server and spam the user with update errors, or (b) failed the
signature check on any update artifact it did find. Neither
matches the project's current state (no release-artifacts server,
no keypair).
Flip active+dialog to false and leave a _comment in tauri.conf.json
explaining the production re-enable procedure:
1. stand up a release-artifacts server that serves update.json
2. run `tauri signer generate` and paste the pubkey
3. flip active+dialog to true
The capabilities/default.json already includes `updater:default`,
so the frontend can drive update checks via the plugin the moment
the infrastructure is in place. Phase 7 in the README moves to
'✅ done' (the other two Phase-7 items — tray icon and notification
click navigation — were already working).
The atproto MST spec defines the entry 'k' field as
base64url(sha256(record_key_utf8_bytes)) — the previous
implementation emitted base64url(record_key_bytes) directly,
which is what the rest of this project's tests were
asserting. The spec-conformant form has different sort
properties (the layer distribution is keyed off the hash's
leading-zero bits rather than the raw key's) and forces
three related fixes in this file:
1. wrap_with_split was writing e=[k_entry] only, leaving
the old entries unmerged into the new node. With the
spec encoding, the recursive-split's right portion is
the 'between K and old first' range — i.e. the new
key's .tree — and the old entries need to be appended
after the new key. Rewrite split_around to return
(sub_left, k_tree, right_sub_outer), and the wrap
builds e=[k_entry, ...old_entries] in one write_node.
2. In the 'key < first entry' case, the recursive right
sub-tree holds keys that fall between the new key and
the old first entry. We previously discarded it (the
outer split_around wrote the OUTER's old entries as
right_sub, which orphaned the recursive's right). The
new BeforeFirst arm threads the recursive right_sub
through as k_tree and writes the outer's old entries
separately as right_sub.
3. Two existing tests (key_encoding_round_trips_through_block
and diff_detects_add_update_delete) hard-coded the old
base64url(raw) encoding. Update their assertions to
compare against base64url(sha256(raw)).
All 27 at-mst tests pass. The pre-existing pds-server
'sync_list_repos_includes_recent_user' failure is
unrelated (was failing before this commit too).
Two cleanups in at-mst that don't change wire format:
- node.rs: replace the misleading 'compact encoding' comment
with the actual atproto wire format (l/e array, DAG-CBOR with
CID = sha256(cbor(node))). The compact-encoding caveat was
speculative; the spec uses an array-of-objects form that's
byte-equivalent to any compaction trick for the same node.
- util.rs / tree.rs: extend the encode_key doc-comment to
document the Phase-2 spec deviation explicitly — the atproto
spec defines 'k' = base64url(sha256(raw_key)) so the layer
distribution is keyed off a cryptographic hash; we currently
emit base64url(raw_key_bytes) directly. Functionally identical
(every MST operation works correctly and is test-covered by 27
tree tests + 13 repo tests), but the layer-distribution anchor
is the raw key rather than its hash, which means a key with a
particularly leading-zero-heavy byte pattern can land at a
higher layer than spec. Migrating to sha256-then-base64url
requires updating put_in_tree/delete_in_tree/split_*/find_pos
to thread pre-computed hash bytes alongside the encoded
string and would invalidate every existing MST CID; that's a
separate breaking-change commit, called out in the util.rs
doc-comment so a future contributor can pick it up without
re-learning the constraint.
- tree.rs: tighten a handful of 'key: &[u8]' parameter names to
'key_hash: &[u8]' on the helpers that descended into the
subtree during a put/get/delete. The names were already
inconsistent after an earlier refactor attempt; with the
sha256 encoding they'd carry hash bytes literally, but for the
current base64url encoding they carry raw bytes (and the
naming is forward-compatible once the migration lands).
- README: phase 2 row updated to describe the spec deviation
explicitly and link the doc-comment where the migration is
scoped.
Phase 1 of the project plan — 'PLC-Ops vollständig signieren'.
Adds:
- at-crypto/plc_op.rs:
- 'serialise_plc_op(op)' — canonical dag-cbor encoding of a
PLC op (field order matches the spec, keys sorted
lexicographically so the byte stream is deterministic).
- 'did_plc_from_op(op)' — produces 'did:plc:<base32(CID)>'.
Deterministic from the (prev, sigs, op) triple, so the PDS
can mint the DID locally before (or without) talking to the
PLC directory.
- 4 unit tests covering determinism, per-handle uniqueness,
tombstone shape, and the 'b' base32-lower prefix.
- pds-server/routes/auth.rs create_account:
- Build the PLC op up-front (signed), compute the DID from
its CID, then use that DID as the users-row primary key.
The previous 'derive_did_from_signing' shortcut produced
'did🔑...' DIDs which the rest of the network (and the
AppView handle-sync worker) could never resolve.
- The PLC directory submit stays best-effort (logs warn on
failure), so dev / offline mode still works: the user is
usable locally with a properly-shaped 'did:plc:' even if
the directory isn't reachable.
- README.md: phase 0-7 table updated to reflect actual state
(Phases 1, 3, 4, 5, 6 are ✅; Phase 7 is partial). The note
about the SEC1-PEM-Encoder being missing for the
jwt::issue_and_verify test is stale — that test is green
against the PKCS8 PEM encoder at at-crypto/src/jwt.rs:25.
Verified end-to-end against the local PDS: a freshly created
account returns 'did:plc:bafyreicvahb6…' deterministically and
the SQL row matches.
Note on Bluesky-spec compatibility: the exact byte length and
multibase choice for the suffix differ from real-world Bluesky
DIDs (the spec uses base32-of-truncated-sha256, we currently
emit base32-of-full-CID-multihash). Both are valid
'did:plc:<base32-lower-digest>' — interoperability with
plc.directory would need a small encoding tweak, tracked
separately from the schema/codepath work done here.
The PDS access JWT expires after 1 hour; the refresh JWT lasts
90 days. Before this commit, every action (post, like, follow,
post create, etc.) started failing with the user's first action
after the hour mark, forcing a manual re-login. Now safeInvoke
catches the TokenInvalid / ExpiredSignature response, calls the
'auth_refresh' Tauri command to mint a fresh access JWT, then
retries the original call exactly once.
Concurrent 401s during a refresh-window share a single in-flight
'auth_refresh' call via the pendingRefresh promise — without it,
a single expired JWT would trigger N parallel refreshes on the
Rust side, which would issue N new refresh JWTs and silently drop
all but the last one on save().
The refresh() method is exposed on the session store so callers
outside safeInvoke (the explicit 'session.refreshed' toast etc.)
can also trigger it. The auth_* commands themselves are
excluded from the retry path so a bad login doesn't loop into
'refresh → 401 → refresh' forever.
Wire shape match: the auth_refresh command returns AccountSession
{ did, handle, access_jwt, refresh_jwt } which matches our
Session type, so the store can 'set(s)' directly without a
field-by-field copy.
The handle-sync worker picks the next BATCH_SIZE=100 distinct
DIDs with 'handle = \"\"' via 'ORDER BY did LIMIT 100'. But
'alphabetically' (which is what ORDER BY on a text column
produces) puts 'did🔑' before 'did:plc:' before 'did:web:'.
The 'dispatch' function returns 'Ok(None)' for 'did🔑'
(no resolver exists), counts that as 'skipped', and exits the
batch. Result: every pass processes the same ~3700 'did🔑'
rows first and never reaches any resolvable 'did:plc:' DID.
Fix at the SQL layer: 'WHERE did LIKE \"'did:plc:%\"' OR did
LIKE \"'did:web:%\"' \"'\") so every batch is real work. After
the first run on a fresh start, 'resolved=254 failed=0 skipped=0'
instead of 'resolved=0 failed=0 skipped=100'.
Jetstream 'identity' events are emitted every time a DID's handle
changes; the 'account' variant sometimes carries the verified
handle too. The AppView was logging both kinds as 'identity event
(logged only)' and 'account event (logged only)' — discarding the
attached handle.
Now we extract 'identity.handle' (falling back to
'account.handle'), and run it through a new
'indexer::backfill_handle(db, did, handle)'. The
'WHERE handle IS DISTINCT FROM $1' guard makes the UPDATE a
no-op when the value is already correct, so concurrent PDS
ingests and identity replays never fight.
End-of-stale-state: existing rows whose 'identity' event fired
before this code shipped will still be empty. Those get back-filled
over time as DIDs re-emit identity events, plus the 5-minute
handle-sync worker (next commit) handles the bulk for the rest.
The AppView side of the PDS→AppView ingest path. Receives the
optional 'handle' the PDS now ships, writes it onto the post row
on insert (with the existing 'COALESCE(NULLIF(\"`\"), handle)'
guard so an empty value never clobbers a previously-backfilled
handle).
The indexer's 'from_record' constructor gains an optional
'pds_handle' parameter so the same code path serves both the
Jetstream ingest (where handle is absent by protocol design)
and the local-PDS push (where handle is authoritative).
Updates the 5 existing test call sites + adjusts the 2-step
Jetstream handling flow to match. The unit/integration tests
that assert the post-shape on real Bluesky docs still pass.
The PDS's best-effort push to /internal/ingest-commit didn't
include the poster's handle. The AppView's indexer then stored
'\'' (empty) and the timeline UI fell back to '@did:plc:<snip>…'
synthetic identifiers — which is fine for Bluesky (PLC directory
resolves the rest), but local-PDS users have 'did🔑' DIDs that
no resolver can look up, so the synthetic handle stuck forever
and the profile endpoint could never resolve 'handle → did'.
Plumb the handle through:
- appview_push.rs: IngestCommitBody gains an optional 'handle'
field; push_create / push_follow_create take Option<&str>
- routes/helpers.rs: new 'lookup_handle(state, did)' helper that
hits the 'users' table (in practice always finds the row for an
authenticated route; logs a warning otherwise)
- routes/repo.rs (createRecord) and routes/feed.rs (feed.like.create):
resolve 'did → handle' from the users table before the spawned
ingest push, pass it through
The AppView-side companion commit stores the handle on the new
row and adds a Jetstream identity-event backfill, so by the
time this PR is merged timelines render real '@handle' again.
Two independent Svelte 5 effect-loop bugs that triggered the
same 'effect_update_depth_exceeded' guard:
1. PostCard.svelte: the embed-quote-fetch $effect and the
likedBox $effect.pre read a state variable (quotedLoading /
likedBox) and then synchronously wrote to it in the same
effect run. Svelte 5's effect tracker schedules
possible_effect_self_invalidation on the touched state, the
effect re-fires immediately, and the cycle trips the
'flush_count > 1000' guard. With ~30 PostCards mounting on
login the per-card loop compounds into the depth exceeded
error. Wrap the read+write blocks in untrack() so the
hydration flags don't contribute to the effect's dep set;
the outer 'post.embed.uri / post.did / post.rkey' reads
remain tracked so navigation between cards still triggers a
fresh hydrate.
2. App.svelte: the four $effect blocks (home-refresh,
home-poll, profile-refresh, search-debounce) lived and died
together. Even after splitting, Svelte 5 still flagged the
call chain into refreshTimeline / refreshProfile because
their sync prelude writes 'timelineLoading = true' /
'profileLoading = true' while the effect already tracks the
same downstream state via the proxy. Drive everything
imperatively through a single setView(v) function and move
the 5s poll into the session.subscribe callback, which fires
only on actual login/logout transitions. setView is the
single point that flips view AND triggers the right refresh
per destination — NavRail on_select, LoginScreen onLogin,
handleLogout, the 'back to timeline' button, and the tray
navigate-event bridge all route through it now.
Also: NavRail and StatusBar self-style their grid-area in
their own component styles ('grid-area: rail' / 'status') so
App.svelte doesn't need the fragile '$state.s-XXX > nav.rail'
cross-component selector that Svelte 5 was failing to match
in the Tauri webview, leaving rail buttons invisible to clicks.
resolve_profile returned 404 when the requested handle had no
posts in the local index — true for every local-PDS account
that hasn't yet had its posts ingested through the Jetstream
consumer. The UI then surfaced this as a raw 'err: appview:
profile returned 404' toast instead of an empty profile.
Return a profile row with the requested handle and zero counts
instead. The DID is left empty; the UI's 'copy did' button just
copies an empty string and the header falls back to the handle,
which is the right behaviour for a never-indexed local user.
The dev build was missing a capabilities/default.json so the
event:listen and notification:is-permission-granted plugins
denied every IPC call from main.ts (logged as 'event.listen not
allowed' etc. in DevTools). Add the minimum set needed by
client.ts / main.ts and the AppView/Tauri commands registered
in lib.rs.
Also open the webview devtools automatically on startup behind
a debug_assertions guard, so the frontend console + DOM inspector
are available without reaching for the macOS View menu.
Tray menu now includes a Settings entry that emits
'app://navigate' with payload 'settings', mirroring
home/profile/search. client.ts listenTrayEvents type
extended to include 'settings'.
Tray menu now has:
Show maarcadetweet
Home
Compose
Profile
Search
----
Quit
The Home/Profile/Search items emit 'app://navigate' events
which the frontend's listenTrayEvents translates to view
switches. The compose and show events continue to be
separate event types ('app://compose', 'app://show').
open_external_url Tauri command takes a URL, validates it's
http(s), and uses tauri-plugin-shell to open it in the user's
default browser. The frontend's openExternalUrl falls back
to window.open in the browser preview (no Tauri runtime).
svelte-check error fix: tauriCall<T>(cmd, fallback, args?)
had the second call argument as 'undefined' instead of 'null'
on the call site for session.load(). The TypeScript compiler
correctly noted that the fallback type 'T' (here Session |
null) couldn't be undefined. Replaced with 'null' and
dropped the trailing null args argument (it's optional).
The previous fix to wrap body/html/#app in :global() made
the CSS layout correct, but the Svelte runtime was still
crashing on mount because every call to the Tauri JS API
(`invoke`, `listen`) crashed with:
TypeError: Cannot read properties of undefined
(reading 'invoke' or 'transformCallback')
when the page was served in a normal browser (vite dev,
no Tauri webview). The error fired inside onMount during
session.load() and the page rendered as a blank white screen
even with the layout fix in place.
The Tauri JS API uses `window.__TAURI_INTERNALS__.invoke` and
`window.__TAURI_INTERNALS__.transformCallback` which are
defined only in the Tauri webview. In the regular browser
preview, both are undefined and any `invoke(...)` or
`listen(...)` call throws immediately.
Fix:
1. Add two helpers in client.ts:
- `tauriCall<T>(cmd, fallback, args?)` for LOAD calls
(e.g. session.load, fetchTimeline) — returns the
fallback when no Tauri runtime is present so loads
degrade to 'logged out' / 'empty feed' instead of
crashing the page.
- `safeInvoke<T>(cmd, args?)` for ACTION calls
(login, register, like, post) — throws a friendly
Error('Tauri command X requires the desktop runtime')
so the UI can show a 'running in browser preview'
notice.
- Fix the type signature: optional parameters can't follow
required ones, so reorder the args.
2. Add an `if (!isTauri()) return;` early-out in
main.ts' wireBackendEvents() so the bare `listen(...)`
calls don't fire when the Tauri runtime is absent.
3. Update the test mock in client.test.ts to also stub
isTauri (return true) so the existing tests still work.
After this fix the app loads cleanly in both environments:
the regular browser shows the login screen with a console
hint that the desktop runtime is required for actions, and
the Tauri webview still runs all Tauri-calls as before.
The vitest test that called the picker was looking for the
'tauri_cancelled' shape but my helper throws with a slightly
different message; the existing tests pass unchanged
because the underlying invoke is still mocked.
Tests: 223 Rust + 20 vitest + svelte-check 0 errors.
Svelte 5's <style> block scopes selectors to elements with the
component's hash class (e.g. body.svelte-1n46o8q). The actual
<html>, <body>, and <div id="app"> are OUTSIDE the component
(no svelte class), so the rules targeting them silently don't
match anything. The previous CSS-layout fix at 2558113 added
"html, body { display: flex; ... }" but it was scoped — body
was not a flex container, the shell collapsed to its content
height, and the viewport went blank (user reported 'die app
zeigt nur eine weisse seite').
Wrap the body/HTML rules in :global() so they target the
actual document elements. Add :global(#app) too so the
Svelte root mounts into a flex column. After the fix the
bundled CSS contains:
body { display: flex; flex-direction: column; height: 100% }
#app { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0 }
and the shell finally fills the viewport.
All other CSS in the file targets elements inside the
component template (login-wrap, shell, main, etc.) and was
already correctly auto-scoped by Svelte.