# PDS Operator Self-hosted admin dashboard for an [atproto](https://atproto.com) PDS: account list/search/takedown/enable/password-reset, moderation-labeler flags with Bluesky DM alerts, relay sync status, invite codes, passkey sign-in, and an audit log. Talks to the PDS and relay only over their public HTTPS APIs, no SSH/DB access required. ![PDS Operator](docs/screenshot-default.png)

flagged accounts view login with passkey

![invite codes, passkeys, and audit log](docs/screenshot-panels.png) ## How it works The server keeps a local SQLite copy of everything in `server/data.sqlite`. A background syncer does a full pass against the PDS on startup and every 15 minutes. Between passes it stays subscribed to the PDS firehose and each labeler's label stream, so handle changes, takedowns, and new labels land within seconds. Stream cursors survive restarts, and dead sockets are detected and reconnected. Avatars and handle resolution go through the PDS itself, not the Bluesky appview. Requests to outside services (label backfill) are throttled and retried with backoff when rate limited. The SQLite file is a rebuildable cache. The only data worth backing up is `server/audit.log`. ## Setup ``` cd web && npm install cd ../server && npm install npm run setup # interactive, writes server/.env ``` The wizard asks for the PDS, relay, and DM alert config, generates a session secret, and can start the server and mint a passkey enrollment link in one go. It can also generate a fly.toml and print the fly.io deploy commands (optional, any host works). The operator password is optional. Leave it empty for passkey-only sign-in, where enrollment links from the CLI are the only way in: ``` npm run enroll # prints a single-use link + QR code, expires in 15 minutes ``` Run it again any time to enroll another device, or as recovery if all passkeys are lost (shell access is the break-glass). To configure by hand instead, copy `.env.example` to `server/.env` and generate the hash with `node -e "console.log(require('bcryptjs').hashSync('', 10))"`. ### Other PDS implementations The reference PDS authenticates admin calls with `PDS_ADMIN_PASSWORD` over basic auth. For implementations without an admin password, like [tranquil-pds](https://tangled.org/tranquil.farm/tranquil-pds), set `PDS_ADMIN_IDENTIFIER` to the handle of an account with admin rights and put that account's password in `PDS_ADMIN_PASSWORD`. The dashboard then signs in as that account and sends bearer tokens instead. Sign-in is headless, so use an app password or keep 2FA off that account. ### Labelers `server/labelers.json` (gitignored, copy `server/labelers.json.example`): ```json [ { "name": "skywatch blue", "did": "did:plc:e4elbtctnfqocyfcml6h2lf7", "labels": ["platform-manipulation", "engagement-abuse"] } ] ``` `labels` is the watchlist of label values that flag an account. Empty or omitted means every label from that labeler counts. The endpoint is resolved from the labeler's DID document and label names come from its published definitions. Changing the file requires a restart. Ozone quirk: `queryLabels` silently returns nothing past 20 `uriPatterns` per request, so backfill batches are capped at 20. ### DM alerts Set the four `NOTIFY_*` / `DASHBOARD_URL` values in `server/.env`: sender handle, an app password created **with** DM access, recipient handle, and the dashboard URL for deep links. The recipient has to accept DMs from the sender (follow them, or allow DMs from everyone). Unset = disabled. With DMs enabled you also get an alert when a single account creates a burst of records (likely spam). The default fires at 500 record creations in 60 minutes, tune with `ACTIVITY_ALERT_CREATES` and `ACTIVITY_ALERT_WINDOW_MINUTES`, disable with `ACTIVITY_ALERT_CREATES=0`. At most one alert per account per window. ### Admins The dashboard supports multiple named admins. Add someone by handle in the Admins panel, then a "new admin" button appears on the sign-in page. They verify their atproto account via OAuth (identity only, no permissions granted) and create a passkey once. From then on they sign in with the passkey, and the audit log records actions under their handle. Enrollment is one shot per admin. To reset someone (lost device), remove and re-add them, which deletes their passkeys and reopens enrollment. `npm run enroll` remains the bootstrap and break-glass path, those passkeys sign in as the generic "operator". ## Dev ``` cd web && npm run dev # vite dev server, proxies /api to :8787 cd server && npm run dev # fastify API on :8787 ``` ## Production ``` cd web && npm run build # writes web/dist cd server && npm run build && NODE_ENV=production npm start ``` The server serves `web/dist` alongside the API, so only the server process runs in production. - Run behind a TLS-terminating reverse proxy. HTTPS is required for passkeys (WebAuthn won't run in a non-secure context), and passkeys are per-hostname, so enroll fresh ones on the production domain. - `NODE_ENV=production` enables the secure session cookie, binds to 127.0.0.1 (override with `HOST`), and refuses weak `SESSION_SECRET`s (`openssl rand -hex 32`). - `trustProxy` is on: `req.ip` (login rate limiting) comes from X-Forwarded-For. Correct behind the proxy, spoofable if you expose the port directly. - Set `DASHBOARD_URL` to the real URL and `chmod 600 server/.env`. - Back up `server/audit.log` (`AUDIT_LOG_PATH` overrides the location). - Run under a process supervisor. Sessions are in-memory, so a restart signs you out. ### Docker ``` cp .env.example .env # fill in per the comments cp compose.example.yaml compose.yaml docker compose up -d --build docker compose exec app node dist/cli.js enroll # passkey enrollment link (see Setup) ``` - The dashboard listens on `127.0.0.1:8787`, reachable only from the host. In production, put a TLS-terminating reverse proxy in front (HTTPS is required for passkeys). If the proxy is another compose service, remove the `ports:` block and point it at `app:8787`. - Data persists in the `app-data` volume across rebuilds and updates. To back up the audit log, do: `docker compose cp app:/data/audit.log .`, or you can switch to a bind mount (see comment in `compose.example.yaml`). - To watch your own labelers, edit `server/labelers.json` (copy the example) then rebuild, or bind mount it (commented line in the compose file) so a restart is enough. - Use the enroll command above, not `npm run enroll`. dev tooling isn't in the image.