73959f9dde343934205fc3ee4327dc209c17740c
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
73959f9dde |
feat(tauri-app): Einladungscode im Client, Produktions-URLs als Default
Zwei Dinge, die einen ausgelieferten Client heute unbrauchbar gemacht hätten. Erstens: Seit die öffentliche Instanz Einladungscodes verlangt, bekam jeder Registrierungsversuch aus dem Client 400 InvalidInviteCode, ohne dass es ein Feld für einen Code gegeben hätte. auth_register und create_account nehmen ihn jetzt entgegen, der LoginScreen zeigt das Feld nur im Registrieren-Modus und leert es beim Moduswechsel — sonst würde ein getippter Code im ausgeblendeten Feld überleben und beim nächsten Versuch stillschweigend mitgehen. Ein leeres Feld wird weggelassen, nicht abgelehnt: ob ein Code nötig ist, entscheidet der Server (PDS_INVITE_REQUIRED), und eine lokale Dev-PDS verlangt keinen. "Kein Code angegeben" und "mein Code ist der Leerstring" sind zwei verschiedene Aussagen; nur die erste ist je wahr. Deshalb fliegt der Schlüssel per skip_serializing_if ganz aus dem Body. Der Server unterscheidet bei der Ablehnung bewusst nicht zwischen fehlend, falsch, gesperrt und verbraucht — sonst wären Codes enumerierbar. Die Meldung im Client nennt deshalb beide plausiblen Auswege, statt einen zu raten. Zweitens: Die Basis-URLs fielen ohne Umgebungsvariable auf http://127.0.0.1:2583 bzw. :2584 zurück. Ein gebautes Paket hätte also gegen nichts gesprochen. Beide zeigen jetzt auf https://tweet.maarcade.com — dieselbe Origin für PDS und AppView, der Proxy trennt nach Pfadpräfix. Die Variablen überschreiben weiterhin, damit Entwicklung gegen localhost möglich bleibt; eine leer gesetzte Variable gilt dabei als ungesetzt, weil eine leere Basis-URL sonst als kryptischer Relative-URL-Fehler weit weg von der Ursache auftaucht. Der Settings-View spiegelte dieselben alten Defaults und hätte falsche Backends angezeigt — mitgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX |
||
|
|
f04d63dd7b |
feat(pds): Einladungscodes für createAccount
Die Instanz soll öffentlich erreichbar werden. createAccount hatte bis
jetzt keinerlei Schranke: kein Code, kein Rate-Limit, describeServer
meldete invite_code_required hartkodiert false. Jeder hätte beliebig viele
Konten anlegen können, jedes mit eigenem Repo, Blöcken und
Firehose-Events.
Zwei Tabellen: invite_codes trägt Zähler und Sperre, invite_code_uses
protokolliert, welcher DID welchen Code eingelöst hat — ein Code kann
mehrere Konten wert sein, also ist "wer hat ihn benutzt" eine Menge. Die
DID hat bewusst keinen Fremdschlüssel: das Protokoll soll das Konto
überleben.
Der Kern ist das Einlösen ohne Rennen. Geprüft wird nicht vorher, sondern
im Schreiben selbst:
UPDATE invite_codes SET used_count = used_count + 1
WHERE code = $1 AND NOT disabled AND used_count < max_uses
RETURNING used_count, max_uses
Der Verlierer zweier gleichzeitiger Registrierungen blockiert auf der
Zeilensperre, liest danach die committete Zeile neu, wertet die
WHERE-Klausel erneut aus, trifft nichts und bekommt 400. Ein
Lesen-dann-Schreiben hätte beide durchgelassen — ich habe genau das
probeweise eingebaut, woraufhin die Nebenläufigkeitstests umfielen
("a one-use code let 5 concurrent registrations through").
Das Einlösen ist die erste Anweisung in der bestehenden Transaktion von
create_account: die Zeilensperre hält über die Konto-Inserts, und ein
Rollback gibt den Code wieder frei. Wer ein Handle-Rennen verliert,
verliert nicht auch noch seine Einladung.
Alle Fehlerfälle — fehlend, leer, unbekannt, gesperrt, aufgebraucht —
liefern dieselbe Meldung, damit der unauthentifizierte Endpoint kein
Orakel zum Abtasten des Code-Raums wird.
Codes erzeugt das Binary selbst, statt dafür einen Endpoint zu öffnen:
`pds-server invite create --count 5 --uses 1`, dazu list und disable.
PDS_INVITE_REQUIRED steht auf false per Default — sonst brechen die
Integrationstests und jede Dev-Instanz. Der Server warnt beim Start
deutlich, solange es aus ist, und describeServer meldet jetzt den
tatsächlichen Wert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
|
||
|
|
b58cb75cfe |
fix(at-firehose): Jetstream-Filter wirkte nie — Collections in die URL
Der Consumer verband sich auf die nackte URL und schickte danach
`{"type":"options","wantedCollections":[…]}` als Textframe. Jetstream
ignoriert das, und zwar stillschweigend: Filter sind Query-Parameter, und
der einzige nachrichtenbasierte Weg (`options_update`) verlangt, dass die
Verbindung mit `requireHello=true` geöffnet wurde.
Jede Instanz, die glaubte, sechs Collections zu abonnieren, hat also den
kompletten öffentlichen Firehose gezogen. Gemessen gegen
jetstream1.us-east: 3119 Events in 8 s ungefiltert, 520 für eine einzelne
Collection, 12 für die beiden, die dieses Projekt wirklich braucht.
Konkrete Folgen: die Dev-Datenbank ist unbemerkt auf 3,3 Mio. Posts
gewachsen, und auf der Produktionsinstanz musste die AppView abgeschaltet
und aus dem Autostart genommen werden, weil sie die Platte vollzuschreiben
drohte — dort stand `JETSTREAM_COLLECTIONS=app.twi.post` korrekt in der
.env und wurde einfach nicht beachtet.
Was der Fix NICHT löst, und das steht auch so im Code: die Collections, die
ein Bluesky-artiges Produkt normalerweise will (post/like/repost/follow),
sind ~97 % des Volumens. Richtig zu filtern ist notwendig, nicht
hinreichend.
Nebenbei: upload_blob_rejects_oversized fiel etwa jeden dritten Lauf um. Der
Server bricht die Verbindung ab, sobald das Body-Limit reißt, also sieht der
Client je nach Timing die 413 oder einen Reset beim Schreiben. Beides
beweist, dass der Upload abgelehnt wurde; der Test akzeptiert jetzt beides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
baeb87214b |
chore(app): drop unused .btn--danger CSS
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. |
||
|
|
48ee25f217 |
feat(follow): end-to-end follow / unfollow with localStorage state
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.
|
||
|
|
eb62fd5654 |
fix(postcard): remove sync effects that looped on like/repost click
`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.
|
||
|
|
a98f891e4f |
fix(profile): untrack bannerCidLoaded read in banner-fetch effect
`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). |
||
|
|
4c71b76763 |
feat(tauri-app): X-style redesign — action bar, compose, tabs, sidebar, reply-mode
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.
|
||
|
|
aba84cbaa9 |
fix(appview): add CorsLayer so Tauri webview can hit /api/*
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. |
||
|
|
e6aa28ca4c |
fix(tauri-app): use absolute AppView URL for /api/profile fetch
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.
|
||
|
|
e4bcfbfa83 |
fix(tauri-app): wrap profile methods in PdsHttpClient impl block
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.
|
||
|
|
6ebf17b493 |
fix(appview): resolve_profile also looks up DID in profiles cache
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. |
||
|
|
ffee5c6685 |
feat(tauri-app): profile view, Avatar component, handle navigation
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.
|
||
|
|
59a3cb02dd |
feat(appview): profile cache + Jetstream indexing + denormalised counts
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.
|
||
|
|
3064d3d8b7 |
feat(pds-server): app.bsky.actor.profile get/set XRPC endpoints
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.
|
||
|
|
3aa5d5c0e3 |
feat(at-identity): PdsHandleResolver for cluster-local DID→handle resolution
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. |
||
|
|
391448a845 |
chore(tauri): disable auto-update for dev; document production config
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).
|
||
|
|
caa30fa65e | docs: mark Phase 2 as done (MST key encoding now spec-conformant) | ||
|
|
fd352180a1 |
fix(at-mst): wrap_with_split subsumes old entries + put k_tree on the recursive right
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). |
||
|
|
3302bca494 |
fix(at-mst): Phase 2 spec-compliance docs + cleanup; behavior unchanged
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. |
||
|
|
b8da282525 |
feat(at-crypto, pds-server): deterministic did:plc: from signed op (Phase 1)
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.
|
||
|
|
a5b1c889dc |
fix(tauri-app): auto-refresh access JWT on TokenInvalid responses
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.
|
||
|
|
a226a92c12 |
fix(appview): skip did:key in handle-sync SQL — they blocked progress
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'. |
||
|
|
73e56fd788 |
fix(appview): store handle from Jetstream identity + account events
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. |
||
|
|
78b752c993 |
fix(appview): populate handle from PDS ingest + backfill race-safely
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. |
||
|
|
184e0dfe03 |
fix(pds-server): forward poster handle to AppView on ingest push
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.
|
||
|
|
abea4d2a8a |
fix(tauri-app): kill effect_update_depth_exceeded via untrack + setView
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.
|
||
|
|
d1fff87e34 |
fix(appview): empty profile instead of 404 for unindexed handles
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. |
||
|
|
647c3059b4 |
feat(tauri-app): Tauri 2 capabilities default + open_devtools in debug
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. |
||
|
|
6a82c906de |
tauri-app: tray settings menu item
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'. |