Commit Graph
7 Commits
Author SHA1 Message Date
tomdeboneandClaude Opus 5 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
2026-09-10 21:06:45 +02:00
tomdeboneandClaude Opus 5 0646fbeebe feat(pds): com.atproto.sync.subscribeRepos — lokaler Firehose
Bisher erreichten eigene Records die AppView nur über den Best-Effort-Push
/internal/ingest-commit. Ging der verloren (AppView kurz weg, Netzwerk-
fehler), war der Post dauerhaft weg: der öffentliche Jetstream kennt diese
PDS nicht, es gab also keinen zweiten Weg.

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

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

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

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

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

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

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

Dazu .env.example am Code verifiziert:

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:35:53 +02:00
tomdebone 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.
2026-07-18 17:56:28 +02:00
tomdebone a60b612f68 fix(pds): 413 body shape + register app.bsky.feed.like/repost
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.
2026-07-06 21:34:10 +02:00
tomdebone c586fd39c9 maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
2026-07-05 20:01:31 +02:00