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
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
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
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
`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
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.
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'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.
Three fixes for the integration test plan:
1. Layer order in tauri.conf.json: `body_limit_fallback` must
wrap `upload_blob_body_limit` so the JSON override is in
effect when the 413 fires. Swapped.
2. Type annotation on the `from_fn` middleware:
`.layer::<_, std::convert::Infallible>(...)`. The function
never errors, so the second type param is Infallible.
3. Register the standard `app.bsky.feed.like` and
`app.bsky.feed.repost` lexicons so the like/repost
endpoints (which create records of those collections) pass
the lex validator. We only ship what the PDS actually lets
users create server-side; anything else passes `validate: false`.
The 'unprocessable entity' style message and 'unknown lexicon'
errors that came up during manual testing are now gone.
Also dropped the stuck migration-2 row from `_sqlx_migrations`
on the dev DB so the new lex schemas apply.
- fetchBlob cache keyed by (did, cid), not just cid.
Security: future per-DID access control on getBlob would
otherwise leak the first responder's bytes to subsequent
viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
get_blob_after_upload_with_different_did ->
get_blob_returns_404_for_cross_did_cid_lookup. The
docstring was misleading — the test only verifies the
(did,cid) PK on the PDS row, not auth. The renamed name
matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
(did, cid) signature.