feat: on-protocol moderation writes + community.create / registerCredentials
Moves `social.colibri.moderation` records off the local `record_data`
cache and onto the community's actual PDS. Adds the credential-management
infrastructure this requires plus the two registration endpoints needed
to populate it end-to-end.
Plumbing
- `lib::crypto` — AES-256-GCM authenticated encryption (aes-gcm crate).
Master key loaded once from `CREDENTIAL_ENCRYPTION_KEY` (base64, 32
bytes) into a process-wide `OnceLock`.
- `community_credentials` table (migration m20260515) + model.
Ciphertext + nonce stored as base64 strings. Each row is tagged
`appview_managed` or `byo`.
- `lib::pds_client` — minimal HTTP client for `com.atproto.server.
createSession`, `com.atproto.repo.createRecord` (with optional pinned
rkey), `com.atproto.repo.deleteRecord` (reserved for follow-up),
`com.atproto.server.createAccount` (accepts optional admin Basic auth).
- `lib::moderation::write_moderation_record` rewritten: load credentials
→ createSession → createRecord on the community repo; optimistic
best-effort local insert into `record_data` so the issuer's own
queries reflect the change immediately. Local-insert failures log and
rely on the upstream firehose ingester to reconcile.
Variant A: `social.colibri.community.create`
- Auths the caller. Generates random handle/email/password.
- Calls createAccount on `PDS_LOC` with HTTP Basic auth from
`PDS_ADMIN_USER` / `PDS_ADMIN_PASS` so the PDS mints an account
without an invite code.
- Stores credentials encrypted, then createSession to obtain an access
JWT for the bootstrap writes.
- Pre-generates four TIDs (category, channel, role, member) so every
record can embed forward references before any PDS round-trip, then
writes five records on the new repo:
* `social.colibri.community` pinned at `rkey: "self"`
* `social.colibri.category` ("General")
* `social.colibri.channel` ("general", text type)
* `social.colibri.role` ("Owner") with the full permission catalog
and `protected: true`
* `social.colibri.member` with the caller as subject and the Owner
role assigned
- Response carries all five at-URIs + the new community DID.
Variant B: `social.colibri.community.registerCredentials`
- BYO submission of (DID, PDS endpoint, identifier, app password).
- Proof-of-control: calls createSession against the supplied PDS and
rejects the submission if the resulting session's DID doesn't match
the claimed community DID.
- Stores credentials encrypted; no bootstrap.
Lexicons (separate PR in the website repo)
- `social.colibri.community` revision bumped to 2 with
`key: "literal:self"`.
- `social.colibri.role` revision bumped to 2 with a new optional
`protected: boolean` field.
Other
- `Permission::all()` enumerates the full catalog for the bootstrap
role.
- Boot-time invariant check: AppView panics with a descriptive message
if `PDS_LOC` / `PDS_ADMIN_USER` / `PDS_ADMIN_PASS` /
`APPVIEW_HANDLE_DOMAIN` are missing.
Tests: 150 passing (22 new across crypto, credentials store,
pds-client, community.create, registerCredentials).
Follow-ups in this stack (matches the agreed three-way split):
- Filter inbound message/reaction records from banned users at the
`ack_tap_msg` persistence site and at read time.
- Delete `social.colibri.member` records on ban / kick / leave via
`pds_client::delete_record` (which is already wired but unused).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>