An easy to set up and operate PDS admin panel.
65

Configure Feed

Select the types of activity you want to include in your feed.

bearer admin auth mode for PDSes without an admin password

Brooke (Jul 10, 2026, 8:48 PM -0700) 0b8dd3a9 fbf886c2

+58 -6
+4
.env.example
··· 1 1 # PDS this dashboard manages 2 2 PDS_HOSTNAME=pds.example.com 3 3 PDS_ADMIN_PASSWORD=changeme 4 + # For a PDS without an admin password (e.g. tranquil-pds): set this to the handle or DID 5 + # of an account with admin rights. PDS_ADMIN_PASSWORD is then that account's password 6 + # (an app password works). Leave empty for the reference PDS. 7 + PDS_ADMIN_IDENTIFIER= 4 8 5 9 # Relay used for sync status / requestCrawl 6 10 RELAY_HOSTNAME=bsky.network
+10
README.md
··· 46 46 `server/.env` and generate the hash with 47 47 `node -e "console.log(require('bcryptjs').hashSync('<password>', 10))"`. 48 48 49 + ### Other PDS implementations 50 + 51 + The reference PDS authenticates admin calls with `PDS_ADMIN_PASSWORD` over basic auth. 52 + For implementations without an admin password, like 53 + [tranquil-pds](https://tangled.org/tranquil.farm/tranquil-pds), set 54 + `PDS_ADMIN_IDENTIFIER` to the handle of an account with admin rights and put that 55 + account's password in `PDS_ADMIN_PASSWORD`. The dashboard then signs in as that account 56 + and sends bearer tokens instead. Sign-in is headless, so use an app password or keep 2FA 57 + off that account. 58 + 49 59 ### Labelers 50 60 51 61 `server/labelers.json` (gitignored, copy `server/labelers.json.example`):
+9 -2
server/src/cli.ts
··· 164 164 } 165 165 166 166 const pdsHostname = await ask("pds hostname (e.g. pds.example.com)"); 167 - const pdsAdminPassword = await askHidden("pds admin password"); 167 + console.log("\nreference pds: leave the handle empty, sign in with the admin password."); 168 + console.log("pds without an admin password (e.g. tranquil-pds): give an admin account's"); 169 + console.log("handle, then its password (an app password works and sidesteps 2fa)."); 170 + const pdsAdminIdentifier = await ask("admin account handle (empty for admin password auth)", ""); 171 + const pdsAdminPassword = await askHidden( 172 + pdsAdminIdentifier ? "admin account password" : "pds admin password", 173 + ); 168 174 const relayHostname = await ask("relay hostname", "bsky.network"); 169 175 const appviewUrl = await ask("appview url for profile links", "https://bsky.app"); 170 176 const dashboardUrl = await ask("dashboard url (used in DM deep links)", "http://localhost:5173"); ··· 186 192 const lines = [ 187 193 `PDS_HOSTNAME=${pdsHostname}`, 188 194 `PDS_ADMIN_PASSWORD=${pdsAdminPassword}`, 195 + `PDS_ADMIN_IDENTIFIER=${pdsAdminIdentifier}`, 189 196 `RELAY_HOSTNAME=${relayHostname}`, 190 197 operatorPassword 191 198 ? `OPERATOR_PASSWORD_HASH=${bcrypt.hashSync(operatorPassword, 10)}` ··· 225 232 HOST = "0.0.0.0" 226 233 PORT = "8787" 227 234 PDS_HOSTNAME = "${pdsHostname}" 228 - RELAY_HOSTNAME = "${relayHostname}" 235 + ${pdsAdminIdentifier ? ` PDS_ADMIN_IDENTIFIER = "${pdsAdminIdentifier}"\n` : ""} RELAY_HOSTNAME = "${relayHostname}" 229 236 APPVIEW_URL = "${appviewUrl}" 230 237 DASHBOARD_URL = "${flyUrl.replace(/\/$/, "")}" 231 238 DB_PATH = "/data/data.sqlite"
+2 -1
server/src/index.ts
··· 24 24 const { 25 25 PDS_HOSTNAME, 26 26 PDS_ADMIN_PASSWORD, 27 + PDS_ADMIN_IDENTIFIER, 27 28 RELAY_HOSTNAME, 28 29 OPERATOR_PASSWORD_HASH, 29 30 SESSION_SECRET, ··· 75 76 }, 76 77 }); 77 78 78 - const pds = new PdsClient(PDS_HOSTNAME!, PDS_ADMIN_PASSWORD!); 79 + const pds = new PdsClient(PDS_HOSTNAME!, PDS_ADMIN_PASSWORD!, PDS_ADMIN_IDENTIFIER || undefined); 79 80 const relay = new RelayClient(RELAY_HOSTNAME!); 80 81 // labelers.json: [{ "name"?, "did", "labels": [...] }] — labels empty/omitted means all labels flag. 81 82 // Falls back to LABELER_DID / FLAG_LABELS env vars if the file doesn't exist.
+33 -3
server/src/pdsClient.ts
··· 36 36 } 37 37 38 38 export class PdsClient { 39 + private accessJwt: string | null = null; 40 + 39 41 constructor( 40 42 public readonly hostname: string, 41 43 private adminPassword: string, 44 + /** 45 + * When set, admin calls sign in as this account (which must have admin rights on the 46 + * PDS) and send bearer tokens instead of `admin:<password>` basic auth. Needed for 47 + * PDS implementations without an admin password, e.g. tranquil-pds. adminPassword is 48 + * then this account's password — an app password works and sidesteps 2FA. 49 + */ 50 + private adminIdentifier?: string, 42 51 ) {} 43 52 44 - private authHeader() { 53 + private async ensureSession(): Promise<string> { 54 + if (this.accessJwt) return this.accessJwt; 55 + const res = await fetch(`https://${this.hostname}/xrpc/com.atproto.server.createSession`, { 56 + method: "POST", 57 + headers: { "Content-Type": "application/json" }, 58 + body: JSON.stringify({ identifier: this.adminIdentifier, password: this.adminPassword }), 59 + }); 60 + if (!res.ok) { 61 + const body = await res.text().catch(() => ""); 62 + throw new Error(`PDS admin sign-in as ${this.adminIdentifier} failed: ${res.status} ${body}`); 63 + } 64 + const { accessJwt } = (await res.json()) as { accessJwt: string }; 65 + this.accessJwt = accessJwt; 66 + return accessJwt; 67 + } 68 + 69 + private async authHeader() { 70 + if (this.adminIdentifier) return `Bearer ${await this.ensureSession()}`; 45 71 const token = Buffer.from(`admin:${this.adminPassword}`).toString("base64"); 46 72 return `Basic ${token}`; 47 73 } 48 74 49 - private async xrpc(path: string, opts: RequestInit = {}) { 75 + private async xrpc(path: string, opts: RequestInit = {}, retry = true): Promise<any> { 50 76 const res = await fetch(`https://${this.hostname}/xrpc/${path}`, { 51 77 ...opts, 52 78 headers: { 53 79 ...opts.headers, 54 - Authorization: this.authHeader(), 80 + Authorization: await this.authHeader(), 55 81 "Content-Type": "application/json", 56 82 }, 57 83 }); 84 + if (res.status === 401 && this.adminIdentifier && retry) { 85 + this.accessJwt = null; // token expired — re-auth once 86 + return this.xrpc(path, opts, false); 87 + } 58 88 if (!res.ok) { 59 89 const body = await res.text().catch(() => ""); 60 90 throw new Error(`PDS ${path} failed: ${res.status} ${body}`);