# Tidepool Tidepool is a read-only ActivityPub → atproto bridge for the threadiverse. It follows Lemmy/PieFed/Mbin communities (FEP-1b12 group federation), materializes their posts, comments, and profiles as `social.coves.*` records in a virtual PDS it operates itself, and serves them over `com.atproto.sync.*` so the [Coves](https://github.com/BrettM86/coves) AppView indexes fediverse communities exactly as it indexes native ones. Votes stay bridge-side as aggregates behind one sanctioned XRPC. See **[PLAN.md](PLAN.md)** for the architecture and locked design decisions. ## Quick start ```sh make dev-up # start the dev postgres (localhost:5442) make run # run the bridge (migrations apply on start in dev) make test # start the test postgres (localhost:5443) and run the suite ``` To try DNS locally, pass the DNS variables to `make run` (the dev Compose file has no Tidepool service). DNS listens on UDP and TCP at the same address and answers handle TXT, SOA/NS at each label apex, and A/AAAA at label apexes and handle names. Zone transfers are refused. Use a free port; 5353 is mDNS and usually taken. The dev zone root is `localhost`: ```sh DNS_LISTEN=127.0.0.1:5300 DNS_PUBLIC_IPV4=127.0.0.1 make run dig @127.0.0.1 -p 5300 TXT _atproto...localhost dig +tcp @127.0.0.1 -p 5300 NS .localhost ``` Production does not migrate on server startup. If operating without a registry, build the image from the checked-out revision and run its embedded one-shot migration command before starting the server: ```sh docker build -t tidepool:local . docker run --rm \ -e DATABASE_URL="$DATABASE_URL" \ tidepool:local migrate ``` In the production deployment Compose file, give the migration and server services the same locally-built image, then make the server depend on a successful migration job: ```yaml x-tidepool-image: &tidepool-image build: . image: tidepool:local services: tidepool-migrate: <<: *tidepool-image command: ["migrate"] environment: DATABASE_URL: ${TIDEPOOL_DATABASE_URL} restart: "no" tidepool: <<: *tidepool-image depends_on: tidepool-migrate: condition: service_completed_successfully ``` This repository ships that stack as [docker-compose.prod.yml](docker-compose.prod.yml) (postgres + migrate + server; TLS terminates in the operator's reverse proxy — see the file header). With `.env` filled in from [.env.prod.example](.env.prod.example), the normal deployment command rebuilds the shared local image, runs the migration job, and starts Tidepool only if migration succeeded: ```sh git pull docker compose -f docker-compose.prod.yml up -d --build tidepool ``` The repo-committed deploy runbook is [.claude/commands/deploy.md](.claude/commands/deploy.md). Requires Go 1.26+, Docker, and (for `make db-migrate`) the `goose` CLI. `make lint` and `make fmt` run a pinned golangci-lint through `go run`; it needs Go 1.26, which the go command downloads automatically. Store tests need a real postgres: they skip with a clear message when `TIDEPOOL_TEST_DATABASE_URL` is unset. Builds and tests use `-tags nodynamic` (the Makefile passes it). The tag keeps AVIF decoding inside the WASM sandbox; the server refuses to start if gen2brain/avif loaded a system libavif. The identity-minting tests additionally need a **local** PLC directory (`make plc-up`, port 3002; the first start clones and builds [did-method-plc](https://github.com/did-method-plc/did-method-plc), which takes several minutes). They skip when it is unreachable and under `go test -short`, and hard-refuse to run against any non-loopback directory — the test suite can never create DIDs on the public `plc.directory`. Point `TIDEPOOL_TEST_PLC_URL` elsewhere-on-localhost if your directory is on a different port. ## Running the stack (e2e harness) The end-to-end harness runs the whole read path against **real infrastructure** — a real Lemmy federating with the bridge, a real did:plc directory backing DID minting, a real atproto relay (indigo **BigSky**) crawling the bridge, a real reference PDS hosting a native account, and a real Jetstream decoding the **relay's** firehose — in one compose network: ``` docker-compose.e2e.yml ┌───────────────────────────────────────────────────────────────────────┐ │ http://lemmy (debug build) http://tidepool (this repo) │ │ ┌───────────────────────┐ Follow ◀─ ┌───────────────────────────┐ │ │ │ lemmy + lemmy-postgres│ Accept ─▶ │ tidepool + its postgres │ │ │ │ + pictrs │ Announce ─▶│ inbox → queue → material │ │ │ └───────────────────────┘ (plain │ izer → virtual PDS │ │ │ HTTP) └─────┬──────────────┬──────┘ │ │ mint DIDs │ CBOR frames│ ▲ │ │ ┌─────────────▼──┐ │ │request │ │ │ plc (did:plc │◀────┐ │ │Crawl │ │ │ + plc-postgres)│ │ ┌──▼─┴───────┐ │ │ └──┬─────────────┘ DID │ │ relay │ │ │ ▲ resolution └──│ (BigSky) │ │ │ │ │ + postgres │ │ │ ┌──┴─────────────┐ │ + the │ │ │ │ pds (reference │ CBOR │ reference │ │ │ │ @atproto/pds) ├───────▶│ pds, in │ │ │ └────────────────┘ frames │ this netns │ │ │ └──────┬─────┘ │ │ CBOR frames│ │ │ ┌───────▼─────┐ │ │ │ jetstream │ │ │ │ (JSON) │ │ │ └───────┬─────┘ │ └───────────────────────────────────────────────────────────────┼───────┘ host (127.0.0.1 only): tidepool :8092, DNS :5380 UDP/TCP, │ lemmy :8541, relay :2480, pds :3081, │ jetstream :6028 ◀───────────────────────────────────┘ tests/e2e (go test -tags e2e,nodynamic) ``` Every event the suite consumes has therefore survived the strictest consumer that exists: the relay resolves each repo's DID against the local PLC, verifies every commit signature, and crawls repos over the bridge's `getRepo`. The bridge announces itself to the relay on startup via the **real** `RequestCrawlAll` code path (`RELAY_HOSTS=http://relay:2470` plus the dev-only `ALLOW_DEV_REQUEST_CRAWL=1` — production always sends and refuses the flag), retrying internally because the relay validates the hostname by calling back into the bridge's `describeServer`. A fresh relay's new-PDS-per-day limit is 0 (refuses all non-admin `requestCrawl`), so a one-shot `relay-bootstrap` service raises the limit over the admin API before the bridge starts — the announcement itself is still bridge-originated, and the suite asserts it landed (`relay_test.go`). Bridged handles verify for real too: `HANDLE_RESOLVER_HOSTS=tidepool,localhost:3001` points bigsky's trial-host resolver at the bridge's Host-header-keyed `/.well-known/atproto-did`, and at the reference PDS's (below) for its own handles (compose-DNS-invisible names like `alice.lemmy.tidepool` would otherwise fail handle verification, which bigsky treats as non-fatal). For debugging, a direct bridge→Jetstream tap exists behind the `direct` compose profile (`jetstream-direct`, host port 6038) — it is not the tested path. The relay also crawls a **reference PDS** — `ghcr.io/bluesky-social/pds`, pinned by digest — hosting a native account (`native-alice.pds.test`) whose DID is minted in the same local PLC. It is there because every other repo on this firehose is the bridge's: our commits, read back by our own understanding of the spec at both ends, so a misreading shared by the writer and the reader is invisible and the suite goes green on the agreement. A repo host we did not write cannot share it. The `pds` container runs in the **relay's network namespace** (`network_mode: "service:relay"`), which is not a convenience: `@atproto/pds` derives its public URL from `PDS_HOSTNAME`, special-casing `localhost` to `http://localhost:$PDS_PORT` and turning every other hostname into `https://`, which this plain-HTTP harness cannot serve — and bigsky peers a PDS by the host in its DID *documents*, calling `describeServer` at that url from inside its own namespace. Sharing the namespace is what makes `http://localhost:3001` true for both. Consequences: the PDS's host port is published on the `relay` service (a container-network-mode service cannot publish its own), and it is 3081 rather than 3001 because the Coves dev stack already owns 3001. A one-shot `pds-bootstrap` creates the account over the plain public `createAccount` (no admin API — the PDS runs with invites off), treats an already-taken handle as success, gates on `createSession` returning 200, and then `requestCrawl`s `localhost:3001` at the relay. One ordering consequence worth knowing: bigsky indexes its inbound firehose with a parallel scheduler keyed by repo DID, so **per-repo event order survives the relay but cross-repo order does not**. Since the author-owned flip a post is a `community.postv2` in the AUTHOR's repo plus a `community.acceptance` in the COMMUNITY's — two repos, so those two events may arrive in either order, and an AppView can see the acceptance for a post it has not indexed yet (or the post before anything makes it visible in the community). The same applies to an author's `actor.profile` and their content. Any AppView consuming through relay infrastructure must tolerate it (see FOLLOWUPS.md). Pre-flip `community.post` records are the exception that proves the rule: they sat in the community's repo with no separate attestation, so there was no cross-repo pair to reorder — those records still exist and are not migrated. The host ports bind **loopback-only** (`127.0.0.1:8092/5380/8541/2480/3081/6028`; DNS uses UDP and TCP and `TIDEPOOL_E2E_DNS_PORT` overrides 5380): the stack carries admin tokens and runs with `ALLOW_PRIVATE_FETCH=1`, so it must not be reachable from the local network. ```sh make e2e # build + start the stack, run tests/e2e, tear down make e2e-up # start and leave running (iterate with make e2e-test) make e2e-test # run the suite against the running stack make e2e-logs # tail everything make e2e-down # tear down (removes volumes) ``` The first `make e2e` builds Lemmy **from source in debug mode** (a full Rust compile; cached afterwards). That is deliberate: whether Lemmy accepts `http://` federation URLs is a compile-time property (`cfg!(debug_assertions)` → the AP crate's `allow_http_urls`), and every published Lemmy image is a release build that refuses plain HTTP, explicit ports, and private addresses. A debug build — the same thing LemmyNet's own `docker/federation` compose uses — federates happily over `http://lemmy` ↔ `http://tidepool` inside the network, with `BRIDGE_SCHEME=http` (dev-only) making the bridge emit plain-HTTP AP ids to match. See the header comments in `docker-compose.e2e.yml` and `e2e/lemmy/Dockerfile` for the full story. The suite (`tests/e2e/`, build tag `e2e`: `bridge_test.go`, `lifecycle_test.go`, `media_test.go`, `moderation_test.go`, `native_pds_test.go`, `relay_test.go`, `votes_hammer_test.go`, `zz_sweep_test.go`) covers: subscribe → `community.profile` on the firehose; a link post → `actor.profile` and `community.post` (presence + author linkage; arrival order across the two repos is relay-dependent, see above), with the shared url crossing the wire as `embed.external`; comment threads in the authors' repos with resolving strongRefs; edits/deletes as update/delete ops; post **and** comment votes reaching `getVoteAggregates` — upvote, flip, retract — while **never** appearing as records; pre-subscribe backfill with every post's author profile arriving and Lemmy's pre-existing vote counts seeded into the aggregates; a mid-test container restart proving deterministic-rkey replay idempotency (the forced backfill redo is confirmed complete via the admin API's `last_backfill_at` before asserting it emitted nothing) plus exactly-once delivery of a post created in the recovery window — now proven **through the relay**, whose slurper reconnects to the bounced bridge with its stored cursor; a concurrent-ingestion burst accounted per (did, collection/rkey); and relay-state assertions — the bridge registered and actively subscribed in the relay's PDS registry (the bridge-originated `requestCrawl`, asserted rather than eyeballed), and bridged repos listed and served by the relay's own `listRepos`/`getLatestCommit`. The task-10 lifecycle scenarios extend that: an **image post** — a real pictrs upload, the blob fetched and stored by the bridge, `embed.images` plus the `nsfw` self-label crossing the wire and served back through `getBlob`; the full **`#nobridge` consent lifecycle** — a marked user's posts never materialize (no DID minted), removing the marker resumes bridging on the next post, re-adding it scrubs every record with delete commits while the repo stays active (reversible; discovery rides the `PROFILE_REFRESH_TTL` re-fetch because Lemmy 0.19 never federates `Update{Person}` on bio edits); **`Delete(Actor)`** — account deletion scrubs the author's post/comment/profile and terminally tombstones the repo (`getRepoStatus`/`listRepos` report `active:false` status `deleted`, the handle stops resolving, content endpoints refuse), the `#account{active:false, status:"deleted"}` frame is asserted on the bridge's own firehose, and the repo **disappears from the relay's `listRepos`** (bigsky consumes the frame, tombstones the account, and purges its data); **unsubscribe** — `DELETE /admin/communities` sends `Undo{Follow}` and new posts in that community produce no bridge output while a still-subscribed control keeps flowing; a **community profile update** federating as an `Announce{Update{Group}}` → `community.profile` update on rkey `self`; a **vote-concurrency hammer** — ten real voters in parallel bursts (votes, flips, clears) with exactly-correct final aggregates; and a **suite-end sweep** that replays the entire firehose from cursor 1 (a zero or negative cursor omits the param and live-tails — see the `newListener` doc in `tests/e2e/helpers.go`) on a fresh unfiltered listener, re-vetting every retained event (collection whitelist, lexicon validation, per-DID rev monotonicity), with a replay floor so a truncated replay cannot pass on the sentinel alone. Every negative assertion ("nothing bridged") is bounded by a positive control in the same window — never a bare sleep-and-assert-nothing. Task 18 adds the **native-PDS smoke scenario** (`native_pds_test.go`): the suite authenticates as the bootstrap account on the reference PDS, writes one `community.postv2` into that account's own repo, and follows it through the relay to Jetstream and into the relay's own `getLatestCommit` state. Deliberately a smoke test, not a feature test — what it buys is the wire, not the record. Note before extending it: `vetEvent`'s `expectedCollections` allowlist knows nothing about which repo a record came from, so it fails **closed** on a collection outside the list (a vote record in the native repo fails its own test and the `zz_sweep` replay) and **open** on one inside it (an `actor.profile` in the native repo passes silently, indistinguishable from the bridge writing its own). That second half is why the allowlist wants rescoping by repo class — task 18's sweep item, see FOLLOWUPS.md. Every create/update the tests consume from Jetstream has passed the relay's signature verification AND is validated against the vendored Coves lexicons on the consumer side of the wire, and any collection outside the `expectedCollections` allowlist fails the suite immediately, in any repo (votes must never become records). Two scripts keep the vendored lexicons honest: `scripts/sync-lexicons.sh` copies them from a Coves checkout; `scripts/check-lexicons.sh` verifies the committed manifest and byte-compares against the current Coves checkout. CI clones the canonical Coves repository for this comparison; locally it uses `~/Code/coves` by default. Everything is local-only: the harness never contacts `plc.directory`, public relays, or public Lemmy instances. ## Configuration All configuration is environment variables, read **once at process start** (`config.Load`) — there is no reload signal, so changing any value below means recreating the container. Two classes, and the difference matters at boot: - **Required in production** — `DATABASE_URL`, `LISTEN_ADDR`, `BRIDGE_HOSTNAME`, `PLC_DIRECTORY_URL`, `BRIDGE_KEK`, `ADMIN_TOKEN`, `AP_USER_ORIGIN`. These have *dev defaults only*; unset with `ENVIRONMENT=production` the process refuses to start (`config: is required in production`). Fail-closed on purpose: every one of them is baked into identities or authority, where a defaulted guess is worse than no boot. - **Tuning knobs** — everything else. Real defaults in every environment, logged when applied. | Variable | Dev default | Meaning | |---|---|---| | `ENVIRONMENT` | `development` | `development` or `production`; any other value is refused at boot. Development enables migrations-on-start, dev defaults, and strict lexicon validation. Production additionally *refuses* `BRIDGE_SCHEME=http`, `ALLOW_PRIVATE_FETCH`, `ALLOW_DEV_REQUEST_CRAWL`, and `AP_HOST_FALLTHROUGH_DEV` | | `DATABASE_URL` | local dev postgres | bridge state | | `LISTEN_ADDR` | `:8091` | HTTP bind address | | `BRIDGE_HOSTNAME` | `localhost` | public domain of the bridge; anchors handles and the PDS endpoint in minted DID docs | | `HANDLE_ZONE_ROOT` | `ap.` | DNS zone for new-format bridged handles; must be a valid hostname strictly under `BRIDGE_HOSTNAME` | | `HANDLE_MIGRATION_RATE` | `0.1` | positive number of PLC handle updates per second submitted by the admin handle migration job; each POST, retries included, is sent at least 1/rate after the previous one. A PLC 429 pauses the whole run (see HANDLE_SCHEME.md) | | `DNS_LISTEN` | *(empty; disabled)* | UDP and TCP listen address (e.g. `:53`); empty disables DNS; serves the `ap` handle zone alongside per-instance subzones (TXT, SOA/NS and A/AAAA); UDP responses are rate limited; zone transfers are refused | | `DNS_UDP_RESPONSE_RATE` | `200` | UDP responses per second per client IPv4 /24 or IPv6 /56 prefix; over the limit every second suppressed response is an empty truncated reply, the rest are dropped; TCP is unlimited | | `DNS_PUBLIC_IPV4` | *(unset)* | IPv4 address returned in A answers; required when `DNS_LISTEN` is set | | `DNS_PUBLIC_IPV6` | *(unset)* | optional IPv6 address returned in AAAA answers when DNS is enabled | | `DNS_NAMESERVERS` | `ns1`/`ns2` under `BRIDGE_HOSTNAME` when DNS is enabled | comma-separated nameserver hostnames returned in apex NS answers (first is the SOA primary) | | `CLOUDFLARE_API_TOKEN` | *(empty; disabled)* | Cloudflare API token with DNS edit on the zone; enables a pass at startup and every 15 minutes creating `