···162162These endpoints need to be implemented at the PDS level (not just proxied to appview).
163163164164### Actor (`app.bsky.actor`)
165165-- [ ] Implement `app.bsky.actor.getPreferences` (user preferences storage).
166166-- [ ] Implement `app.bsky.actor.putPreferences` (update user preferences).
167167-- [ ] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback).
168168-- [ ] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback).
165165+- [x] Implement `app.bsky.actor.getPreferences` (user preferences storage).
166166+- [x] Implement `app.bsky.actor.putPreferences` (update user preferences).
167167+- [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback).
168168+- [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback).
169169170170### Feed (`app.bsky.feed`)
171171These are implemented at PDS level to enable local-first reads:
···190190191191## Preference Storage
192192User preferences (for app.bsky.actor.getPreferences/putPreferences):
193193-- [ ] Create preferences table for storing user app preferences.
194194-- [ ] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback).
195195-- [ ] Implement `app.bsky.actor.putPreferences` handler (write to postgres).
193193+- [x] Create preferences table for storing user app preferences.
194194+- [x] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback).
195195+- [x] Implement `app.bsky.actor.putPreferences` handler (write to postgres).
196196197197## Infrastructure & Core Components
198198- [x] Sequencer (Event Log)
···221221 - [ ] Telegram bot sender
222222 - [ ] Signal bot sender
223223 - [x] Helper functions for common notification types (welcome, password reset, email verification, etc.)
224224+ - [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications
224225- [ ] Image Processing
225226 - [ ] Implement image resize/formatting pipeline (for blob uploads).
226227- [x] IPLD & MST
···230231 - [ ] DID PLC Operations (Sign rotation keys).
231232- [ ] Fix any remaining TODOs in the code, everywhere, full stop.
232233234234+## Web Management UI
235235+A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers.
236236+237237+### Architecture
238238+- [ ] Static SPA served from PDS (or separate static host)
239239+- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
240240+- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
241241+- [ ] No server-side sessions or CSRF - pure API client
242242+243243+### PDS-Specific XRPC Endpoints (new)
244244+Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
245245+Anyway... endpoints for PDS settings not covered by standard ATProto:
246246+- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
247247+- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
248248+- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
249249+- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
250250+- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
251251+- [ ] `com.bspds.admin.getServerStats` - user count, storage usage, etc.
252252+253253+### Frontend Views
254254+Uses existing ATProto endpoints where possible:
255255+256256+**User Dashboard**
257257+- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
258258+- [ ] Active sessions view (needs new endpoint or extend existing)
259259+- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
260260+- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
261261+262262+**Notification Preferences**
263263+- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
264264+- [ ] Verification flows for Discord/Telegram/Signal
265265+- [ ] Notification history view
266266+267267+**Account Settings**
268268+- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
269269+- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
270270+- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
271271+- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
272272+- [ ] Data export (uses `com.atproto.sync.getRepo`)
273273+274274+**Admin Dashboard** (privileged users only)
275275+- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
276276+- [ ] User detail/actions (uses `com.atproto.admin.*` endpoints)
277277+- [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`)
278278+- [ ] Server stats (uses `com.bspds.admin.getServerStats`)
279279+
+12
migrations/202512211500_account_preferences.sql
···11+CREATE TABLE IF NOT EXISTS account_preferences (
22+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44+ name TEXT NOT NULL,
55+ value_json JSONB NOT NULL,
66+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
88+ UNIQUE(user_id, name)
99+);
1010+1111+CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
1212+CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
+5
src/api/actor/mod.rs
···11+mod preferences;
22+mod profile;
33+44+pub use preferences::{get_preferences, put_preferences};
55+pub use profile::{get_profile, get_profiles};
···11mod common;
2233use bspds::notifications::{
44- enqueue_notification, enqueue_welcome_email, NewNotification, NotificationChannel,
44+ enqueue_notification, enqueue_welcome, NewNotification, NotificationChannel,
55 NotificationStatus, NotificationType,
66};
77use sqlx::PgPool;
···6464}
65656666#[tokio::test]
6767-async fn test_enqueue_welcome_email() {
6767+async fn test_enqueue_welcome() {
6868 let pool = get_pool().await;
69697070 let (_, did) = common::create_account_and_login(&common::client()).await;
71717272- let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
7272+ let user_row = sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did)
7373 .fetch_one(&pool)
7474 .await
7575 .expect("User not found");
76767777- let notification_id = enqueue_welcome_email(&pool, user_id, "user@example.com", "testhandle", "example.com")
7777+ let notification_id = enqueue_welcome(&pool, user_row.id, "example.com")
7878 .await
7979- .expect("Failed to enqueue welcome email");
7979+ .expect("Failed to enqueue welcome notification");
80808181 let row = sqlx::query!(
8282 r#"
···9292 .await
9393 .expect("Notification not found");
94949595- assert_eq!(row.recipient, "user@example.com");
9595+ assert_eq!(row.recipient, user_row.email);
9696 assert_eq!(row.subject.as_deref(), Some("Welcome to example.com"));
9797- assert!(row.body.contains("@testhandle"));
9797+ assert!(row.body.contains(&format!("@{}", user_row.handle)));
9898 assert_eq!(row.notification_type, NotificationType::Welcome);
9999}
100100
+1-1
tests/oauth.rs
···14281428 let mock_client = setup_mock_client_metadata(redirect_uri).await;
14291429 let client_id = mock_client.uri();
1430143014311431- let (code_verifier, code_challenge) = generate_pkce();
14311431+ let (_code_verifier, code_challenge) = generate_pkce();
14321432 let special_state = "state=with&special=chars&plus+more";
1433143314341434 let par_body: Value = http_client
-1
tests/oauth_dpop.rs
···1111 iat_offset_secs: i64,
1212) -> String {
1313 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1414- use p256::elliptic_curve::sec1::ToEncodedPoint;
15141615 let signing_key = SigningKey::random(&mut rand::thread_rng());
1716 let verifying_key = signing_key.verifying_key();