···11+# =============================================================================
22+# Server
33+# =============================================================================
14SERVER_HOST=127.0.0.1
25SERVER_PORT=3000
3677+# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
88+PDS_HOSTNAME=localhost:3000
99+1010+# =============================================================================
1111+# Database
1212+# =============================================================================
413DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
51466-S3_ENDPOINT=http://objsto:9000
1515+# Connection pool settings (defaults are good for most deployments)
1616+# DATABASE_MAX_CONNECTIONS=100
1717+# DATABASE_MIN_CONNECTIONS=10
1818+# DATABASE_ACQUIRE_TIMEOUT_SECS=30
1919+2020+# =============================================================================
2121+# Blob Storage (S3-compatible)
2222+# =============================================================================
2323+S3_ENDPOINT=http://localhost:9000
724AWS_REGION=us-east-1
825S3_BUCKET=pds-blobs
926AWS_ACCESS_KEY_ID=minioadmin
1027AWS_SECRET_ACCESS_KEY=minioadmin
11281212-# The public-facing hostname of the PDS
1313-PDS_HOSTNAME=localhost:3000
1414-PLC_URL=plc.directory
2929+# =============================================================================
3030+# Valkey (for caching and distributed rate limiting)
3131+# =============================================================================
3232+# If not set, falls back to in-memory caching (single-node only)
3333+# VALKEY_URL=redis://localhost:6379
3434+3535+# =============================================================================
3636+# Security Secrets
3737+# =============================================================================
3838+# These MUST be set in production (minimum 32 characters each)
3939+# In development, set BSPDS_ALLOW_INSECURE_SECRETS=1 to use defaults
4040+4141+# Server-wide secret for OAuth token signing (HS256)
4242+# JWT_SECRET=your-secure-random-string-at-least-32-chars
15431616-# A comma-separated list of relay URLs to notify via requestCrawl when we have updates.
1717-# e.g., CRAWLERS=https://bsky.network
1818-CRAWLERS=
4444+# Secret for DPoP proof validation
4545+# DPOP_SECRET=your-secure-random-string-at-least-32-chars
19462020-# Notification Service Configuration
2121-# At least one notification channel should be configured for user notifications to work.
4747+# Key for encrypting user signing keys at rest (AES-256-GCM)
4848+# MASTER_KEY=your-secure-random-string-at-least-32-chars
4949+5050+# Set this ONLY in development to allow default/weak secrets
5151+# BSPDS_ALLOW_INSECURE_SECRETS=1
5252+5353+# =============================================================================
5454+# PLC Directory
5555+# =============================================================================
5656+# PLC_DIRECTORY_URL=https://plc.directory
5757+# PLC_TIMEOUT_SECS=10
5858+# PLC_CONNECT_TIMEOUT_SECS=5
5959+6060+# Optional: rotation key for PLC operations (defaults to user's key)
6161+# PLC_ROTATION_KEY=did:key:...
6262+6363+# =============================================================================
6464+# Federation
6565+# =============================================================================
6666+# Appview URL for proxying app.bsky.* requests
6767+# APPVIEW_URL=https://api.bsky.app
6868+6969+# Comma-separated list of relay URLs to notify via requestCrawl
7070+# CRAWLERS=https://bsky.network
7171+7272+# =============================================================================
7373+# Firehose (subscribeRepos WebSocket)
7474+# =============================================================================
7575+# Buffer size for firehose broadcast channel
7676+# FIREHOSE_BUFFER_SIZE=10000
7777+7878+# Disconnect slow consumers after this many events of lag
7979+# FIREHOSE_MAX_LAG=5000
8080+8181+# =============================================================================
8282+# Notification Service
8383+# =============================================================================
8484+# Queue processing settings
8585+# NOTIFICATION_BATCH_SIZE=100
8686+# NOTIFICATION_POLL_INTERVAL_MS=1000
22872388# Email notifications (via sendmail/msmtp)
2489# MAIL_FROM_ADDRESS=noreply@example.com
···3499# Signal notifications (via signal-cli)
35100# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
36101# SIGNAL_SENDER_NUMBER=+1234567890
102102+103103+# =============================================================================
104104+# Repository Import
105105+# =============================================================================
106106+# Set to "true" to accept repository imports
107107+# ACCEPTING_REPO_IMPORTS=false
108108+109109+# Maximum import size in bytes (default: 50MB)
110110+# MAX_IMPORT_SIZE=52428800
111111+112112+# Maximum blocks per import (default: 100000)
113113+# MAX_IMPORT_BLOCKS=100000
114114+115115+# Skip verification during import (testing only)
116116+# SKIP_IMPORT_VERIFICATION=false
117117+118118+# =============================================================================
119119+# Account Registration
120120+# =============================================================================
121121+# Require invite codes for registration
122122+# INVITE_CODE_REQUIRED=false
123123+124124+# Comma-separated list of available user domains
125125+# AVAILABLE_USER_DOMAINS=example.com
126126+127127+# =============================================================================
128128+# Rate Limiting
129129+# =============================================================================
130130+# Disable all rate limiting (testing only, NEVER in production)
131131+# DISABLE_RATE_LIMITING=1
132132+133133+# =============================================================================
134134+# Miscellaneous
135135+# =============================================================================
136136+# Allow HTTP for proxy requests (development only)
137137+# ALLOW_HTTP_PROXY=1
138138+139139+# Custom frontend directory (defaults to ./frontend/dist)
140140+# FRONTEND_DIR=/path/to/frontend/dist
3714138142CARGO_MOMMYS_LITTLE=mister
39143CARGO_MOMMYS_PRONOUNS=his
···11-# BSPDS, a Personal Data Server
22-33-A production-grade Personal Data Server (PDS) implementation for the AT Protocol.
11+# BSPDS
4255-Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed to be a complete drop-in replacement for Bluesky's reference PDS implementation.
33+A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
6475## Features
8699-- Full AT Protocol support, all `com.atproto.*` endpoints implemented
1010-- OAuth 2.1 Provider. PKCE, DPoP, Pushed Authorization Requests
1111-- PostgreSQL, prod-ready database backend
1212-- S3-compatible object storage for blobs; works with AWS S3, UpCloud object storage, self-hosted MinIO, etc.
1313-- WebSocket `subscribeRepos` endpoint for real-time sync
1414-- Crawler notifications via `requestCrawl`
1515-- Multi-channel notifications: email, discord, telegram, signal
1616-- Per-IP rate limiting on sensitive endpoints
77+- Full AT Protocol support (`com.atproto.*` endpoints)
88+- OAuth 2.1 provider (PKCE, DPoP, PAR)
99+- WebSocket firehose (`subscribeRepos`)
1010+- Multi-channel notifications (email, discord, telegram, signal)
1711- Built-in web UI for account management
1818-1919-## Running Locally
2020-2121-Requires Rust installed locally.
2222-2323-Run PostgreSQL and S3-compatible object store (e.g., with podman/docker):
2424-2525-```bash
2626-podman compose up db objsto -d
2727-```
1212+- Per-IP rate limiting
28132929-Run the PDS:
1414+## Quick Start
30153116```bash
1717+cp .env.example .env
1818+podman compose up -d
3219just run
3320```
34213522## Configuration
36233737-### Required
3838-3939-| Variable | Description |
4040-|----------|-------------|
4141-| `DATABASE_URL` | PostgreSQL connection string |
4242-| `S3_BUCKET` | Blob storage bucket name |
4343-| `S3_ENDPOINT` | S3 endpoint URL (for MinIO, etc.) |
4444-| `AWS_ACCESS_KEY_ID` | S3 credentials |
4545-| `AWS_SECRET_ACCESS_KEY` | S3 credentials |
4646-| `AWS_REGION` | S3 region |
4747-| `PDS_HOSTNAME` | Public hostname of this PDS |
4848-| `JWT_SECRET` | Secret for OAuth token signing (HS256) |
4949-| `KEY_ENCRYPTION_KEY` | Key for encrypting user signing keys (AES-256-GCM) |
5050-5151-### Optional
5252-5353-| Variable | Description |
5454-|----------|-------------|
5555-| `APPVIEW_URL` | Appview URL to proxy unimplemented endpoints to |
5656-| `CRAWLERS` | Comma-separated list of relay URLs to notify via `requestCrawl` |
5757-5858-### Notifications
5959-6060-At least one channel should be configured for user notifications (password reset, email verification, etc.):
6161-6262-| Variable | Description |
6363-|----------|-------------|
6464-| `MAIL_FROM_ADDRESS` | Email sender address (enables email via sendmail) |
6565-| `MAIL_FROM_NAME` | Email sender name (default: "BSPDS") |
6666-| `SENDMAIL_PATH` | Path to sendmail binary (default: /usr/sbin/sendmail) |
6767-| `DISCORD_WEBHOOK_URL` | Discord webhook URL for notifications |
6868-| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
6969-| `SIGNAL_CLI_PATH` | Path to signal-cli binary |
7070-| `SIGNAL_SENDER_NUMBER` | Signal sender phone number (+1234567890 format) |
2424+See `.env.example` for all configuration options.
71257226## Development
73277474-```bash
7575-just # Show available commands
7676-just test # Run tests (auto-starts postgres/minio, runs nextest)
7777-just lint # Clippy + fmt check
7878-just db-reset # Drop and recreate local database
7979-```
8080-8181-## Web UI
8282-8383-BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
8484-8585-- Sign in and register new accounts
8686-- Manage app passwords
8787-- View and create invite codes
8888-- Update email and handle
8989-- Configure notification preferences
9090-- Browse their repository data
9191-9292-The frontend is built with svelte and deno, and is served directly by the PDS.
2828+Run `just` to see available commands.
93299430```bash
9595-just frontend-dev # Run frontend dev server
9696-just frontend-build # Build for production
9797-just frontend-test # Run frontend tests
9898-```
9999-100100-## Project Structure
101101-102102-```
103103-src/
104104- main.rs Server entrypoint
105105- lib.rs Router setup
106106- state.rs AppState (db pool, stores, rate limiters, circuit breakers)
107107- api/ XRPC handlers organized by namespace
108108- auth/ JWT authentication (ES256K per-user keys)
109109- oauth/ OAuth 2.1 provider (HS256 server-wide)
110110- repo/ PostgreSQL block store
111111- storage/ S3 blob storage
112112- sync/ Firehose, CAR export, crawler notifications
113113- notifications/ Multi-channel notification service
114114- plc/ PLC directory client
115115- circuit_breaker/ Circuit breaker for external services
116116- rate_limit/ Per-IP rate limiting
117117-frontend/ Svelte web UI (deno)
118118-tests/ Integration tests
119119-migrations/ SQLx migrations
3131+just test # run tests
3232+just lint # clippy + fmt
12033```
1213412235## License
···11+CREATE INDEX IF NOT EXISTS idx_records_repo_collection
22+ ON records(repo_id, collection);
33+44+CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
55+ ON records(repo_id, collection, created_at DESC);
66+77+CREATE INDEX IF NOT EXISTS idx_users_email
88+ ON users(email)
99+ WHERE email IS NOT NULL;
1010+1111+CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
1212+ ON blobs(created_by_user, created_at DESC);
1313+1414+CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
1515+ ON repo_seq(did, seq DESC);
1616+1717+CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
1818+ ON app_passwords(user_id);
1919+2020+CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
2121+ ON invite_codes(created_by_user);
···441441 assert_eq!(create_account_res.status(), StatusCode::OK);
442442 let account_body: Value = create_account_res.json().await.unwrap();
443443 let did = account_body["did"].as_str().unwrap().to_string();
444444- let access_jwt = account_body["accessJwt"].as_str().unwrap().to_string();
444444+445445+ let access_jwt = verify_new_account(&client, &did).await;
445446446447 let get_session_res = client
447448 .get(format!(
-554
tests/list_records_pagination.rs
···11-mod common;
22-mod helpers;
33-use common::*;
44-use helpers::*;
55-66-use chrono::Utc;
77-use reqwest::StatusCode;
88-use serde_json::{Value, json};
99-use std::time::Duration;
1010-1111-async fn create_post_with_rkey(
1212- client: &reqwest::Client,
1313- did: &str,
1414- jwt: &str,
1515- rkey: &str,
1616- text: &str,
1717-) -> (String, String) {
1818- let payload = json!({
1919- "repo": did,
2020- "collection": "app.bsky.feed.post",
2121- "rkey": rkey,
2222- "record": {
2323- "$type": "app.bsky.feed.post",
2424- "text": text,
2525- "createdAt": Utc::now().to_rfc3339()
2626- }
2727- });
2828-2929- let res = client
3030- .post(format!(
3131- "{}/xrpc/com.atproto.repo.putRecord",
3232- base_url().await
3333- ))
3434- .bearer_auth(jwt)
3535- .json(&payload)
3636- .send()
3737- .await
3838- .expect("Failed to create record");
3939-4040- assert_eq!(res.status(), StatusCode::OK);
4141- let body: Value = res.json().await.unwrap();
4242- (
4343- body["uri"].as_str().unwrap().to_string(),
4444- body["cid"].as_str().unwrap().to_string(),
4545- )
4646-}
4747-4848-#[tokio::test]
4949-async fn test_list_records_default_order() {
5050- let client = client();
5151- let (did, jwt) = setup_new_user("list-default-order").await;
5252-5353- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
5454- tokio::time::sleep(Duration::from_millis(50)).await;
5555- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
5656- tokio::time::sleep(Duration::from_millis(50)).await;
5757- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
5858-5959- let res = client
6060- .get(format!(
6161- "{}/xrpc/com.atproto.repo.listRecords",
6262- base_url().await
6363- ))
6464- .query(&[
6565- ("repo", did.as_str()),
6666- ("collection", "app.bsky.feed.post"),
6767- ])
6868- .send()
6969- .await
7070- .expect("Failed to list records");
7171-7272- assert_eq!(res.status(), StatusCode::OK);
7373- let body: Value = res.json().await.unwrap();
7474- let records = body["records"].as_array().unwrap();
7575-7676- assert_eq!(records.len(), 3);
7777- let rkeys: Vec<&str> = records
7878- .iter()
7979- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
8080- .collect();
8181-8282- assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
8383-}
8484-8585-#[tokio::test]
8686-async fn test_list_records_reverse_true() {
8787- let client = client();
8888- let (did, jwt) = setup_new_user("list-reverse").await;
8989-9090- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
9191- tokio::time::sleep(Duration::from_millis(50)).await;
9292- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
9393- tokio::time::sleep(Duration::from_millis(50)).await;
9494- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
9595-9696- let res = client
9797- .get(format!(
9898- "{}/xrpc/com.atproto.repo.listRecords",
9999- base_url().await
100100- ))
101101- .query(&[
102102- ("repo", did.as_str()),
103103- ("collection", "app.bsky.feed.post"),
104104- ("reverse", "true"),
105105- ])
106106- .send()
107107- .await
108108- .expect("Failed to list records");
109109-110110- assert_eq!(res.status(), StatusCode::OK);
111111- let body: Value = res.json().await.unwrap();
112112- let records = body["records"].as_array().unwrap();
113113-114114- let rkeys: Vec<&str> = records
115115- .iter()
116116- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
117117- .collect();
118118-119119- assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
120120-}
121121-122122-#[tokio::test]
123123-async fn test_list_records_cursor_pagination() {
124124- let client = client();
125125- let (did, jwt) = setup_new_user("list-cursor").await;
126126-127127- for i in 0..5 {
128128- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
129129- tokio::time::sleep(Duration::from_millis(50)).await;
130130- }
131131-132132- let res = client
133133- .get(format!(
134134- "{}/xrpc/com.atproto.repo.listRecords",
135135- base_url().await
136136- ))
137137- .query(&[
138138- ("repo", did.as_str()),
139139- ("collection", "app.bsky.feed.post"),
140140- ("limit", "2"),
141141- ])
142142- .send()
143143- .await
144144- .expect("Failed to list records");
145145-146146- assert_eq!(res.status(), StatusCode::OK);
147147- let body: Value = res.json().await.unwrap();
148148- let records = body["records"].as_array().unwrap();
149149- assert_eq!(records.len(), 2);
150150-151151- let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
152152-153153- let res2 = client
154154- .get(format!(
155155- "{}/xrpc/com.atproto.repo.listRecords",
156156- base_url().await
157157- ))
158158- .query(&[
159159- ("repo", did.as_str()),
160160- ("collection", "app.bsky.feed.post"),
161161- ("limit", "2"),
162162- ("cursor", cursor),
163163- ])
164164- .send()
165165- .await
166166- .expect("Failed to list records with cursor");
167167-168168- assert_eq!(res2.status(), StatusCode::OK);
169169- let body2: Value = res2.json().await.unwrap();
170170- let records2 = body2["records"].as_array().unwrap();
171171- assert_eq!(records2.len(), 2);
172172-173173- let all_uris: Vec<&str> = records
174174- .iter()
175175- .chain(records2.iter())
176176- .map(|r| r["uri"].as_str().unwrap())
177177- .collect();
178178- let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
179179- assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
180180-}
181181-182182-#[tokio::test]
183183-async fn test_list_records_rkey_start() {
184184- let client = client();
185185- let (did, jwt) = setup_new_user("list-rkey-start").await;
186186-187187- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
188188- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
189189- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
190190- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
191191-192192- let res = client
193193- .get(format!(
194194- "{}/xrpc/com.atproto.repo.listRecords",
195195- base_url().await
196196- ))
197197- .query(&[
198198- ("repo", did.as_str()),
199199- ("collection", "app.bsky.feed.post"),
200200- ("rkeyStart", "bbbb"),
201201- ("reverse", "true"),
202202- ])
203203- .send()
204204- .await
205205- .expect("Failed to list records");
206206-207207- assert_eq!(res.status(), StatusCode::OK);
208208- let body: Value = res.json().await.unwrap();
209209- let records = body["records"].as_array().unwrap();
210210-211211- let rkeys: Vec<&str> = records
212212- .iter()
213213- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
214214- .collect();
215215-216216- for rkey in &rkeys {
217217- assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
218218- }
219219-}
220220-221221-#[tokio::test]
222222-async fn test_list_records_rkey_end() {
223223- let client = client();
224224- let (did, jwt) = setup_new_user("list-rkey-end").await;
225225-226226- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
227227- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
228228- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
229229- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
230230-231231- let res = client
232232- .get(format!(
233233- "{}/xrpc/com.atproto.repo.listRecords",
234234- base_url().await
235235- ))
236236- .query(&[
237237- ("repo", did.as_str()),
238238- ("collection", "app.bsky.feed.post"),
239239- ("rkeyEnd", "cccc"),
240240- ("reverse", "true"),
241241- ])
242242- .send()
243243- .await
244244- .expect("Failed to list records");
245245-246246- assert_eq!(res.status(), StatusCode::OK);
247247- let body: Value = res.json().await.unwrap();
248248- let records = body["records"].as_array().unwrap();
249249-250250- let rkeys: Vec<&str> = records
251251- .iter()
252252- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
253253- .collect();
254254-255255- for rkey in &rkeys {
256256- assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
257257- }
258258-}
259259-260260-#[tokio::test]
261261-async fn test_list_records_rkey_range() {
262262- let client = client();
263263- let (did, jwt) = setup_new_user("list-rkey-range").await;
264264-265265- create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
266266- create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
267267- create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
268268- create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
269269- create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
270270-271271- let res = client
272272- .get(format!(
273273- "{}/xrpc/com.atproto.repo.listRecords",
274274- base_url().await
275275- ))
276276- .query(&[
277277- ("repo", did.as_str()),
278278- ("collection", "app.bsky.feed.post"),
279279- ("rkeyStart", "bbbb"),
280280- ("rkeyEnd", "dddd"),
281281- ("reverse", "true"),
282282- ])
283283- .send()
284284- .await
285285- .expect("Failed to list records");
286286-287287- assert_eq!(res.status(), StatusCode::OK);
288288- let body: Value = res.json().await.unwrap();
289289- let records = body["records"].as_array().unwrap();
290290-291291- let rkeys: Vec<&str> = records
292292- .iter()
293293- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
294294- .collect();
295295-296296- for rkey in &rkeys {
297297- assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
298298- }
299299- assert!(!rkeys.is_empty(), "Should have at least some records in range");
300300-}
301301-302302-#[tokio::test]
303303-async fn test_list_records_limit_clamping_max() {
304304- let client = client();
305305- let (did, jwt) = setup_new_user("list-limit-max").await;
306306-307307- for i in 0..5 {
308308- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
309309- }
310310-311311- let res = client
312312- .get(format!(
313313- "{}/xrpc/com.atproto.repo.listRecords",
314314- base_url().await
315315- ))
316316- .query(&[
317317- ("repo", did.as_str()),
318318- ("collection", "app.bsky.feed.post"),
319319- ("limit", "1000"),
320320- ])
321321- .send()
322322- .await
323323- .expect("Failed to list records");
324324-325325- assert_eq!(res.status(), StatusCode::OK);
326326- let body: Value = res.json().await.unwrap();
327327- let records = body["records"].as_array().unwrap();
328328- assert!(records.len() <= 100, "Limit should be clamped to max 100");
329329-}
330330-331331-#[tokio::test]
332332-async fn test_list_records_limit_clamping_min() {
333333- let client = client();
334334- let (did, jwt) = setup_new_user("list-limit-min").await;
335335-336336- create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
337337-338338- let res = client
339339- .get(format!(
340340- "{}/xrpc/com.atproto.repo.listRecords",
341341- base_url().await
342342- ))
343343- .query(&[
344344- ("repo", did.as_str()),
345345- ("collection", "app.bsky.feed.post"),
346346- ("limit", "0"),
347347- ])
348348- .send()
349349- .await
350350- .expect("Failed to list records");
351351-352352- assert_eq!(res.status(), StatusCode::OK);
353353- let body: Value = res.json().await.unwrap();
354354- let records = body["records"].as_array().unwrap();
355355- assert!(records.len() >= 1, "Limit should be clamped to min 1");
356356-}
357357-358358-#[tokio::test]
359359-async fn test_list_records_empty_collection() {
360360- let client = client();
361361- let (did, _jwt) = setup_new_user("list-empty").await;
362362-363363- let res = client
364364- .get(format!(
365365- "{}/xrpc/com.atproto.repo.listRecords",
366366- base_url().await
367367- ))
368368- .query(&[
369369- ("repo", did.as_str()),
370370- ("collection", "app.bsky.feed.post"),
371371- ])
372372- .send()
373373- .await
374374- .expect("Failed to list records");
375375-376376- assert_eq!(res.status(), StatusCode::OK);
377377- let body: Value = res.json().await.unwrap();
378378- let records = body["records"].as_array().unwrap();
379379- assert!(records.is_empty(), "Empty collection should return empty array");
380380- assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
381381-}
382382-383383-#[tokio::test]
384384-async fn test_list_records_exact_limit() {
385385- let client = client();
386386- let (did, jwt) = setup_new_user("list-exact-limit").await;
387387-388388- for i in 0..10 {
389389- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
390390- }
391391-392392- let res = client
393393- .get(format!(
394394- "{}/xrpc/com.atproto.repo.listRecords",
395395- base_url().await
396396- ))
397397- .query(&[
398398- ("repo", did.as_str()),
399399- ("collection", "app.bsky.feed.post"),
400400- ("limit", "5"),
401401- ])
402402- .send()
403403- .await
404404- .expect("Failed to list records");
405405-406406- assert_eq!(res.status(), StatusCode::OK);
407407- let body: Value = res.json().await.unwrap();
408408- let records = body["records"].as_array().unwrap();
409409- assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
410410-}
411411-412412-#[tokio::test]
413413-async fn test_list_records_cursor_exhaustion() {
414414- let client = client();
415415- let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
416416-417417- for i in 0..3 {
418418- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
419419- }
420420-421421- let res = client
422422- .get(format!(
423423- "{}/xrpc/com.atproto.repo.listRecords",
424424- base_url().await
425425- ))
426426- .query(&[
427427- ("repo", did.as_str()),
428428- ("collection", "app.bsky.feed.post"),
429429- ("limit", "10"),
430430- ])
431431- .send()
432432- .await
433433- .expect("Failed to list records");
434434-435435- assert_eq!(res.status(), StatusCode::OK);
436436- let body: Value = res.json().await.unwrap();
437437- let records = body["records"].as_array().unwrap();
438438- assert_eq!(records.len(), 3);
439439-}
440440-441441-#[tokio::test]
442442-async fn test_list_records_repo_not_found() {
443443- let client = client();
444444-445445- let res = client
446446- .get(format!(
447447- "{}/xrpc/com.atproto.repo.listRecords",
448448- base_url().await
449449- ))
450450- .query(&[
451451- ("repo", "did:plc:nonexistent12345"),
452452- ("collection", "app.bsky.feed.post"),
453453- ])
454454- .send()
455455- .await
456456- .expect("Failed to list records");
457457-458458- assert_eq!(res.status(), StatusCode::NOT_FOUND);
459459-}
460460-461461-#[tokio::test]
462462-async fn test_list_records_includes_cid() {
463463- let client = client();
464464- let (did, jwt) = setup_new_user("list-includes-cid").await;
465465-466466- create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
467467-468468- let res = client
469469- .get(format!(
470470- "{}/xrpc/com.atproto.repo.listRecords",
471471- base_url().await
472472- ))
473473- .query(&[
474474- ("repo", did.as_str()),
475475- ("collection", "app.bsky.feed.post"),
476476- ])
477477- .send()
478478- .await
479479- .expect("Failed to list records");
480480-481481- assert_eq!(res.status(), StatusCode::OK);
482482- let body: Value = res.json().await.unwrap();
483483- let records = body["records"].as_array().unwrap();
484484-485485- for record in records {
486486- assert!(record["uri"].is_string(), "Record should have uri");
487487- assert!(record["cid"].is_string(), "Record should have cid");
488488- assert!(record["value"].is_object(), "Record should have value");
489489- let cid = record["cid"].as_str().unwrap();
490490- assert!(cid.starts_with("bafy"), "CID should be valid");
491491- }
492492-}
493493-494494-#[tokio::test]
495495-async fn test_list_records_cursor_with_reverse() {
496496- let client = client();
497497- let (did, jwt) = setup_new_user("list-cursor-reverse").await;
498498-499499- for i in 0..5 {
500500- create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
501501- }
502502-503503- let res = client
504504- .get(format!(
505505- "{}/xrpc/com.atproto.repo.listRecords",
506506- base_url().await
507507- ))
508508- .query(&[
509509- ("repo", did.as_str()),
510510- ("collection", "app.bsky.feed.post"),
511511- ("limit", "2"),
512512- ("reverse", "true"),
513513- ])
514514- .send()
515515- .await
516516- .expect("Failed to list records");
517517-518518- assert_eq!(res.status(), StatusCode::OK);
519519- let body: Value = res.json().await.unwrap();
520520- let records = body["records"].as_array().unwrap();
521521- let first_rkeys: Vec<&str> = records
522522- .iter()
523523- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
524524- .collect();
525525-526526- assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
527527-528528- if let Some(cursor) = body["cursor"].as_str() {
529529- let res2 = client
530530- .get(format!(
531531- "{}/xrpc/com.atproto.repo.listRecords",
532532- base_url().await
533533- ))
534534- .query(&[
535535- ("repo", did.as_str()),
536536- ("collection", "app.bsky.feed.post"),
537537- ("limit", "2"),
538538- ("reverse", "true"),
539539- ("cursor", cursor),
540540- ])
541541- .send()
542542- .await
543543- .expect("Failed to list records with cursor");
544544-545545- let body2: Value = res2.json().await.unwrap();
546546- let records2 = body2["records"].as_array().unwrap();
547547- let second_rkeys: Vec<&str> = records2
548548- .iter()
549549- .map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
550550- .collect();
551551-552552- assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
553553- }
554554-}