+18
-3
.env.example
+18
-3
.env.example
···
48
48
# Optional: rotation key for PLC operations (defaults to user's key)
49
49
# PLC_ROTATION_KEY=did:key:...
50
50
# =============================================================================
51
-
# Federation
51
+
# AppView Federation
52
52
# =============================================================================
53
-
# Appview URL for proxying app.bsky.* requests
54
-
# APPVIEW_URL=https://api.bsky.app
53
+
# AppViews are resolved via DID-based discovery. Configure by mapping lexicon
54
+
# namespaces to AppView DIDs. The DID document is fetched and the service
55
+
# endpoint is extracted automatically.
56
+
#
57
+
# Format: APPVIEW_DID_<NAMESPACE>=<did>
58
+
# Where <NAMESPACE> uses underscores instead of dots (e.g., APP_BSKY for app.bsky)
59
+
#
60
+
# Default: app.bsky and com.atproto -> did:web:api.bsky.app
61
+
#
62
+
# Examples:
63
+
# APPVIEW_DID_APP_BSKY=did:web:api.bsky.app
64
+
# APPVIEW_DID_COM_WHTWND=did:web:whtwnd.com
65
+
# APPVIEW_DID_BLUE_ZIO=did:plc:some-custom-appview
66
+
#
67
+
# Cache TTL for resolved AppView endpoints (default: 300 seconds)
68
+
# APPVIEW_CACHE_TTL_SECS=300
69
+
#
55
70
# Comma-separated list of relay URLs to notify via requestCrawl
56
71
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
57
72
# =============================================================================
+11
-5
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
+11
-5
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n handle, email, email_confirmed,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
3
+
"query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
20
20
},
21
21
{
22
22
"ordinal": 3,
23
+
"name": "is_admin",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
23
28
"name": "preferred_channel: crate::notifications::NotificationChannel",
24
29
"type_info": {
25
30
"Custom": {
···
36
41
}
37
42
},
38
43
{
39
-
"ordinal": 4,
44
+
"ordinal": 5,
40
45
"name": "discord_verified",
41
46
"type_info": "Bool"
42
47
},
43
48
{
44
-
"ordinal": 5,
49
+
"ordinal": 6,
45
50
"name": "telegram_verified",
46
51
"type_info": "Bool"
47
52
},
48
53
{
49
-
"ordinal": 6,
54
+
"ordinal": 7,
50
55
"name": "signal_verified",
51
56
"type_info": "Bool"
52
57
}
···
63
68
false,
64
69
false,
65
70
false,
71
+
false,
66
72
false
67
73
]
68
74
},
69
-
"hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b"
75
+
"hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1"
70
76
}
+82
-284
TODO.md
+82
-284
TODO.md
···
1
-
# PDS Implementation TODOs
2
-
Lewis' corrected big boy todofile
3
-
## Server Infrastructure & Proxying
4
-
- [x] Health Check
5
-
- [x] Implement `GET /health` endpoint (returns "OK").
6
-
- [x] Implement `GET /xrpc/_health` endpoint (returns "OK").
7
-
- [x] Server Description
8
-
- [x] Implement `com.atproto.server.describeServer` (returns available user domains).
9
-
- [x] XRPC Proxying
10
-
- [x] Implement strict forwarding for all `app.bsky.*` and `chat.bsky.*` requests to an appview.
11
-
- [x] Forward auth headers correctly.
12
-
- [x] Handle appview errors/timeouts gracefully.
13
-
## Authentication & Account Management (`com.atproto.server`)
14
-
- [x] Account Creation
15
-
- [x] Implement `com.atproto.server.createAccount`.
16
-
- [x] Validate handle format (reject invalid characters).
17
-
- [x] Create DID for new user (PLC directory).
18
-
- [x] Initialize user repository (Root commit).
19
-
- [x] Return access JWT and DID.
20
-
- [x] Create DID for new user (did:web).
21
-
- [x] Session Management
22
-
- [x] Implement `com.atproto.server.createSession` (Login).
23
-
- [x] Implement `com.atproto.server.getSession`.
24
-
- [x] Implement `com.atproto.server.refreshSession`.
25
-
- [x] Implement `com.atproto.server.deleteSession` (Logout).
26
-
- [x] Implement `com.atproto.server.activateAccount`.
27
-
- [x] Implement `com.atproto.server.checkAccountStatus`.
28
-
- [x] Implement `com.atproto.server.createAppPassword`.
29
-
- [x] Implement `com.atproto.server.createInviteCode`.
30
-
- [x] Implement `com.atproto.server.createInviteCodes`.
31
-
- [x] Implement `com.atproto.server.deactivateAccount`.
32
-
- [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token).
33
-
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
34
-
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
35
-
- [x] Implement `com.atproto.server.listAppPasswords`.
36
-
- [x] Implement `com.atproto.server.requestAccountDelete`.
37
-
- [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
38
-
- [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
39
-
- [x] Implement `com.atproto.server.reserveSigningKey`.
40
-
- [x] Implement `com.atproto.server.revokeAppPassword`.
41
-
- [x] Implement `com.atproto.server.updateEmail`.
42
-
- [x] Implement `com.atproto.server.confirmEmail`.
43
-
## Repository Operations (`com.atproto.repo`)
44
-
- [x] Record CRUD
45
-
- [x] Implement `com.atproto.repo.createRecord`.
46
-
- [x] Generate `rkey` (TID) if not provided.
47
-
- [x] Handle MST (Merkle Search Tree) insertion.
48
-
- [x] **Trigger Firehose Event**.
49
-
- [x] Implement `com.atproto.repo.putRecord`.
50
-
- [x] Implement `com.atproto.repo.getRecord`.
51
-
- [x] Implement `com.atproto.repo.deleteRecord`.
52
-
- [x] Implement `com.atproto.repo.listRecords`.
53
-
- [x] Implement `com.atproto.repo.describeRepo`.
54
-
- [x] Implement `com.atproto.repo.applyWrites` (Batch writes).
55
-
- [x] Implement `com.atproto.repo.importRepo` (Migration).
56
-
- [x] Implement `com.atproto.repo.listMissingBlobs`.
57
-
- [x] Blob Management
58
-
- [x] Implement `com.atproto.repo.uploadBlob`.
59
-
- [x] Store blob (S3).
60
-
- [x] return `blob` ref (CID + MimeType).
61
-
## Sync & Federation (`com.atproto.sync`)
62
-
- [x] The Firehose (WebSocket)
63
-
- [x] Implement `com.atproto.sync.subscribeRepos`.
64
-
- [x] Broadcast real-time commit events.
65
-
- [x] Handle cursor replay (backfill).
66
-
- [x] Bulk Export
67
-
- [x] Implement `com.atproto.sync.getRepo` (Return full CAR file of repo).
68
-
- [x] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs).
69
-
- [x] Implement `com.atproto.sync.getLatestCommit`.
70
-
- [x] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord).
71
-
- [x] Implement `com.atproto.sync.getRepoStatus`.
72
-
- [x] Implement `com.atproto.sync.listRepos`.
73
-
- [x] Implement `com.atproto.sync.notifyOfUpdate`.
74
-
- [x] Blob Sync
75
-
- [x] Implement `com.atproto.sync.getBlob`.
76
-
- [x] Implement `com.atproto.sync.listBlobs`.
77
-
- [x] Crawler Interaction
78
-
- [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us).
79
-
- [x] Deprecated Sync Endpoints (for compatibility)
80
-
- [x] Implement `com.atproto.sync.getCheckout` (deprecated).
81
-
- [x] Implement `com.atproto.sync.getHead` (deprecated).
82
-
## Identity (`com.atproto.identity`)
83
-
- [x] Resolution
84
-
- [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC).
85
-
- [x] Implement `com.atproto.identity.updateHandle`.
86
-
- [x] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`.
87
-
- [x] Implement `com.atproto.identity.getRecommendedDidCredentials`.
88
-
- [x] Implement `/.well-known/did.json` (Depends on supporting did:web).
89
-
## Admin Management (`com.atproto.admin`)
90
-
- [x] Implement `com.atproto.admin.deleteAccount`.
91
-
- [x] Implement `com.atproto.admin.disableAccountInvites`.
92
-
- [x] Implement `com.atproto.admin.disableInviteCodes`.
93
-
- [x] Implement `com.atproto.admin.enableAccountInvites`.
94
-
- [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`.
95
-
- [x] Implement `com.atproto.admin.getInviteCodes`.
96
-
- [x] Implement `com.atproto.admin.getSubjectStatus`.
97
-
- [x] Implement `com.atproto.admin.sendEmail`.
98
-
- [x] Implement `com.atproto.admin.updateAccountEmail`.
99
-
- [x] Implement `com.atproto.admin.updateAccountHandle`.
100
-
- [x] Implement `com.atproto.admin.updateAccountPassword`.
101
-
- [x] Implement `com.atproto.admin.updateSubjectStatus`.
102
-
## Moderation (`com.atproto.moderation`)
103
-
- [x] Implement `com.atproto.moderation.createReport`.
104
-
## Temp Namespace (`com.atproto.temp`)
105
-
- [x] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups).
106
-
## Misc HTTP Endpoints
107
-
- [x] Implement `/robots.txt` endpoint.
108
-
## OAuth 2.1 Support
109
-
Full OAuth 2.1 provider for ATProto native app authentication.
110
-
- [x] OAuth Provider Core
111
-
- [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint.
112
-
- [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint.
113
-
- [x] Implement `/oauth/authorize` authorization endpoint (with login UI).
114
-
- [x] Implement `/oauth/par` Pushed Authorization Request endpoint.
115
-
- [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants).
116
-
- [x] Implement `/oauth/jwks` JSON Web Key Set endpoint.
117
-
- [x] Implement `/oauth/revoke` token revocation endpoint.
118
-
- [x] Implement `/oauth/introspect` token introspection endpoint.
119
-
- [x] OAuth Database Tables
120
-
- [x] Device table for tracking authorized devices.
121
-
- [x] Authorization request table.
122
-
- [x] Authorized client table.
123
-
- [x] Token table for OAuth tokens.
124
-
- [x] Used refresh token table (replay protection).
125
-
- [x] DPoP JTI tracking table.
126
-
- [x] DPoP (Demonstrating Proof-of-Possession) support.
127
-
- [x] Client metadata fetching and validation.
128
-
- [x] PKCE (S256) enforcement.
129
-
- [x] OAuth token verification extractor for protected resources.
130
-
- [x] Authorization UI templates (HTML login form).
131
-
- [x] Implement `private_key_jwt` signature verification with async JWKS fetching.
132
-
- [x] HS256 JWT support (matches reference PDS).
133
-
## OAuth Security Notes
134
-
Security measures implemented:
135
-
- Constant-time comparison for signature verification (prevents timing attacks)
136
-
- HMAC-SHA256 for access token signing with configurable secret
137
-
- Production secrets require 32+ character minimum
138
-
- DPoP JTI replay protection via database
139
-
- DPoP nonce validation with HMAC-based timestamps (5 min validity)
140
-
- Refresh token rotation with reuse detection (revokes token family on reuse)
141
-
- PKCE S256 enforced (plain not allowed)
142
-
- Authorization code single-use enforcement
143
-
- URL encoding for redirect parameters (prevents injection)
144
-
- All database queries use parameterized statements (no SQL injection)
145
-
- Deactivated/taken-down accounts blocked from OAuth authorization
146
-
- Client ID validation on token exchange (defense-in-depth against cross-client attacks)
147
-
- HTML escaping in OAuth templates (XSS prevention)
148
-
### Auth Notes
149
-
- Dual algorithm support: ES256K (secp256k1 ECDSA) with per-user keys AND HS256 (HMAC) for compatibility with reference PDS.
150
-
- Token storage: Storing only token JTIs in session_tokens table (defense in depth against DB breaches). Refresh token family tracking enables detection of token reuse attacks.
151
-
- Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from KEY_ENCRYPTION_KEY environment variable.
152
-
## PDS-Level App Endpoints
153
-
These endpoints need to be implemented at the PDS level (not just proxied to appview).
154
-
### Actor (`app.bsky.actor`)
155
-
- [x] Implement `app.bsky.actor.getPreferences` (user preferences storage).
156
-
- [x] Implement `app.bsky.actor.putPreferences` (update user preferences).
157
-
- [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback).
158
-
- [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback).
159
-
### Feed (`app.bsky.feed`)
160
-
These are implemented at PDS level to enable local-first reads (read-after-write pattern):
161
-
- [x] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy + RAW).
162
-
- [x] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy + RAW).
163
-
- [x] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy + RAW).
164
-
- [x] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy + RAW + NotFound handling).
165
-
- [x] Implement `app.bsky.feed.getFeed` (proxy to feed generator).
166
-
### Notification (`app.bsky.notification`)
167
-
- [x] Implement `app.bsky.notification.registerPush` (push notification registration, proxied).
168
-
## Infrastructure & Core Components
169
-
- [x] Sequencer (Event Log)
170
-
- [x] Implement a `Sequencer` (backed by `repo_seq` table).
171
-
- [x] Implement event formatting (`commit`, `handle`, `identity`, `account`).
172
-
- [x] Implement database polling / event emission mechanism.
173
-
- [x] Implement cursor-based event replay (`requestSeqRange`).
174
-
- [x] Repo Storage & Consistency (in postgres)
175
-
- [x] Implement `RepoStorage` for postgres (replaces per-user SQLite).
176
-
- [x] Read/Write IPLD blocks to `blocks` table (global deduplication).
177
-
- [x] Manage Repo Root in `repos` table.
178
-
- [x] Implement Atomic Repo Transactions.
179
-
- [x] Ensure `blocks` write, `repo_root` update, `records` index update, and `sequencer` event are committed in a single transaction.
180
-
- [x] Implement concurrency control (row-level locking via FOR UPDATE).
181
-
- [x] DID Cache
182
-
- [x] Implement caching layer for DID resolution (valkey).
183
-
- [x] Handle cache invalidation/expiry.
184
-
- [x] Graceful fallback to no-cache when valkey unavailable.
185
-
- [x] Crawlers Service
186
-
- [x] Implement `Crawlers` service (debounce notifications to relays).
187
-
- [x] 20-minute notification debounce.
188
-
- [x] Circuit breaker for relay failures.
189
-
- [x] Notification Service
190
-
- [x] Queue-based notification system with database table
191
-
- [x] Background worker polling for pending notifications
192
-
- [x] Extensible sender trait for multiple channels
193
-
- [x] Email sender via OS sendmail/msmtp
194
-
- [x] Discord webhook sender
195
-
- [x] Telegram bot sender
196
-
- [x] Signal CLI sender
197
-
- [x] Helper functions for common notification types (welcome, password reset, email verification, etc.)
198
-
- [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications
199
-
- [x] Image Processing
200
-
- [x] Implement image resize/formatting pipeline (for blob uploads).
201
-
- [x] WebP conversion for thumbnails.
202
-
- [x] EXIF stripping.
203
-
- [x] File size limits (10MB default).
204
-
- [x] IPLD & MST
205
-
- [x] Implement Merkle Search Tree logic for repo signing.
206
-
- [x] Implement CAR (Content Addressable Archive) encoding/decoding.
207
-
- [x] Cycle detection in CAR export.
208
-
- [x] Rate Limiting
209
-
- [x] Per-IP rate limiting on login (10/min).
210
-
- [x] Per-IP rate limiting on OAuth token endpoint (30/min).
211
-
- [x] Per-IP rate limiting on password reset (5/hour).
212
-
- [x] Per-IP rate limiting on account creation (10/hour).
213
-
- [x] Per-IP rate limiting on refreshSession (60/min).
214
-
- [x] Per-IP rate limiting on OAuth authorize POST (10/min).
215
-
- [x] Per-IP rate limiting on OAuth 2FA POST (10/min).
216
-
- [x] Per-IP rate limiting on OAuth PAR (30/min).
217
-
- [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
218
-
- [x] Per-IP rate limiting on createAppPassword (10/min).
219
-
- [x] Per-IP rate limiting on email endpoints (5/hour).
220
-
- [x] Distributed rate limiting via valkey (with in-memory fallback).
221
-
- [x] Circuit Breakers
222
-
- [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
223
-
- [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
224
-
- [x] Security Hardening
225
-
- [x] Email header injection prevention (CRLF sanitization).
226
-
- [x] Signal command injection prevention (phone number validation).
227
-
- [x] Constant-time signature comparison.
228
-
- [x] SSRF protection for outbound requests.
229
-
- [x] Timing attack protection (dummy bcrypt on user-not-found prevents account enumeration).
230
-
## Lewis' fabulous mini-list of remaining TODOs
231
-
- [x] The OAuth authorize POST endpoint has no rate limiting, allowing password brute-forcing. Fix this and audit all oauth and 2fa surface again.
232
-
- [x] DID resolution caching (valkey).
233
-
- [x] Record schema validation (generic validation framework).
234
-
- [x] Fix any remaining TODOs in the code.
235
-
## Future: Web Management UI
236
-
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.
237
-
### Architecture
238
-
- [x] Static SPA served from PDS (or separate static host)
239
-
- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
240
-
- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
241
-
- [x] No server-side sessions or CSRF - pure API client
242
-
### PDS-Specific XRPC Endpoints (new)
243
-
Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
244
-
Anyway... endpoints for PDS settings not covered by standard ATProto:
245
-
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
246
-
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
247
-
- [x] `com.bspds.account.getNotificationHistory` - list past notifications
248
-
- [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
249
-
- [x] `com.bspds.account.confirmChannelVerification` - confirm with code
250
-
- [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc.
251
-
### Frontend Views
252
-
Uses existing ATProto endpoints where possible:
253
-
Authentication
254
-
- [x] Login page (uses `com.atproto.server.createSession`)
255
-
- [x] Registration page (uses `com.atproto.server.createAccount`)
256
-
- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
257
-
- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
258
-
User Dashboard
259
-
- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
260
-
- [ ] Active sessions view (needs new endpoint or extend existing)
261
-
- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
262
-
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
263
-
Notification Preferences
264
-
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
265
-
- [x] Verification flows for Discord/Telegram/Signal
266
-
- [ ] Notification history view
267
-
Account Settings
268
-
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
269
-
- [ ] Password change while logged in (needs new endpoint - change password with current password)
270
-
- [x] Handle change (uses `com.atproto.identity.updateHandle`)
271
-
- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
272
-
Data Management
273
-
- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
274
-
- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
275
-
Admin Dashboard (privileged users only)
276
-
- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
277
-
- [ ] User detail/actions (uses `com.atproto.admin.*` endpoints)
278
-
- [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`)
279
-
- [ ] Server stats (uses `com.bspds.admin.getServerStats`)
280
-
## Future: private data
281
-
I will see where the discourse about encrypted/privileged private data is at the current moment, and make an implementation that matches what the bsky team will likely do in their pds whenever they get around to it.
282
-
Then when they come out with theirs, I can make adjustments to mine and be ready on day 1. Or 2.
283
-
We want records that only authorized parties can see and decrypt. This requires some sort of federation of keys and communication between PDSes?
284
-
Gotta figure all of this out as a first step.
1
+
# Lewis' Big Boy TODO list
2
+
3
+
## Active development
4
+
5
+
### OAuth scope authorization UI
6
+
Display and manage OAuth scopes during authorization flows.
7
+
8
+
- [ ] Parse and display requested scopes from authorization request
9
+
- [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read")
10
+
- [ ] Group scopes by category (read, write, admin, etc.)
11
+
- [ ] Allow users to uncheck optional scopes before authorizing
12
+
- [ ] Distinguish required vs optional scopes in UI
13
+
- [ ] Remember scope preferences per client (don't ask again for same scopes)
14
+
- [ ] Token endpoint respects user's scope selections
15
+
- [ ] Protected endpoints check token scopes before allowing operations
16
+
17
+
### Frontend
18
+
So like... make the thing unique, make it cool.
19
+
20
+
- [ ] Frontpage that explains what this thing is
21
+
- [ ] Unique "brand" style both unauthed and authed
22
+
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
23
+
24
+
### Delegated accounts
25
+
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
26
+
27
+
- [ ] Account type flag in actors table (personal | delegated)
28
+
- [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at)
29
+
- [ ] Detect delegated account during authorize flow
30
+
- [ ] Redirect to "authenticate as controller" instead of password prompt
31
+
- [ ] Validate controller has delegation grant for this account
32
+
- [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes)
33
+
- [ ] Token includes act_as claim indicating delegation
34
+
- [ ] Define standard scope sets (owner, admin, editor, viewer)
35
+
- [ ] Create delegated account flow (no password, must add initial controller)
36
+
- [ ] Controller management page (add/remove controllers, modify scopes)
37
+
- [ ] "Act as" account switcher for users with delegation grants
38
+
- [ ] Log all actions with both actor DID and controller DID
39
+
- [ ] Audit log view for delegated account owners
40
+
41
+
### Passkey support
42
+
Modern passwordless authentication using WebAuthn/FIDO2, alongside or instead of passwords.
43
+
44
+
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
45
+
- [ ] Generate WebAuthn registration challenge
46
+
- [ ] Verify attestation response and store credential
47
+
- [ ] UI for registering new passkey from settings
48
+
- [ ] Detect if account has passkeys during OAuth authorize
49
+
- [ ] Offer passkey option alongside password
50
+
- [ ] Generate authentication challenge and verify assertion
51
+
- [ ] Update sign count (replay protection)
52
+
- [ ] Allow creating account with passkey instead of password
53
+
- [ ] List/rename/remove passkeys in settings
54
+
55
+
### Private/encrypted data
56
+
Records that only authorized parties can see and decrypt. Requires key federation between PDSes.
57
+
58
+
- [ ] Survey current ATProto discourse on private data
59
+
- [ ] Document Bluesky team's likely approach
60
+
- [ ] Design key management strategy
61
+
- [ ] Per-user encryption keys (separate from signing keys)
62
+
- [ ] Key derivation for per-record or per-collection encryption
63
+
- [ ] Encrypted record storage format
64
+
- [ ] Transparent encryption/decryption in repo operations
65
+
- [ ] Protocol for sharing decryption keys between PDSes
66
+
- [ ] Handle key rotation and revocation
67
+
68
+
---
69
+
70
+
## Completed
71
+
72
+
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports.
73
+
74
+
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
75
+
76
+
App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback).
77
+
78
+
Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening.
79
+
80
+
Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel.
81
+
82
+
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
+9
frontend/src/App.svelte
+9
frontend/src/App.svelte
···
3
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
4
import Login from './routes/Login.svelte'
5
5
import Register from './routes/Register.svelte'
6
+
import ResetPassword from './routes/ResetPassword.svelte'
6
7
import Dashboard from './routes/Dashboard.svelte'
7
8
import AppPasswords from './routes/AppPasswords.svelte'
8
9
import InviteCodes from './routes/InviteCodes.svelte'
9
10
import Settings from './routes/Settings.svelte'
11
+
import Sessions from './routes/Sessions.svelte'
10
12
import Notifications from './routes/Notifications.svelte'
11
13
import RepoExplorer from './routes/RepoExplorer.svelte'
14
+
import Admin from './routes/Admin.svelte'
12
15
13
16
const auth = getAuthState()
14
17
···
22
25
return Login
23
26
case '/register':
24
27
return Register
28
+
case '/reset-password':
29
+
return ResetPassword
25
30
case '/dashboard':
26
31
return Dashboard
27
32
case '/app-passwords':
···
30
35
return InviteCodes
31
36
case '/settings':
32
37
return Settings
38
+
case '/sessions':
39
+
return Sessions
33
40
case '/notifications':
34
41
return Notifications
35
42
case '/repo':
36
43
return RepoExplorer
44
+
case '/admin':
45
+
return Admin
37
46
default:
38
47
return auth.session ? Dashboard : Login
39
48
}
+147
frontend/src/lib/api.ts
+147
frontend/src/lib/api.ts
···
47
47
emailConfirmed?: boolean
48
48
preferredChannel?: string
49
49
preferredChannelVerified?: boolean
50
+
isAdmin?: boolean
50
51
accessJwt: string
51
52
refreshJwt: string
52
53
}
···
267
268
method: 'POST',
268
269
token,
269
270
body: prefs,
271
+
})
272
+
},
273
+
274
+
async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> {
275
+
return xrpc('com.bspds.account.confirmChannelVerification', {
276
+
method: 'POST',
277
+
token,
278
+
body: { channel, code },
279
+
})
280
+
},
281
+
282
+
async getNotificationHistory(token: string): Promise<{
283
+
notifications: Array<{
284
+
createdAt: string
285
+
channel: string
286
+
notificationType: string
287
+
status: string
288
+
subject: string | null
289
+
body: string
290
+
}>
291
+
}> {
292
+
return xrpc('com.bspds.account.getNotificationHistory', { token })
293
+
},
294
+
295
+
async getServerStats(token: string): Promise<{
296
+
userCount: number
297
+
repoCount: number
298
+
recordCount: number
299
+
blobStorageBytes: number
300
+
}> {
301
+
return xrpc('com.bspds.admin.getServerStats', { token })
302
+
},
303
+
304
+
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
305
+
await xrpc('com.bspds.account.changePassword', {
306
+
method: 'POST',
307
+
token,
308
+
body: { currentPassword, newPassword },
309
+
})
310
+
},
311
+
312
+
async listSessions(token: string): Promise<{
313
+
sessions: Array<{
314
+
id: string
315
+
createdAt: string
316
+
expiresAt: string
317
+
isCurrent: boolean
318
+
}>
319
+
}> {
320
+
return xrpc('com.bspds.account.listSessions', { token })
321
+
},
322
+
323
+
async revokeSession(token: string, sessionId: string): Promise<void> {
324
+
await xrpc('com.bspds.account.revokeSession', {
325
+
method: 'POST',
326
+
token,
327
+
body: { sessionId },
328
+
})
329
+
},
330
+
331
+
async searchAccounts(token: string, options?: {
332
+
handle?: string
333
+
cursor?: string
334
+
limit?: number
335
+
}): Promise<{
336
+
cursor?: string
337
+
accounts: Array<{
338
+
did: string
339
+
handle: string
340
+
email?: string
341
+
indexedAt: string
342
+
emailConfirmedAt?: string
343
+
deactivatedAt?: string
344
+
}>
345
+
}> {
346
+
const params: Record<string, string> = {}
347
+
if (options?.handle) params.handle = options.handle
348
+
if (options?.cursor) params.cursor = options.cursor
349
+
if (options?.limit) params.limit = String(options.limit)
350
+
return xrpc('com.atproto.admin.searchAccounts', { token, params })
351
+
},
352
+
353
+
async getInviteCodes(token: string, options?: {
354
+
sort?: 'recent' | 'usage'
355
+
cursor?: string
356
+
limit?: number
357
+
}): Promise<{
358
+
cursor?: string
359
+
codes: Array<{
360
+
code: string
361
+
available: number
362
+
disabled: boolean
363
+
forAccount: string
364
+
createdBy: string
365
+
createdAt: string
366
+
uses: Array<{ usedBy: string; usedAt: string }>
367
+
}>
368
+
}> {
369
+
const params: Record<string, string> = {}
370
+
if (options?.sort) params.sort = options.sort
371
+
if (options?.cursor) params.cursor = options.cursor
372
+
if (options?.limit) params.limit = String(options.limit)
373
+
return xrpc('com.atproto.admin.getInviteCodes', { token, params })
374
+
},
375
+
376
+
async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> {
377
+
await xrpc('com.atproto.admin.disableInviteCodes', {
378
+
method: 'POST',
379
+
token,
380
+
body: { codes, accounts },
381
+
})
382
+
},
383
+
384
+
async getAccountInfo(token: string, did: string): Promise<{
385
+
did: string
386
+
handle: string
387
+
email?: string
388
+
indexedAt: string
389
+
emailConfirmedAt?: string
390
+
invitesDisabled?: boolean
391
+
deactivatedAt?: string
392
+
}> {
393
+
return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } })
394
+
},
395
+
396
+
async disableAccountInvites(token: string, account: string): Promise<void> {
397
+
await xrpc('com.atproto.admin.disableAccountInvites', {
398
+
method: 'POST',
399
+
token,
400
+
body: { account },
401
+
})
402
+
},
403
+
404
+
async enableAccountInvites(token: string, account: string): Promise<void> {
405
+
await xrpc('com.atproto.admin.enableAccountInvites', {
406
+
method: 'POST',
407
+
token,
408
+
body: { account },
409
+
})
410
+
},
411
+
412
+
async adminDeleteAccount(token: string, did: string): Promise<void> {
413
+
await xrpc('com.atproto.admin.deleteAccount', {
414
+
method: 'POST',
415
+
token,
416
+
body: { did },
270
417
})
271
418
},
272
419
+148
-4
frontend/src/lib/auth.svelte.ts
+148
-4
frontend/src/lib/auth.svelte.ts
···
1
1
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
2
+
import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth'
2
3
3
4
const STORAGE_KEY = 'bspds_session'
5
+
const ACCOUNTS_KEY = 'bspds_accounts'
6
+
7
+
export interface SavedAccount {
8
+
did: string
9
+
handle: string
10
+
accessJwt: string
11
+
refreshJwt: string
12
+
}
4
13
5
14
interface AuthState {
6
15
session: Session | null
7
16
loading: boolean
8
17
error: string | null
18
+
savedAccounts: SavedAccount[]
9
19
}
10
20
11
21
let state = $state<AuthState>({
12
22
session: null,
13
23
loading: true,
14
24
error: null,
25
+
savedAccounts: [],
15
26
})
16
27
17
28
function saveSession(session: Session | null) {
···
34
45
return null
35
46
}
36
47
48
+
function loadSavedAccounts(): SavedAccount[] {
49
+
const stored = localStorage.getItem(ACCOUNTS_KEY)
50
+
if (stored) {
51
+
try {
52
+
return JSON.parse(stored)
53
+
} catch {
54
+
return []
55
+
}
56
+
}
57
+
return []
58
+
}
59
+
60
+
function saveSavedAccounts(accounts: SavedAccount[]) {
61
+
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts))
62
+
}
63
+
64
+
function addOrUpdateSavedAccount(session: Session) {
65
+
const accounts = loadSavedAccounts()
66
+
const existing = accounts.findIndex(a => a.did === session.did)
67
+
const savedAccount: SavedAccount = {
68
+
did: session.did,
69
+
handle: session.handle,
70
+
accessJwt: session.accessJwt,
71
+
refreshJwt: session.refreshJwt,
72
+
}
73
+
if (existing >= 0) {
74
+
accounts[existing] = savedAccount
75
+
} else {
76
+
accounts.push(savedAccount)
77
+
}
78
+
saveSavedAccounts(accounts)
79
+
state.savedAccounts = accounts
80
+
}
81
+
82
+
function removeSavedAccount(did: string) {
83
+
const accounts = loadSavedAccounts().filter(a => a.did !== did)
84
+
saveSavedAccounts(accounts)
85
+
state.savedAccounts = accounts
86
+
}
87
+
37
88
export async function initAuth() {
38
89
state.loading = true
39
90
state.error = null
91
+
state.savedAccounts = loadSavedAccounts()
92
+
93
+
const oauthCallback = checkForOAuthCallback()
94
+
if (oauthCallback) {
95
+
clearOAuthCallbackParams()
96
+
try {
97
+
const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state)
98
+
const sessionInfo = await api.getSession(tokens.access_token)
99
+
const session: Session = {
100
+
...sessionInfo,
101
+
accessJwt: tokens.access_token,
102
+
refreshJwt: tokens.refresh_token || '',
103
+
}
104
+
state.session = session
105
+
saveSession(session)
106
+
addOrUpdateSavedAccount(session)
107
+
state.loading = false
108
+
return
109
+
} catch (e) {
110
+
state.error = e instanceof Error ? e.message : 'OAuth login failed'
111
+
state.loading = false
112
+
return
113
+
}
114
+
}
115
+
40
116
const stored = loadSession()
41
117
if (stored) {
42
118
try {
43
119
const session = await api.getSession(stored.accessJwt)
44
120
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
121
+
addOrUpdateSavedAccount(state.session)
45
122
} catch (e) {
46
123
if (e instanceof ApiError && e.status === 401) {
47
124
try {
48
-
const refreshed = await api.refreshSession(stored.refreshJwt)
49
-
state.session = refreshed
50
-
saveSession(refreshed)
125
+
const tokens = await refreshOAuthToken(stored.refreshJwt)
126
+
const sessionInfo = await api.getSession(tokens.access_token)
127
+
const session: Session = {
128
+
...sessionInfo,
129
+
accessJwt: tokens.access_token,
130
+
refreshJwt: tokens.refresh_token || stored.refreshJwt,
131
+
}
132
+
state.session = session
133
+
saveSession(session)
134
+
addOrUpdateSavedAccount(session)
51
135
} catch {
52
136
saveSession(null)
53
137
state.session = null
···
68
152
const session = await api.createSession(identifier, password)
69
153
state.session = session
70
154
saveSession(session)
155
+
addOrUpdateSavedAccount(session)
71
156
} catch (e) {
72
157
if (e instanceof ApiError) {
73
158
state.error = e.message
···
80
165
}
81
166
}
82
167
168
+
export async function loginWithOAuth(): Promise<void> {
169
+
state.loading = true
170
+
state.error = null
171
+
try {
172
+
await startOAuthLogin()
173
+
} catch (e) {
174
+
state.loading = false
175
+
state.error = e instanceof Error ? e.message : 'Failed to start OAuth login'
176
+
throw e
177
+
}
178
+
}
179
+
83
180
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
84
181
try {
85
182
const result = await api.createAccount(params)
···
111
208
}
112
209
state.session = session
113
210
saveSession(session)
211
+
addOrUpdateSavedAccount(session)
114
212
} catch (e) {
115
213
if (e instanceof ApiError) {
116
214
state.error = e.message
···
146
244
saveSession(null)
147
245
}
148
246
247
+
export async function switchAccount(did: string): Promise<void> {
248
+
const account = state.savedAccounts.find(a => a.did === did)
249
+
if (!account) {
250
+
throw new Error('Account not found')
251
+
}
252
+
state.loading = true
253
+
state.error = null
254
+
try {
255
+
const session = await api.getSession(account.accessJwt)
256
+
state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt }
257
+
saveSession(state.session)
258
+
addOrUpdateSavedAccount(state.session)
259
+
} catch (e) {
260
+
if (e instanceof ApiError && e.status === 401) {
261
+
try {
262
+
const tokens = await refreshOAuthToken(account.refreshJwt)
263
+
const sessionInfo = await api.getSession(tokens.access_token)
264
+
const session: Session = {
265
+
...sessionInfo,
266
+
accessJwt: tokens.access_token,
267
+
refreshJwt: tokens.refresh_token || account.refreshJwt,
268
+
}
269
+
state.session = session
270
+
saveSession(session)
271
+
addOrUpdateSavedAccount(session)
272
+
} catch {
273
+
removeSavedAccount(did)
274
+
state.error = 'Session expired. Please log in again.'
275
+
throw new Error('Session expired')
276
+
}
277
+
} else {
278
+
state.error = 'Failed to switch account'
279
+
throw e
280
+
}
281
+
} finally {
282
+
state.loading = false
283
+
}
284
+
}
285
+
286
+
export function forgetAccount(did: string): void {
287
+
removeSavedAccount(did)
288
+
}
289
+
149
290
export function getAuthState() {
150
291
return state
151
292
}
···
158
299
return state.session !== null
159
300
}
160
301
161
-
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
302
+
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) {
162
303
state.session = newState.session
163
304
state.loading = newState.loading
164
305
state.error = newState.error
306
+
state.savedAccounts = newState.savedAccounts ?? []
165
307
}
166
308
167
309
export function _testReset() {
168
310
state.session = null
169
311
state.loading = true
170
312
state.error = null
313
+
state.savedAccounts = []
171
314
localStorage.removeItem(STORAGE_KEY)
315
+
localStorage.removeItem(ACCOUNTS_KEY)
172
316
}
+181
frontend/src/lib/oauth.ts
+181
frontend/src/lib/oauth.ts
···
1
+
const OAUTH_STATE_KEY = 'bspds_oauth_state'
2
+
const OAUTH_VERIFIER_KEY = 'bspds_oauth_verifier'
3
+
4
+
interface OAuthState {
5
+
state: string
6
+
codeVerifier: string
7
+
returnTo?: string
8
+
}
9
+
10
+
function generateRandomString(length: number): string {
11
+
const array = new Uint8Array(length)
12
+
crypto.getRandomValues(array)
13
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
14
+
}
15
+
16
+
async function sha256(plain: string): Promise<ArrayBuffer> {
17
+
const encoder = new TextEncoder()
18
+
const data = encoder.encode(plain)
19
+
return crypto.subtle.digest('SHA-256', data)
20
+
}
21
+
22
+
function base64UrlEncode(buffer: ArrayBuffer): string {
23
+
const bytes = new Uint8Array(buffer)
24
+
let binary = ''
25
+
for (const byte of bytes) {
26
+
binary += String.fromCharCode(byte)
27
+
}
28
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
29
+
}
30
+
31
+
async function generateCodeChallenge(verifier: string): Promise<string> {
32
+
const hash = await sha256(verifier)
33
+
return base64UrlEncode(hash)
34
+
}
35
+
36
+
function generateState(): string {
37
+
return generateRandomString(32)
38
+
}
39
+
40
+
function generateCodeVerifier(): string {
41
+
return generateRandomString(32)
42
+
}
43
+
44
+
function saveOAuthState(state: OAuthState): void {
45
+
sessionStorage.setItem(OAUTH_STATE_KEY, state.state)
46
+
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier)
47
+
}
48
+
49
+
function getOAuthState(): OAuthState | null {
50
+
const state = sessionStorage.getItem(OAUTH_STATE_KEY)
51
+
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY)
52
+
if (!state || !codeVerifier) return null
53
+
return { state, codeVerifier }
54
+
}
55
+
56
+
function clearOAuthState(): void {
57
+
sessionStorage.removeItem(OAUTH_STATE_KEY)
58
+
sessionStorage.removeItem(OAUTH_VERIFIER_KEY)
59
+
}
60
+
61
+
export async function startOAuthLogin(): Promise<void> {
62
+
const state = generateState()
63
+
const codeVerifier = generateCodeVerifier()
64
+
const codeChallenge = await generateCodeChallenge(codeVerifier)
65
+
66
+
saveOAuthState({ state, codeVerifier })
67
+
68
+
const clientId = `${window.location.origin}/oauth/client-metadata.json`
69
+
const redirectUri = `${window.location.origin}/`
70
+
71
+
const parResponse = await fetch('/oauth/par', {
72
+
method: 'POST',
73
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
74
+
body: new URLSearchParams({
75
+
client_id: clientId,
76
+
redirect_uri: redirectUri,
77
+
response_type: 'code',
78
+
scope: 'atproto transition:generic',
79
+
state: state,
80
+
code_challenge: codeChallenge,
81
+
code_challenge_method: 'S256',
82
+
}),
83
+
})
84
+
85
+
if (!parResponse.ok) {
86
+
const error = await parResponse.json().catch(() => ({ error: 'Unknown error' }))
87
+
throw new Error(error.error_description || error.error || 'Failed to start OAuth flow')
88
+
}
89
+
90
+
const { request_uri } = await parResponse.json()
91
+
92
+
const authorizeUrl = new URL('/oauth/authorize', window.location.origin)
93
+
authorizeUrl.searchParams.set('client_id', clientId)
94
+
authorizeUrl.searchParams.set('request_uri', request_uri)
95
+
96
+
window.location.href = authorizeUrl.toString()
97
+
}
98
+
99
+
export interface OAuthTokens {
100
+
access_token: string
101
+
refresh_token?: string
102
+
token_type: string
103
+
expires_in?: number
104
+
scope?: string
105
+
sub: string
106
+
}
107
+
108
+
export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> {
109
+
const savedState = getOAuthState()
110
+
if (!savedState) {
111
+
throw new Error('No OAuth state found. Please try logging in again.')
112
+
}
113
+
114
+
if (savedState.state !== state) {
115
+
clearOAuthState()
116
+
throw new Error('OAuth state mismatch. Please try logging in again.')
117
+
}
118
+
119
+
const clientId = `${window.location.origin}/oauth/client-metadata.json`
120
+
const redirectUri = `${window.location.origin}/`
121
+
122
+
const tokenResponse = await fetch('/oauth/token', {
123
+
method: 'POST',
124
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
125
+
body: new URLSearchParams({
126
+
grant_type: 'authorization_code',
127
+
client_id: clientId,
128
+
code: code,
129
+
redirect_uri: redirectUri,
130
+
code_verifier: savedState.codeVerifier,
131
+
}),
132
+
})
133
+
134
+
clearOAuthState()
135
+
136
+
if (!tokenResponse.ok) {
137
+
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
138
+
throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens')
139
+
}
140
+
141
+
return tokenResponse.json()
142
+
}
143
+
144
+
export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
145
+
const clientId = `${window.location.origin}/oauth/client-metadata.json`
146
+
147
+
const tokenResponse = await fetch('/oauth/token', {
148
+
method: 'POST',
149
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
150
+
body: new URLSearchParams({
151
+
grant_type: 'refresh_token',
152
+
client_id: clientId,
153
+
refresh_token: refreshToken,
154
+
}),
155
+
})
156
+
157
+
if (!tokenResponse.ok) {
158
+
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
159
+
throw new Error(error.error_description || error.error || 'Failed to refresh token')
160
+
}
161
+
162
+
return tokenResponse.json()
163
+
}
164
+
165
+
export function checkForOAuthCallback(): { code: string; state: string } | null {
166
+
const params = new URLSearchParams(window.location.search)
167
+
const code = params.get('code')
168
+
const state = params.get('state')
169
+
170
+
if (code && state) {
171
+
return { code, state }
172
+
}
173
+
174
+
return null
175
+
}
176
+
177
+
export function clearOAuthCallbackParams(): void {
178
+
const url = new URL(window.location.href)
179
+
url.search = ''
180
+
window.history.replaceState({}, '', url.toString())
181
+
}
+744
frontend/src/routes/Admin.svelte
+744
frontend/src/routes/Admin.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
const auth = getAuthState()
6
+
let loading = $state(true)
7
+
let error = $state<string | null>(null)
8
+
let stats = $state<{
9
+
userCount: number
10
+
repoCount: number
11
+
recordCount: number
12
+
blobStorageBytes: number
13
+
} | null>(null)
14
+
let usersLoading = $state(false)
15
+
let usersError = $state<string | null>(null)
16
+
let users = $state<Array<{
17
+
did: string
18
+
handle: string
19
+
email?: string
20
+
indexedAt: string
21
+
emailConfirmedAt?: string
22
+
deactivatedAt?: string
23
+
}>>([])
24
+
let usersCursor = $state<string | undefined>(undefined)
25
+
let handleSearchQuery = $state('')
26
+
let showUsers = $state(false)
27
+
let invitesLoading = $state(false)
28
+
let invitesError = $state<string | null>(null)
29
+
let invites = $state<Array<{
30
+
code: string
31
+
available: number
32
+
disabled: boolean
33
+
forAccount: string
34
+
createdBy: string
35
+
createdAt: string
36
+
uses: Array<{ usedBy: string; usedAt: string }>
37
+
}>>([])
38
+
let invitesCursor = $state<string | undefined>(undefined)
39
+
let showInvites = $state(false)
40
+
let selectedUser = $state<{
41
+
did: string
42
+
handle: string
43
+
email?: string
44
+
indexedAt: string
45
+
emailConfirmedAt?: string
46
+
invitesDisabled?: boolean
47
+
deactivatedAt?: string
48
+
} | null>(null)
49
+
let userDetailLoading = $state(false)
50
+
let userActionLoading = $state(false)
51
+
$effect(() => {
52
+
if (!auth.loading && !auth.session) {
53
+
navigate('/login')
54
+
} else if (!auth.loading && auth.session && !auth.session.isAdmin) {
55
+
navigate('/dashboard')
56
+
}
57
+
})
58
+
$effect(() => {
59
+
if (auth.session?.isAdmin) {
60
+
loadStats()
61
+
}
62
+
})
63
+
async function loadStats() {
64
+
if (!auth.session) return
65
+
loading = true
66
+
error = null
67
+
try {
68
+
stats = await api.getServerStats(auth.session.accessJwt)
69
+
} catch (e) {
70
+
error = e instanceof ApiError ? e.message : 'Failed to load server stats'
71
+
} finally {
72
+
loading = false
73
+
}
74
+
}
75
+
async function loadUsers(reset = false) {
76
+
if (!auth.session) return
77
+
usersLoading = true
78
+
usersError = null
79
+
if (reset) {
80
+
users = []
81
+
usersCursor = undefined
82
+
}
83
+
try {
84
+
const result = await api.searchAccounts(auth.session.accessJwt, {
85
+
handle: handleSearchQuery || undefined,
86
+
cursor: reset ? undefined : usersCursor,
87
+
limit: 25,
88
+
})
89
+
users = reset ? result.accounts : [...users, ...result.accounts]
90
+
usersCursor = result.cursor
91
+
showUsers = true
92
+
} catch (e) {
93
+
usersError = e instanceof ApiError ? e.message : 'Failed to load users'
94
+
} finally {
95
+
usersLoading = false
96
+
}
97
+
}
98
+
function handleSearch(e: Event) {
99
+
e.preventDefault()
100
+
loadUsers(true)
101
+
}
102
+
async function loadInvites(reset = false) {
103
+
if (!auth.session) return
104
+
invitesLoading = true
105
+
invitesError = null
106
+
if (reset) {
107
+
invites = []
108
+
invitesCursor = undefined
109
+
}
110
+
try {
111
+
const result = await api.getInviteCodes(auth.session.accessJwt, {
112
+
cursor: reset ? undefined : invitesCursor,
113
+
limit: 25,
114
+
})
115
+
invites = reset ? result.codes : [...invites, ...result.codes]
116
+
invitesCursor = result.cursor
117
+
showInvites = true
118
+
} catch (e) {
119
+
invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
120
+
} finally {
121
+
invitesLoading = false
122
+
}
123
+
}
124
+
async function disableInvite(code: string) {
125
+
if (!auth.session) return
126
+
if (!confirm(`Disable invite code ${code}?`)) return
127
+
try {
128
+
await api.disableInviteCodes(auth.session.accessJwt, [code])
129
+
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
130
+
} catch (e) {
131
+
invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
132
+
}
133
+
}
134
+
async function selectUser(did: string) {
135
+
if (!auth.session) return
136
+
userDetailLoading = true
137
+
try {
138
+
selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
139
+
} catch (e) {
140
+
usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
141
+
} finally {
142
+
userDetailLoading = false
143
+
}
144
+
}
145
+
function closeUserDetail() {
146
+
selectedUser = null
147
+
}
148
+
async function toggleUserInvites() {
149
+
if (!auth.session || !selectedUser) return
150
+
userActionLoading = true
151
+
try {
152
+
if (selectedUser.invitesDisabled) {
153
+
await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
154
+
selectedUser = { ...selectedUser, invitesDisabled: false }
155
+
} else {
156
+
await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
157
+
selectedUser = { ...selectedUser, invitesDisabled: true }
158
+
}
159
+
} catch (e) {
160
+
usersError = e instanceof ApiError ? e.message : 'Failed to update user'
161
+
} finally {
162
+
userActionLoading = false
163
+
}
164
+
}
165
+
async function deleteUser() {
166
+
if (!auth.session || !selectedUser) return
167
+
if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return
168
+
userActionLoading = true
169
+
try {
170
+
await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
171
+
users = users.filter(u => u.did !== selectedUser!.did)
172
+
selectedUser = null
173
+
} catch (e) {
174
+
usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
175
+
} finally {
176
+
userActionLoading = false
177
+
}
178
+
}
179
+
function formatBytes(bytes: number): string {
180
+
if (bytes === 0) return '0 B'
181
+
const k = 1024
182
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
183
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
184
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
185
+
}
186
+
function formatNumber(num: number): string {
187
+
return num.toLocaleString()
188
+
}
189
+
</script>
190
+
{#if auth.session?.isAdmin}
191
+
<div class="page">
192
+
<header>
193
+
<a href="#/dashboard" class="back">← Dashboard</a>
194
+
<h1>Admin Panel</h1>
195
+
</header>
196
+
{#if loading}
197
+
<p class="loading">Loading...</p>
198
+
{:else}
199
+
{#if error}
200
+
<div class="message error">{error}</div>
201
+
{/if}
202
+
{#if stats}
203
+
<section>
204
+
<h2>Server Statistics</h2>
205
+
<div class="stats-grid">
206
+
<div class="stat-card">
207
+
<div class="stat-value">{formatNumber(stats.userCount)}</div>
208
+
<div class="stat-label">Users</div>
209
+
</div>
210
+
<div class="stat-card">
211
+
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
212
+
<div class="stat-label">Repositories</div>
213
+
</div>
214
+
<div class="stat-card">
215
+
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
216
+
<div class="stat-label">Records</div>
217
+
</div>
218
+
<div class="stat-card">
219
+
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
220
+
<div class="stat-label">Blob Storage</div>
221
+
</div>
222
+
</div>
223
+
<button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
224
+
</section>
225
+
{/if}
226
+
<section>
227
+
<h2>User Management</h2>
228
+
<form class="search-form" onsubmit={handleSearch}>
229
+
<input
230
+
type="text"
231
+
bind:value={handleSearchQuery}
232
+
placeholder="Search by handle (optional)"
233
+
disabled={usersLoading}
234
+
/>
235
+
<button type="submit" disabled={usersLoading}>
236
+
{usersLoading ? 'Loading...' : 'Search Users'}
237
+
</button>
238
+
</form>
239
+
{#if usersError}
240
+
<div class="message error">{usersError}</div>
241
+
{/if}
242
+
{#if showUsers}
243
+
<div class="user-list">
244
+
{#if users.length === 0}
245
+
<p class="no-results">No users found</p>
246
+
{:else}
247
+
<table>
248
+
<thead>
249
+
<tr>
250
+
<th>Handle</th>
251
+
<th>Email</th>
252
+
<th>Status</th>
253
+
<th>Created</th>
254
+
</tr>
255
+
</thead>
256
+
<tbody>
257
+
{#each users as user}
258
+
<tr class="clickable" onclick={() => selectUser(user.did)}>
259
+
<td class="handle">@{user.handle}</td>
260
+
<td class="email">{user.email || '-'}</td>
261
+
<td>
262
+
{#if user.deactivatedAt}
263
+
<span class="badge deactivated">Deactivated</span>
264
+
{:else if user.emailConfirmedAt}
265
+
<span class="badge verified">Verified</span>
266
+
{:else}
267
+
<span class="badge unverified">Unverified</span>
268
+
{/if}
269
+
</td>
270
+
<td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td>
271
+
</tr>
272
+
{/each}
273
+
</tbody>
274
+
</table>
275
+
{#if usersCursor}
276
+
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
277
+
{usersLoading ? 'Loading...' : 'Load More'}
278
+
</button>
279
+
{/if}
280
+
{/if}
281
+
</div>
282
+
{/if}
283
+
</section>
284
+
<section>
285
+
<h2>Invite Codes</h2>
286
+
<div class="section-actions">
287
+
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
288
+
{invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
289
+
</button>
290
+
</div>
291
+
{#if invitesError}
292
+
<div class="message error">{invitesError}</div>
293
+
{/if}
294
+
{#if showInvites}
295
+
<div class="invite-list">
296
+
{#if invites.length === 0}
297
+
<p class="no-results">No invite codes found</p>
298
+
{:else}
299
+
<table>
300
+
<thead>
301
+
<tr>
302
+
<th>Code</th>
303
+
<th>Available</th>
304
+
<th>Uses</th>
305
+
<th>Status</th>
306
+
<th>Created</th>
307
+
<th>Actions</th>
308
+
</tr>
309
+
</thead>
310
+
<tbody>
311
+
{#each invites as invite}
312
+
<tr class:disabled-row={invite.disabled}>
313
+
<td class="code">{invite.code}</td>
314
+
<td>{invite.available}</td>
315
+
<td>{invite.uses.length}</td>
316
+
<td>
317
+
{#if invite.disabled}
318
+
<span class="badge deactivated">Disabled</span>
319
+
{:else if invite.available === 0}
320
+
<span class="badge unverified">Exhausted</span>
321
+
{:else}
322
+
<span class="badge verified">Active</span>
323
+
{/if}
324
+
</td>
325
+
<td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td>
326
+
<td>
327
+
{#if !invite.disabled}
328
+
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
329
+
Disable
330
+
</button>
331
+
{:else}
332
+
<span class="muted">-</span>
333
+
{/if}
334
+
</td>
335
+
</tr>
336
+
{/each}
337
+
</tbody>
338
+
</table>
339
+
{#if invitesCursor}
340
+
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
341
+
{invitesLoading ? 'Loading...' : 'Load More'}
342
+
</button>
343
+
{/if}
344
+
{/if}
345
+
</div>
346
+
{/if}
347
+
</section>
348
+
{/if}
349
+
</div>
350
+
{#if selectedUser}
351
+
<div class="modal-overlay" onclick={closeUserDetail} role="presentation">
352
+
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
353
+
<div class="modal-header">
354
+
<h2>User Details</h2>
355
+
<button class="close-btn" onclick={closeUserDetail}>×</button>
356
+
</div>
357
+
{#if userDetailLoading}
358
+
<p class="loading">Loading...</p>
359
+
{:else}
360
+
<div class="modal-body">
361
+
<dl class="user-details">
362
+
<dt>Handle</dt>
363
+
<dd>@{selectedUser.handle}</dd>
364
+
<dt>DID</dt>
365
+
<dd class="mono">{selectedUser.did}</dd>
366
+
<dt>Email</dt>
367
+
<dd>{selectedUser.email || '-'}</dd>
368
+
<dt>Status</dt>
369
+
<dd>
370
+
{#if selectedUser.deactivatedAt}
371
+
<span class="badge deactivated">Deactivated</span>
372
+
{:else if selectedUser.emailConfirmedAt}
373
+
<span class="badge verified">Verified</span>
374
+
{:else}
375
+
<span class="badge unverified">Unverified</span>
376
+
{/if}
377
+
</dd>
378
+
<dt>Created</dt>
379
+
<dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd>
380
+
<dt>Invites</dt>
381
+
<dd>
382
+
{#if selectedUser.invitesDisabled}
383
+
<span class="badge deactivated">Disabled</span>
384
+
{:else}
385
+
<span class="badge verified">Enabled</span>
386
+
{/if}
387
+
</dd>
388
+
</dl>
389
+
<div class="modal-actions">
390
+
<button
391
+
class="action-btn"
392
+
onclick={toggleUserInvites}
393
+
disabled={userActionLoading}
394
+
>
395
+
{selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
396
+
</button>
397
+
<button
398
+
class="action-btn danger"
399
+
onclick={deleteUser}
400
+
disabled={userActionLoading}
401
+
>
402
+
Delete Account
403
+
</button>
404
+
</div>
405
+
</div>
406
+
{/if}
407
+
</div>
408
+
</div>
409
+
{/if}
410
+
{:else if auth.loading}
411
+
<div class="loading">Loading...</div>
412
+
{/if}
413
+
<style>
414
+
.page {
415
+
max-width: 800px;
416
+
margin: 0 auto;
417
+
padding: 2rem;
418
+
}
419
+
header {
420
+
margin-bottom: 2rem;
421
+
}
422
+
.back {
423
+
color: var(--text-secondary);
424
+
text-decoration: none;
425
+
font-size: 0.875rem;
426
+
}
427
+
.back:hover {
428
+
color: var(--accent);
429
+
}
430
+
h1 {
431
+
margin: 0.5rem 0 0 0;
432
+
}
433
+
.loading {
434
+
text-align: center;
435
+
color: var(--text-secondary);
436
+
padding: 2rem;
437
+
}
438
+
.message {
439
+
padding: 0.75rem;
440
+
border-radius: 4px;
441
+
margin-bottom: 1rem;
442
+
}
443
+
.message.error {
444
+
background: var(--error-bg);
445
+
border: 1px solid var(--error-border);
446
+
color: var(--error-text);
447
+
}
448
+
section {
449
+
background: var(--bg-secondary);
450
+
padding: 1.5rem;
451
+
border-radius: 8px;
452
+
margin-bottom: 1.5rem;
453
+
}
454
+
section h2 {
455
+
margin: 0 0 1rem 0;
456
+
font-size: 1.25rem;
457
+
}
458
+
.stats-grid {
459
+
display: grid;
460
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
461
+
gap: 1rem;
462
+
margin-bottom: 1rem;
463
+
}
464
+
.stat-card {
465
+
background: var(--bg-card);
466
+
border: 1px solid var(--border-color);
467
+
border-radius: 8px;
468
+
padding: 1rem;
469
+
text-align: center;
470
+
}
471
+
.stat-value {
472
+
font-size: 1.5rem;
473
+
font-weight: 600;
474
+
color: var(--accent);
475
+
}
476
+
.stat-label {
477
+
font-size: 0.875rem;
478
+
color: var(--text-secondary);
479
+
margin-top: 0.25rem;
480
+
}
481
+
.refresh-btn {
482
+
padding: 0.5rem 1rem;
483
+
background: transparent;
484
+
border: 1px solid var(--border-color);
485
+
border-radius: 4px;
486
+
cursor: pointer;
487
+
color: var(--text-primary);
488
+
}
489
+
.refresh-btn:hover {
490
+
background: var(--bg-card);
491
+
border-color: var(--accent);
492
+
}
493
+
.search-form {
494
+
display: flex;
495
+
gap: 0.5rem;
496
+
margin-bottom: 1rem;
497
+
}
498
+
.search-form input {
499
+
flex: 1;
500
+
padding: 0.5rem 0.75rem;
501
+
border: 1px solid var(--border-color);
502
+
border-radius: 4px;
503
+
font-size: 0.875rem;
504
+
background: var(--bg-input);
505
+
color: var(--text-primary);
506
+
}
507
+
.search-form input:focus {
508
+
outline: none;
509
+
border-color: var(--accent);
510
+
}
511
+
.search-form button {
512
+
padding: 0.5rem 1rem;
513
+
background: var(--accent);
514
+
color: white;
515
+
border: none;
516
+
border-radius: 4px;
517
+
cursor: pointer;
518
+
font-size: 0.875rem;
519
+
}
520
+
.search-form button:hover:not(:disabled) {
521
+
background: var(--accent-hover);
522
+
}
523
+
.search-form button:disabled {
524
+
opacity: 0.6;
525
+
cursor: not-allowed;
526
+
}
527
+
.user-list {
528
+
margin-top: 1rem;
529
+
}
530
+
.no-results {
531
+
color: var(--text-secondary);
532
+
text-align: center;
533
+
padding: 1rem;
534
+
}
535
+
table {
536
+
width: 100%;
537
+
border-collapse: collapse;
538
+
font-size: 0.875rem;
539
+
}
540
+
th, td {
541
+
padding: 0.75rem 0.5rem;
542
+
text-align: left;
543
+
border-bottom: 1px solid var(--border-color);
544
+
}
545
+
th {
546
+
font-weight: 600;
547
+
color: var(--text-secondary);
548
+
font-size: 0.75rem;
549
+
text-transform: uppercase;
550
+
letter-spacing: 0.05em;
551
+
}
552
+
.handle {
553
+
font-weight: 500;
554
+
}
555
+
.email {
556
+
color: var(--text-secondary);
557
+
}
558
+
.date {
559
+
color: var(--text-secondary);
560
+
font-size: 0.75rem;
561
+
}
562
+
.badge {
563
+
display: inline-block;
564
+
padding: 0.125rem 0.5rem;
565
+
border-radius: 4px;
566
+
font-size: 0.75rem;
567
+
}
568
+
.badge.verified {
569
+
background: var(--success-bg);
570
+
color: var(--success-text);
571
+
}
572
+
.badge.unverified {
573
+
background: var(--warning-bg);
574
+
color: var(--warning-text);
575
+
}
576
+
.badge.deactivated {
577
+
background: var(--error-bg);
578
+
color: var(--error-text);
579
+
}
580
+
.load-more {
581
+
display: block;
582
+
width: 100%;
583
+
padding: 0.75rem;
584
+
margin-top: 1rem;
585
+
background: transparent;
586
+
border: 1px solid var(--border-color);
587
+
border-radius: 4px;
588
+
cursor: pointer;
589
+
color: var(--text-primary);
590
+
font-size: 0.875rem;
591
+
}
592
+
.load-more:hover:not(:disabled) {
593
+
background: var(--bg-card);
594
+
border-color: var(--accent);
595
+
}
596
+
.load-more:disabled {
597
+
opacity: 0.6;
598
+
cursor: not-allowed;
599
+
}
600
+
.section-actions {
601
+
margin-bottom: 1rem;
602
+
}
603
+
.section-actions button {
604
+
padding: 0.5rem 1rem;
605
+
background: var(--accent);
606
+
color: white;
607
+
border: none;
608
+
border-radius: 4px;
609
+
cursor: pointer;
610
+
font-size: 0.875rem;
611
+
}
612
+
.section-actions button:hover:not(:disabled) {
613
+
background: var(--accent-hover);
614
+
}
615
+
.section-actions button:disabled {
616
+
opacity: 0.6;
617
+
cursor: not-allowed;
618
+
}
619
+
.invite-list {
620
+
margin-top: 1rem;
621
+
}
622
+
.code {
623
+
font-family: monospace;
624
+
font-size: 0.75rem;
625
+
}
626
+
.disabled-row {
627
+
opacity: 0.5;
628
+
}
629
+
.action-btn {
630
+
padding: 0.25rem 0.5rem;
631
+
font-size: 0.75rem;
632
+
border: none;
633
+
border-radius: 4px;
634
+
cursor: pointer;
635
+
}
636
+
.action-btn.danger {
637
+
background: var(--error-text);
638
+
color: white;
639
+
}
640
+
.action-btn.danger:hover {
641
+
background: #900;
642
+
}
643
+
.muted {
644
+
color: var(--text-muted);
645
+
}
646
+
.clickable {
647
+
cursor: pointer;
648
+
}
649
+
.clickable:hover {
650
+
background: var(--bg-card);
651
+
}
652
+
.modal-overlay {
653
+
position: fixed;
654
+
top: 0;
655
+
left: 0;
656
+
right: 0;
657
+
bottom: 0;
658
+
background: rgba(0, 0, 0, 0.5);
659
+
display: flex;
660
+
align-items: center;
661
+
justify-content: center;
662
+
z-index: 1000;
663
+
}
664
+
.modal {
665
+
background: var(--bg-card);
666
+
border-radius: 8px;
667
+
max-width: 500px;
668
+
width: 90%;
669
+
max-height: 90vh;
670
+
overflow-y: auto;
671
+
}
672
+
.modal-header {
673
+
display: flex;
674
+
justify-content: space-between;
675
+
align-items: center;
676
+
padding: 1rem 1.5rem;
677
+
border-bottom: 1px solid var(--border-color);
678
+
}
679
+
.modal-header h2 {
680
+
margin: 0;
681
+
font-size: 1.25rem;
682
+
}
683
+
.close-btn {
684
+
background: none;
685
+
border: none;
686
+
font-size: 1.5rem;
687
+
cursor: pointer;
688
+
color: var(--text-secondary);
689
+
padding: 0;
690
+
line-height: 1;
691
+
}
692
+
.close-btn:hover {
693
+
color: var(--text-primary);
694
+
}
695
+
.modal-body {
696
+
padding: 1.5rem;
697
+
}
698
+
.user-details {
699
+
display: grid;
700
+
grid-template-columns: auto 1fr;
701
+
gap: 0.5rem 1rem;
702
+
margin: 0 0 1.5rem 0;
703
+
}
704
+
.user-details dt {
705
+
font-weight: 500;
706
+
color: var(--text-secondary);
707
+
}
708
+
.user-details dd {
709
+
margin: 0;
710
+
}
711
+
.mono {
712
+
font-family: monospace;
713
+
font-size: 0.75rem;
714
+
word-break: break-all;
715
+
}
716
+
.modal-actions {
717
+
display: flex;
718
+
gap: 0.5rem;
719
+
flex-wrap: wrap;
720
+
}
721
+
.modal-actions .action-btn {
722
+
padding: 0.5rem 1rem;
723
+
border: 1px solid var(--border-color);
724
+
border-radius: 4px;
725
+
background: transparent;
726
+
cursor: pointer;
727
+
font-size: 0.875rem;
728
+
color: var(--text-primary);
729
+
}
730
+
.modal-actions .action-btn:hover:not(:disabled) {
731
+
background: var(--bg-secondary);
732
+
}
733
+
.modal-actions .action-btn:disabled {
734
+
opacity: 0.6;
735
+
cursor: not-allowed;
736
+
}
737
+
.modal-actions .action-btn.danger {
738
+
border-color: var(--error-text);
739
+
color: var(--error-text);
740
+
}
741
+
.modal-actions .action-btn.danger:hover:not(:disabled) {
742
+
background: var(--error-bg);
743
+
}
744
+
</style>
+160
-5
frontend/src/routes/Dashboard.svelte
+160
-5
frontend/src/routes/Dashboard.svelte
···
1
1
<script lang="ts">
2
-
import { getAuthState, logout } from '../lib/auth.svelte'
2
+
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
4
const auth = getAuthState()
5
+
let dropdownOpen = $state(false)
6
+
let switching = $state(false)
5
7
$effect(() => {
6
8
if (!auth.loading && !auth.session) {
7
9
navigate('/login')
···
11
13
await logout()
12
14
navigate('/login')
13
15
}
16
+
async function handleSwitchAccount(did: string) {
17
+
switching = true
18
+
dropdownOpen = false
19
+
try {
20
+
await switchAccount(did)
21
+
} catch {
22
+
navigate('/login')
23
+
} finally {
24
+
switching = false
25
+
}
26
+
}
27
+
function toggleDropdown() {
28
+
dropdownOpen = !dropdownOpen
29
+
}
30
+
function closeDropdown(e: MouseEvent) {
31
+
const target = e.target as HTMLElement
32
+
if (!target.closest('.account-dropdown')) {
33
+
dropdownOpen = false
34
+
}
35
+
}
36
+
$effect(() => {
37
+
if (dropdownOpen) {
38
+
document.addEventListener('click', closeDropdown)
39
+
return () => document.removeEventListener('click', closeDropdown)
40
+
}
41
+
})
42
+
let otherAccounts = $derived(
43
+
auth.savedAccounts.filter(a => a.did !== auth.session?.did)
44
+
)
14
45
</script>
15
46
{#if auth.session}
16
47
<div class="dashboard">
17
48
<header>
18
49
<h1>Dashboard</h1>
19
-
<button class="logout" onclick={handleLogout}>Sign Out</button>
50
+
<div class="account-dropdown">
51
+
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
52
+
<span class="account-handle">@{auth.session.handle}</span>
53
+
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
54
+
</button>
55
+
{#if dropdownOpen}
56
+
<div class="dropdown-menu">
57
+
{#if otherAccounts.length > 0}
58
+
<div class="dropdown-section">
59
+
<span class="dropdown-label">Switch Account</span>
60
+
{#each otherAccounts as account}
61
+
<button
62
+
type="button"
63
+
class="dropdown-item"
64
+
onclick={() => handleSwitchAccount(account.did)}
65
+
>
66
+
@{account.handle}
67
+
</button>
68
+
{/each}
69
+
</div>
70
+
<div class="dropdown-divider"></div>
71
+
{/if}
72
+
<button
73
+
type="button"
74
+
class="dropdown-item"
75
+
onclick={() => { dropdownOpen = false; navigate('/login') }}
76
+
>
77
+
Add another account
78
+
</button>
79
+
<div class="dropdown-divider"></div>
80
+
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
81
+
Sign out @{auth.session.handle}
82
+
</button>
83
+
</div>
84
+
{/if}
85
+
</div>
20
86
</header>
21
87
<section class="account-overview">
22
88
<h2>Account Overview</h2>
23
89
<dl>
24
90
<dt>Handle</dt>
25
-
<dd>@{auth.session.handle}</dd>
91
+
<dd>
92
+
@{auth.session.handle}
93
+
{#if auth.session.isAdmin}
94
+
<span class="badge admin">Admin</span>
95
+
{/if}
96
+
</dd>
26
97
<dt>DID</dt>
27
98
<dd class="mono">{auth.session.did}</dd>
28
99
{#if auth.session.preferredChannel}
···
63
134
<h3>App Passwords</h3>
64
135
<p>Manage passwords for third-party apps</p>
65
136
</a>
137
+
<a href="#/sessions" class="nav-card">
138
+
<h3>Active Sessions</h3>
139
+
<p>View and manage your login sessions</p>
140
+
</a>
66
141
<a href="#/invite-codes" class="nav-card">
67
142
<h3>Invite Codes</h3>
68
143
<p>View and create invite codes</p>
···
79
154
<h3>Repository Explorer</h3>
80
155
<p>Browse and manage raw AT Protocol records</p>
81
156
</a>
157
+
{#if auth.session.isAdmin}
158
+
<a href="#/admin" class="nav-card admin-card">
159
+
<h3>Admin Panel</h3>
160
+
<p>Server stats and admin operations</p>
161
+
</a>
162
+
{/if}
82
163
</nav>
83
164
</div>
84
165
{:else if auth.loading}
···
99
180
header h1 {
100
181
margin: 0;
101
182
}
102
-
.logout {
183
+
.account-dropdown {
184
+
position: relative;
185
+
}
186
+
.account-trigger {
187
+
display: flex;
188
+
align-items: center;
189
+
gap: 0.5rem;
103
190
padding: 0.5rem 1rem;
104
191
background: transparent;
105
192
border: 1px solid var(--border-color-light);
···
107
194
cursor: pointer;
108
195
color: var(--text-primary);
109
196
}
110
-
.logout:hover {
197
+
.account-trigger:hover:not(:disabled) {
111
198
background: var(--bg-secondary);
112
199
}
200
+
.account-trigger:disabled {
201
+
opacity: 0.6;
202
+
cursor: not-allowed;
203
+
}
204
+
.account-trigger .account-handle {
205
+
font-weight: 500;
206
+
}
207
+
.dropdown-arrow {
208
+
font-size: 0.625rem;
209
+
color: var(--text-secondary);
210
+
}
211
+
.dropdown-menu {
212
+
position: absolute;
213
+
top: 100%;
214
+
right: 0;
215
+
margin-top: 0.25rem;
216
+
min-width: 200px;
217
+
background: var(--bg-card);
218
+
border: 1px solid var(--border-color);
219
+
border-radius: 8px;
220
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
221
+
z-index: 100;
222
+
overflow: hidden;
223
+
}
224
+
.dropdown-section {
225
+
padding: 0.5rem 0;
226
+
}
227
+
.dropdown-label {
228
+
display: block;
229
+
padding: 0.25rem 1rem;
230
+
font-size: 0.75rem;
231
+
color: var(--text-muted);
232
+
text-transform: uppercase;
233
+
letter-spacing: 0.05em;
234
+
}
235
+
.dropdown-item {
236
+
display: block;
237
+
width: 100%;
238
+
padding: 0.75rem 1rem;
239
+
background: transparent;
240
+
border: none;
241
+
text-align: left;
242
+
cursor: pointer;
243
+
color: var(--text-primary);
244
+
font-size: 0.875rem;
245
+
}
246
+
.dropdown-item:hover {
247
+
background: var(--bg-secondary);
248
+
}
249
+
.dropdown-item.logout-item {
250
+
color: var(--error-text);
251
+
}
252
+
.dropdown-divider {
253
+
height: 1px;
254
+
background: var(--border-color);
255
+
margin: 0;
256
+
}
113
257
section {
114
258
background: var(--bg-secondary);
115
259
padding: 1.5rem;
···
153
297
background: var(--warning-bg);
154
298
color: var(--warning-text);
155
299
}
300
+
.badge.admin {
301
+
background: var(--accent);
302
+
color: white;
303
+
}
156
304
.nav-grid {
157
305
display: grid;
158
306
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
180
328
margin: 0;
181
329
color: var(--text-secondary);
182
330
font-size: 0.875rem;
331
+
}
332
+
.nav-card.admin-card {
333
+
border-color: var(--accent);
334
+
background: linear-gradient(135deg, var(--bg-card) 0%, rgba(77, 166, 255, 0.05) 100%);
335
+
}
336
+
.nav-card.admin-card:hover {
337
+
box-shadow: 0 2px 12px rgba(77, 166, 255, 0.25);
183
338
}
184
339
.loading {
185
340
text-align: center;
+149
-59
frontend/src/routes/Login.svelte
+149
-59
frontend/src/routes/Login.svelte
···
1
1
<script lang="ts">
2
-
import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
2
+
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
-
import { ApiError } from '../lib/api'
5
-
let identifier = $state('')
6
-
let password = $state('')
7
4
let submitting = $state(false)
8
-
let error = $state<string | null>(null)
9
5
let pendingVerification = $state<{ did: string } | null>(null)
10
6
let verificationCode = $state('')
11
7
let resendingCode = $state(false)
12
8
let resendMessage = $state<string | null>(null)
9
+
let showNewLogin = $state(false)
13
10
const auth = getAuthState()
14
11
$effect(() => {
15
12
if (auth.session) {
16
13
navigate('/dashboard')
17
14
}
18
15
})
19
-
async function handleSubmit(e: Event) {
20
-
e.preventDefault()
21
-
if (!identifier || !password) return
16
+
async function handleSwitchAccount(did: string) {
22
17
submitting = true
23
-
error = null
24
-
pendingVerification = null
25
18
try {
26
-
await login(identifier, password)
19
+
await switchAccount(did)
27
20
navigate('/dashboard')
28
-
} catch (e: any) {
29
-
if (e instanceof ApiError && e.error === 'AccountNotVerified') {
30
-
if (e.did) {
31
-
pendingVerification = { did: e.did }
32
-
} else {
33
-
error = 'Account not verified. Please check your verification method for a code.'
34
-
}
35
-
} else {
36
-
error = e.message || 'Login failed'
37
-
}
38
-
} finally {
21
+
} catch {
22
+
submitting = false
23
+
}
24
+
}
25
+
function handleForgetAccount(did: string, e: Event) {
26
+
e.stopPropagation()
27
+
forgetAccount(did)
28
+
}
29
+
async function handleOAuthLogin() {
30
+
submitting = true
31
+
try {
32
+
await loginWithOAuth()
33
+
} catch {
39
34
submitting = false
40
35
}
41
36
}
···
43
38
e.preventDefault()
44
39
if (!pendingVerification || !verificationCode.trim()) return
45
40
submitting = true
46
-
error = null
47
41
try {
48
42
await confirmSignup(pendingVerification.did, verificationCode.trim())
49
43
navigate('/dashboard')
50
-
} catch (e: any) {
51
-
error = e.message || 'Verification failed'
52
-
} finally {
44
+
} catch {
53
45
submitting = false
54
46
}
55
47
}
···
57
49
if (!pendingVerification || resendingCode) return
58
50
resendingCode = true
59
51
resendMessage = null
60
-
error = null
61
52
try {
62
53
await resendVerification(pendingVerification.did)
63
54
resendMessage = 'Verification code resent!'
64
-
} catch (e: any) {
65
-
error = e.message || 'Failed to resend code'
55
+
} catch {
56
+
resendMessage = null
66
57
} finally {
67
58
resendingCode = false
68
59
}
···
70
61
function backToLogin() {
71
62
pendingVerification = null
72
63
verificationCode = ''
73
-
error = null
74
64
resendMessage = null
75
65
}
76
66
</script>
77
67
<div class="login-container">
78
-
{#if error}
79
-
<div class="error">{error}</div>
68
+
{#if auth.error}
69
+
<div class="error">{auth.error}</div>
80
70
{/if}
81
71
{#if pendingVerification}
82
72
<h1>Verify Your Account</h1>
···
111
101
Back to Login
112
102
</button>
113
103
</form>
104
+
{:else if auth.savedAccounts.length > 0 && !showNewLogin}
105
+
<h1>Sign In</h1>
106
+
<p class="subtitle">Choose an account</p>
107
+
<div class="saved-accounts">
108
+
{#each auth.savedAccounts as account}
109
+
<div
110
+
class="account-item"
111
+
class:disabled={submitting}
112
+
role="button"
113
+
tabindex="0"
114
+
onclick={() => !submitting && handleSwitchAccount(account.did)}
115
+
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
116
+
>
117
+
<div class="account-info">
118
+
<span class="account-handle">@{account.handle}</span>
119
+
<span class="account-did">{account.did}</span>
120
+
</div>
121
+
<button
122
+
type="button"
123
+
class="forget-btn"
124
+
onclick={(e) => handleForgetAccount(account.did, e)}
125
+
title="Remove from saved accounts"
126
+
>
127
+
×
128
+
</button>
129
+
</div>
130
+
{/each}
131
+
</div>
132
+
<button type="button" class="secondary add-account" onclick={() => showNewLogin = true}>
133
+
Sign in to another account
134
+
</button>
135
+
<p class="register-link">
136
+
Don't have an account? <a href="#/register">Create one</a>
137
+
</p>
114
138
{:else}
115
139
<h1>Sign In</h1>
116
140
<p class="subtitle">Sign in to manage your PDS account</p>
117
-
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
118
-
<div class="field">
119
-
<label for="identifier">Handle or Email</label>
120
-
<input
121
-
id="identifier"
122
-
type="text"
123
-
bind:value={identifier}
124
-
placeholder="you.bsky.social or you@example.com"
125
-
disabled={submitting}
126
-
required
127
-
/>
128
-
</div>
129
-
<div class="field">
130
-
<label for="password">Password</label>
131
-
<input
132
-
id="password"
133
-
type="password"
134
-
bind:value={password}
135
-
placeholder="Password"
136
-
disabled={submitting}
137
-
required
138
-
/>
139
-
</div>
140
-
<button type="submit" disabled={submitting || !identifier || !password}>
141
-
{submitting ? 'Signing in...' : 'Sign In'}
141
+
{#if auth.savedAccounts.length > 0}
142
+
<button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}>
143
+
← Back to saved accounts
142
144
</button>
143
-
</form>
145
+
{/if}
146
+
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
147
+
{submitting ? 'Redirecting...' : 'Sign In'}
148
+
</button>
149
+
<p class="forgot-link">
150
+
<a href="#/reset-password">Forgot password?</a>
151
+
</p>
144
152
<p class="register-link">
145
153
Don't have an account? <a href="#/register">Create one</a>
146
154
</p>
···
219
227
button.tertiary:hover:not(:disabled) {
220
228
color: var(--text-primary);
221
229
}
230
+
.oauth-btn {
231
+
width: 100%;
232
+
padding: 1rem;
233
+
font-size: 1.125rem;
234
+
font-weight: 500;
235
+
}
222
236
.error {
223
237
padding: 0.75rem;
224
238
background: var(--error-bg);
···
233
247
border-radius: 4px;
234
248
color: var(--success-text);
235
249
}
250
+
.forgot-link {
251
+
text-align: center;
252
+
margin-top: 1rem;
253
+
margin-bottom: 0;
254
+
color: var(--text-secondary);
255
+
}
256
+
.forgot-link a {
257
+
color: var(--accent);
258
+
}
236
259
.register-link {
237
260
text-align: center;
238
-
margin-top: 1.5rem;
261
+
margin-top: 0.5rem;
239
262
color: var(--text-secondary);
240
263
}
241
264
.register-link a {
242
265
color: var(--accent);
266
+
}
267
+
.saved-accounts {
268
+
display: flex;
269
+
flex-direction: column;
270
+
gap: 0.5rem;
271
+
margin-bottom: 1rem;
272
+
}
273
+
.account-item {
274
+
display: flex;
275
+
align-items: center;
276
+
justify-content: space-between;
277
+
padding: 1rem;
278
+
background: var(--bg-card);
279
+
border: 1px solid var(--border-color);
280
+
border-radius: 8px;
281
+
cursor: pointer;
282
+
text-align: left;
283
+
width: 100%;
284
+
transition: border-color 0.15s, box-shadow 0.15s;
285
+
}
286
+
.account-item:hover:not(.disabled) {
287
+
border-color: var(--accent);
288
+
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
289
+
}
290
+
.account-item.disabled {
291
+
opacity: 0.6;
292
+
cursor: not-allowed;
293
+
}
294
+
.account-info {
295
+
display: flex;
296
+
flex-direction: column;
297
+
gap: 0.25rem;
298
+
}
299
+
.account-handle {
300
+
font-weight: 500;
301
+
color: var(--text-primary);
302
+
}
303
+
.account-did {
304
+
font-size: 0.75rem;
305
+
color: var(--text-muted);
306
+
font-family: monospace;
307
+
overflow: hidden;
308
+
text-overflow: ellipsis;
309
+
max-width: 250px;
310
+
}
311
+
.forget-btn {
312
+
padding: 0.25rem 0.5rem;
313
+
background: transparent;
314
+
border: none;
315
+
color: var(--text-muted);
316
+
cursor: pointer;
317
+
font-size: 1.25rem;
318
+
line-height: 1;
319
+
border-radius: 4px;
320
+
margin: 0;
321
+
}
322
+
.forget-btn:hover {
323
+
background: var(--error-bg);
324
+
color: var(--error-text);
325
+
}
326
+
.add-account {
327
+
width: 100%;
328
+
margin-bottom: 1rem;
329
+
}
330
+
.back-btn {
331
+
margin-bottom: 1rem;
332
+
padding: 0;
243
333
}
244
334
</style>
+269
frontend/src/routes/Notifications.svelte
+269
frontend/src/routes/Notifications.svelte
···
15
15
let telegramVerified = $state(false)
16
16
let signalNumber = $state('')
17
17
let signalVerified = $state(false)
18
+
let verifyingChannel = $state<string | null>(null)
19
+
let verificationCode = $state('')
20
+
let verificationError = $state<string | null>(null)
21
+
let verificationSuccess = $state<string | null>(null)
22
+
let historyLoading = $state(false)
23
+
let historyError = $state<string | null>(null)
24
+
let notifications = $state<Array<{
25
+
createdAt: string
26
+
channel: string
27
+
notificationType: string
28
+
status: string
29
+
subject: string | null
30
+
body: string
31
+
}>>([])
32
+
let showHistory = $state(false)
18
33
$effect(() => {
19
34
if (!auth.loading && !auth.session) {
20
35
navigate('/login')
···
66
81
saving = false
67
82
}
68
83
}
84
+
async function handleVerify(channel: string) {
85
+
if (!auth.session || !verificationCode) return
86
+
verificationError = null
87
+
verificationSuccess = null
88
+
try {
89
+
await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
90
+
verificationSuccess = `${channel} verified successfully`
91
+
verificationCode = ''
92
+
verifyingChannel = null
93
+
await loadPrefs()
94
+
} catch (e) {
95
+
verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
96
+
}
97
+
}
98
+
async function loadHistory() {
99
+
if (!auth.session) return
100
+
historyLoading = true
101
+
historyError = null
102
+
try {
103
+
const result = await api.getNotificationHistory(auth.session.accessJwt)
104
+
notifications = result.notifications
105
+
showHistory = true
106
+
} catch (e) {
107
+
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
108
+
} finally {
109
+
historyLoading = false
110
+
}
111
+
}
112
+
function formatDate(dateStr: string): string {
113
+
return new Date(dateStr).toLocaleString()
114
+
}
69
115
const channels = [
70
116
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
71
117
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
···
77
123
if (channelId === 'discord') return !!discordId
78
124
if (channelId === 'telegram') return !!telegramUsername
79
125
if (channelId === 'signal') return !!signalNumber
126
+
return false
127
+
}
128
+
function needsVerification(channelId: string): boolean {
129
+
if (channelId === 'discord') return !!discordId && !discordVerified
130
+
if (channelId === 'telegram') return !!telegramUsername && !telegramVerified
131
+
if (channelId === 'signal') return !!signalNumber && !signalVerified
80
132
return false
81
133
}
82
134
</script>
···
157
209
<span class="status verified">Verified</span>
158
210
{:else}
159
211
<span class="status unverified">Not verified</span>
212
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button>
160
213
{/if}
161
214
{/if}
162
215
</div>
163
216
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
217
+
{#if verifyingChannel === 'discord'}
218
+
<div class="verify-form">
219
+
<input
220
+
type="text"
221
+
bind:value={verificationCode}
222
+
placeholder="Enter verification code"
223
+
maxlength="6"
224
+
/>
225
+
<button type="button" onclick={() => handleVerify('discord')}>Submit</button>
226
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
227
+
</div>
228
+
{/if}
164
229
</div>
165
230
<div class="config-item">
166
231
<label for="telegram">Telegram Username</label>
···
177
242
<span class="status verified">Verified</span>
178
243
{:else}
179
244
<span class="status unverified">Not verified</span>
245
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button>
180
246
{/if}
181
247
{/if}
182
248
</div>
183
249
<p class="config-hint">Your Telegram username without the @ symbol</p>
250
+
{#if verifyingChannel === 'telegram'}
251
+
<div class="verify-form">
252
+
<input
253
+
type="text"
254
+
bind:value={verificationCode}
255
+
placeholder="Enter verification code"
256
+
maxlength="6"
257
+
/>
258
+
<button type="button" onclick={() => handleVerify('telegram')}>Submit</button>
259
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
260
+
</div>
261
+
{/if}
184
262
</div>
185
263
<div class="config-item">
186
264
<label for="signal">Signal Phone Number</label>
···
197
275
<span class="status verified">Verified</span>
198
276
{:else}
199
277
<span class="status unverified">Not verified</span>
278
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button>
200
279
{/if}
201
280
{/if}
202
281
</div>
203
282
<p class="config-hint">Your Signal phone number with country code</p>
283
+
{#if verifyingChannel === 'signal'}
284
+
<div class="verify-form">
285
+
<input
286
+
type="text"
287
+
bind:value={verificationCode}
288
+
placeholder="Enter verification code"
289
+
maxlength="6"
290
+
/>
291
+
<button type="button" onclick={() => handleVerify('signal')}>Submit</button>
292
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
293
+
</div>
294
+
{/if}
204
295
</div>
205
296
</div>
297
+
{#if verificationError}
298
+
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
299
+
{/if}
300
+
{#if verificationSuccess}
301
+
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
302
+
{/if}
206
303
</section>
207
304
<div class="actions">
208
305
<button type="submit" disabled={saving}>
···
210
307
</button>
211
308
</div>
212
309
</form>
310
+
<section class="history-section">
311
+
<h2>Notification History</h2>
312
+
<p class="section-description">View recent notifications sent to your account.</p>
313
+
{#if !showHistory}
314
+
<button class="load-history" onclick={loadHistory} disabled={historyLoading}>
315
+
{historyLoading ? 'Loading...' : 'Load History'}
316
+
</button>
317
+
{:else}
318
+
<button class="load-history" onclick={() => showHistory = false}>Hide History</button>
319
+
{#if historyError}
320
+
<div class="message error">{historyError}</div>
321
+
{:else if notifications.length === 0}
322
+
<p class="no-notifications">No notifications found.</p>
323
+
{:else}
324
+
<div class="notification-list">
325
+
{#each notifications as notification}
326
+
<div class="notification-item">
327
+
<div class="notification-header">
328
+
<span class="notification-type">{notification.notificationType}</span>
329
+
<span class="notification-channel">{notification.channel}</span>
330
+
<span class="notification-status" class:sent={notification.status === 'sent'} class:failed={notification.status === 'failed'}>{notification.status}</span>
331
+
</div>
332
+
{#if notification.subject}
333
+
<div class="notification-subject">{notification.subject}</div>
334
+
{/if}
335
+
<div class="notification-body">{notification.body}</div>
336
+
<div class="notification-date">{formatDate(notification.createdAt)}</div>
337
+
</div>
338
+
{/each}
339
+
</div>
340
+
{/if}
341
+
{/if}
342
+
</section>
213
343
{/if}
214
344
</div>
215
345
<style>
···
388
518
.actions button:disabled {
389
519
opacity: 0.6;
390
520
cursor: not-allowed;
521
+
}
522
+
.verify-btn {
523
+
padding: 0.25rem 0.5rem;
524
+
background: var(--accent);
525
+
color: white;
526
+
border: none;
527
+
border-radius: 4px;
528
+
font-size: 0.75rem;
529
+
cursor: pointer;
530
+
}
531
+
.verify-btn:hover {
532
+
background: var(--accent-hover);
533
+
}
534
+
.verify-form {
535
+
display: flex;
536
+
gap: 0.5rem;
537
+
margin-top: 0.5rem;
538
+
align-items: center;
539
+
}
540
+
.verify-form input {
541
+
padding: 0.5rem;
542
+
border: 1px solid var(--border-color-light);
543
+
border-radius: 4px;
544
+
font-size: 0.875rem;
545
+
width: 150px;
546
+
background: var(--bg-input);
547
+
color: var(--text-primary);
548
+
}
549
+
.verify-form button {
550
+
padding: 0.5rem 0.75rem;
551
+
background: var(--accent);
552
+
color: white;
553
+
border: none;
554
+
border-radius: 4px;
555
+
font-size: 0.875rem;
556
+
cursor: pointer;
557
+
}
558
+
.verify-form button:hover {
559
+
background: var(--accent-hover);
560
+
}
561
+
.verify-form button.cancel {
562
+
background: transparent;
563
+
border: 1px solid var(--border-color);
564
+
color: var(--text-secondary);
565
+
}
566
+
.verify-form button.cancel:hover {
567
+
background: var(--bg-secondary);
568
+
}
569
+
.history-section {
570
+
background: var(--bg-secondary);
571
+
padding: 1.5rem;
572
+
border-radius: 8px;
573
+
margin-top: 1.5rem;
574
+
}
575
+
.history-section h2 {
576
+
margin: 0 0 0.5rem 0;
577
+
font-size: 1.125rem;
578
+
}
579
+
.load-history {
580
+
padding: 0.5rem 1rem;
581
+
background: transparent;
582
+
border: 1px solid var(--border-color);
583
+
border-radius: 4px;
584
+
cursor: pointer;
585
+
color: var(--text-primary);
586
+
margin-top: 0.5rem;
587
+
}
588
+
.load-history:hover:not(:disabled) {
589
+
background: var(--bg-card);
590
+
border-color: var(--accent);
591
+
}
592
+
.load-history:disabled {
593
+
opacity: 0.6;
594
+
cursor: not-allowed;
595
+
}
596
+
.no-notifications {
597
+
color: var(--text-secondary);
598
+
font-style: italic;
599
+
margin-top: 1rem;
600
+
}
601
+
.notification-list {
602
+
display: flex;
603
+
flex-direction: column;
604
+
gap: 0.75rem;
605
+
margin-top: 1rem;
606
+
}
607
+
.notification-item {
608
+
background: var(--bg-card);
609
+
border: 1px solid var(--border-color);
610
+
border-radius: 4px;
611
+
padding: 0.75rem;
612
+
}
613
+
.notification-header {
614
+
display: flex;
615
+
gap: 0.5rem;
616
+
margin-bottom: 0.5rem;
617
+
flex-wrap: wrap;
618
+
align-items: center;
619
+
}
620
+
.notification-type {
621
+
font-weight: 500;
622
+
font-size: 0.875rem;
623
+
}
624
+
.notification-channel {
625
+
font-size: 0.75rem;
626
+
padding: 0.125rem 0.375rem;
627
+
background: var(--bg-secondary);
628
+
border-radius: 4px;
629
+
color: var(--text-secondary);
630
+
}
631
+
.notification-status {
632
+
font-size: 0.75rem;
633
+
padding: 0.125rem 0.375rem;
634
+
border-radius: 4px;
635
+
margin-left: auto;
636
+
}
637
+
.notification-status.sent {
638
+
background: var(--success-bg);
639
+
color: var(--success-text);
640
+
}
641
+
.notification-status.failed {
642
+
background: var(--error-bg);
643
+
color: var(--error-text);
644
+
}
645
+
.notification-subject {
646
+
font-weight: 500;
647
+
font-size: 0.875rem;
648
+
margin-bottom: 0.25rem;
649
+
}
650
+
.notification-body {
651
+
font-size: 0.875rem;
652
+
color: var(--text-secondary);
653
+
white-space: pre-wrap;
654
+
word-break: break-word;
655
+
}
656
+
.notification-date {
657
+
font-size: 0.75rem;
658
+
color: var(--text-muted);
659
+
margin-top: 0.5rem;
391
660
}
392
661
</style>
+223
frontend/src/routes/ResetPassword.svelte
+223
frontend/src/routes/ResetPassword.svelte
···
1
+
<script lang="ts">
2
+
import { navigate } from '../lib/router.svelte'
3
+
import { api, ApiError } from '../lib/api'
4
+
import { getAuthState } from '../lib/auth.svelte'
5
+
const auth = getAuthState()
6
+
let email = $state('')
7
+
let token = $state('')
8
+
let newPassword = $state('')
9
+
let confirmPassword = $state('')
10
+
let submitting = $state(false)
11
+
let error = $state<string | null>(null)
12
+
let success = $state<string | null>(null)
13
+
let tokenSent = $state(false)
14
+
$effect(() => {
15
+
if (auth.session) {
16
+
navigate('/dashboard')
17
+
}
18
+
})
19
+
async function handleRequestReset(e: Event) {
20
+
e.preventDefault()
21
+
if (!email) return
22
+
submitting = true
23
+
error = null
24
+
success = null
25
+
try {
26
+
await api.requestPasswordReset(email)
27
+
tokenSent = true
28
+
success = 'Password reset code sent to your email'
29
+
} catch (e) {
30
+
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
31
+
} finally {
32
+
submitting = false
33
+
}
34
+
}
35
+
async function handleReset(e: Event) {
36
+
e.preventDefault()
37
+
if (!token || !newPassword || !confirmPassword) return
38
+
if (newPassword !== confirmPassword) {
39
+
error = 'Passwords do not match'
40
+
return
41
+
}
42
+
if (newPassword.length < 8) {
43
+
error = 'Password must be at least 8 characters'
44
+
return
45
+
}
46
+
submitting = true
47
+
error = null
48
+
success = null
49
+
try {
50
+
await api.resetPassword(token, newPassword)
51
+
success = 'Password reset successfully!'
52
+
setTimeout(() => navigate('/login'), 2000)
53
+
} catch (e) {
54
+
error = e instanceof ApiError ? e.message : 'Failed to reset password'
55
+
} finally {
56
+
submitting = false
57
+
}
58
+
}
59
+
</script>
60
+
<div class="reset-container">
61
+
{#if error}
62
+
<div class="message error">{error}</div>
63
+
{/if}
64
+
{#if success}
65
+
<div class="message success">{success}</div>
66
+
{/if}
67
+
{#if tokenSent}
68
+
<h1>Reset Password</h1>
69
+
<p class="subtitle">Enter the code from your email and choose a new password.</p>
70
+
<form onsubmit={handleReset}>
71
+
<div class="field">
72
+
<label for="token">Reset Code</label>
73
+
<input
74
+
id="token"
75
+
type="text"
76
+
bind:value={token}
77
+
placeholder="Enter code from email"
78
+
disabled={submitting}
79
+
required
80
+
/>
81
+
</div>
82
+
<div class="field">
83
+
<label for="new-password">New Password</label>
84
+
<input
85
+
id="new-password"
86
+
type="password"
87
+
bind:value={newPassword}
88
+
placeholder="At least 8 characters"
89
+
disabled={submitting}
90
+
required
91
+
minlength="8"
92
+
/>
93
+
</div>
94
+
<div class="field">
95
+
<label for="confirm-password">Confirm Password</label>
96
+
<input
97
+
id="confirm-password"
98
+
type="password"
99
+
bind:value={confirmPassword}
100
+
placeholder="Confirm new password"
101
+
disabled={submitting}
102
+
required
103
+
/>
104
+
</div>
105
+
<button type="submit" disabled={submitting || !token || !newPassword || !confirmPassword}>
106
+
{submitting ? 'Resetting...' : 'Reset Password'}
107
+
</button>
108
+
<button type="button" class="secondary" onclick={() => { tokenSent = false; token = ''; newPassword = ''; confirmPassword = '' }}>
109
+
Request New Code
110
+
</button>
111
+
</form>
112
+
{:else}
113
+
<h1>Forgot Password</h1>
114
+
<p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p>
115
+
<form onsubmit={handleRequestReset}>
116
+
<div class="field">
117
+
<label for="email">Email</label>
118
+
<input
119
+
id="email"
120
+
type="email"
121
+
bind:value={email}
122
+
placeholder="you@example.com"
123
+
disabled={submitting}
124
+
required
125
+
/>
126
+
</div>
127
+
<button type="submit" disabled={submitting || !email}>
128
+
{submitting ? 'Sending...' : 'Send Reset Code'}
129
+
</button>
130
+
</form>
131
+
{/if}
132
+
<p class="back-link">
133
+
<a href="#/login">Back to Sign In</a>
134
+
</p>
135
+
</div>
136
+
<style>
137
+
.reset-container {
138
+
max-width: 400px;
139
+
margin: 4rem auto;
140
+
padding: 2rem;
141
+
}
142
+
h1 {
143
+
margin: 0 0 0.5rem 0;
144
+
}
145
+
.subtitle {
146
+
color: var(--text-secondary);
147
+
margin: 0 0 2rem 0;
148
+
}
149
+
form {
150
+
display: flex;
151
+
flex-direction: column;
152
+
gap: 1rem;
153
+
}
154
+
.field {
155
+
display: flex;
156
+
flex-direction: column;
157
+
gap: 0.25rem;
158
+
}
159
+
label {
160
+
font-size: 0.875rem;
161
+
font-weight: 500;
162
+
}
163
+
input {
164
+
padding: 0.75rem;
165
+
border: 1px solid var(--border-color-light);
166
+
border-radius: 4px;
167
+
font-size: 1rem;
168
+
background: var(--bg-input);
169
+
color: var(--text-primary);
170
+
}
171
+
input:focus {
172
+
outline: none;
173
+
border-color: var(--accent);
174
+
}
175
+
button {
176
+
padding: 0.75rem;
177
+
background: var(--accent);
178
+
color: white;
179
+
border: none;
180
+
border-radius: 4px;
181
+
font-size: 1rem;
182
+
cursor: pointer;
183
+
margin-top: 0.5rem;
184
+
}
185
+
button:hover:not(:disabled) {
186
+
background: var(--accent-hover);
187
+
}
188
+
button:disabled {
189
+
opacity: 0.6;
190
+
cursor: not-allowed;
191
+
}
192
+
button.secondary {
193
+
background: transparent;
194
+
color: var(--text-secondary);
195
+
border: 1px solid var(--border-color-light);
196
+
}
197
+
button.secondary:hover:not(:disabled) {
198
+
background: var(--bg-secondary);
199
+
}
200
+
.message {
201
+
padding: 0.75rem;
202
+
border-radius: 4px;
203
+
margin-bottom: 1rem;
204
+
}
205
+
.message.success {
206
+
background: var(--success-bg);
207
+
border: 1px solid var(--success-border);
208
+
color: var(--success-text);
209
+
}
210
+
.message.error {
211
+
background: var(--error-bg);
212
+
border: 1px solid var(--error-border);
213
+
color: var(--error-text);
214
+
}
215
+
.back-link {
216
+
text-align: center;
217
+
margin-top: 1.5rem;
218
+
color: var(--text-secondary);
219
+
}
220
+
.back-link a {
221
+
color: var(--accent);
222
+
}
223
+
</style>
+240
frontend/src/routes/Sessions.svelte
+240
frontend/src/routes/Sessions.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
const auth = getAuthState()
6
+
let loading = $state(true)
7
+
let error = $state<string | null>(null)
8
+
let sessions = $state<Array<{
9
+
id: string
10
+
createdAt: string
11
+
expiresAt: string
12
+
isCurrent: boolean
13
+
}>>([])
14
+
$effect(() => {
15
+
if (!auth.loading && !auth.session) {
16
+
navigate('/login')
17
+
}
18
+
})
19
+
$effect(() => {
20
+
if (auth.session) {
21
+
loadSessions()
22
+
}
23
+
})
24
+
async function loadSessions() {
25
+
if (!auth.session) return
26
+
loading = true
27
+
error = null
28
+
try {
29
+
const result = await api.listSessions(auth.session.accessJwt)
30
+
sessions = result.sessions
31
+
} catch (e) {
32
+
error = e instanceof ApiError ? e.message : 'Failed to load sessions'
33
+
} finally {
34
+
loading = false
35
+
}
36
+
}
37
+
async function revokeSession(sessionId: string, isCurrent: boolean) {
38
+
if (!auth.session) return
39
+
const msg = isCurrent
40
+
? 'This will log you out of this session. Continue?'
41
+
: 'Revoke this session?'
42
+
if (!confirm(msg)) return
43
+
try {
44
+
await api.revokeSession(auth.session.accessJwt, sessionId)
45
+
if (isCurrent) {
46
+
navigate('/login')
47
+
} else {
48
+
sessions = sessions.filter(s => s.id !== sessionId)
49
+
}
50
+
} catch (e) {
51
+
error = e instanceof ApiError ? e.message : 'Failed to revoke session'
52
+
}
53
+
}
54
+
function formatDate(dateStr: string): string {
55
+
return new Date(dateStr).toLocaleString()
56
+
}
57
+
function timeAgo(dateStr: string): string {
58
+
const date = new Date(dateStr)
59
+
const now = new Date()
60
+
const diff = now.getTime() - date.getTime()
61
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
62
+
const hours = Math.floor(diff / (1000 * 60 * 60))
63
+
const minutes = Math.floor(diff / (1000 * 60))
64
+
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
65
+
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
66
+
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
67
+
return 'Just now'
68
+
}
69
+
</script>
70
+
<div class="page">
71
+
<header>
72
+
<a href="#/dashboard" class="back">← Dashboard</a>
73
+
<h1>Active Sessions</h1>
74
+
</header>
75
+
{#if loading}
76
+
<p class="loading">Loading sessions...</p>
77
+
{:else}
78
+
{#if error}
79
+
<div class="message error">{error}</div>
80
+
{/if}
81
+
{#if sessions.length === 0}
82
+
<p class="empty">No active sessions found.</p>
83
+
{:else}
84
+
<div class="sessions-list">
85
+
{#each sessions as session}
86
+
<div class="session-card" class:current={session.isCurrent}>
87
+
<div class="session-info">
88
+
<div class="session-header">
89
+
{#if session.isCurrent}
90
+
<span class="badge current">Current Session</span>
91
+
{:else}
92
+
<span class="session-label">Session</span>
93
+
{/if}
94
+
</div>
95
+
<div class="session-details">
96
+
<div class="detail">
97
+
<span class="label">Created:</span>
98
+
<span class="value">{timeAgo(session.createdAt)}</span>
99
+
</div>
100
+
<div class="detail">
101
+
<span class="label">Expires:</span>
102
+
<span class="value">{formatDate(session.expiresAt)}</span>
103
+
</div>
104
+
</div>
105
+
</div>
106
+
<div class="session-actions">
107
+
<button
108
+
class="revoke-btn"
109
+
class:danger={!session.isCurrent}
110
+
onclick={() => revokeSession(session.id, session.isCurrent)}
111
+
>
112
+
{session.isCurrent ? 'Sign Out' : 'Revoke'}
113
+
</button>
114
+
</div>
115
+
</div>
116
+
{/each}
117
+
</div>
118
+
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
119
+
{/if}
120
+
{/if}
121
+
</div>
122
+
<style>
123
+
.page {
124
+
max-width: 600px;
125
+
margin: 0 auto;
126
+
padding: 2rem;
127
+
}
128
+
header {
129
+
margin-bottom: 2rem;
130
+
}
131
+
.back {
132
+
color: var(--text-secondary);
133
+
text-decoration: none;
134
+
font-size: 0.875rem;
135
+
}
136
+
.back:hover {
137
+
color: var(--accent);
138
+
}
139
+
h1 {
140
+
margin: 0.5rem 0 0 0;
141
+
}
142
+
.loading, .empty {
143
+
text-align: center;
144
+
color: var(--text-secondary);
145
+
padding: 2rem;
146
+
}
147
+
.message {
148
+
padding: 0.75rem;
149
+
border-radius: 4px;
150
+
margin-bottom: 1rem;
151
+
}
152
+
.message.error {
153
+
background: var(--error-bg);
154
+
border: 1px solid var(--error-border);
155
+
color: var(--error-text);
156
+
}
157
+
.sessions-list {
158
+
display: flex;
159
+
flex-direction: column;
160
+
gap: 1rem;
161
+
}
162
+
.session-card {
163
+
background: var(--bg-secondary);
164
+
border: 1px solid var(--border-color);
165
+
border-radius: 8px;
166
+
padding: 1rem;
167
+
display: flex;
168
+
justify-content: space-between;
169
+
align-items: center;
170
+
}
171
+
.session-card.current {
172
+
border-color: var(--accent);
173
+
background: var(--bg-card);
174
+
}
175
+
.session-header {
176
+
margin-bottom: 0.5rem;
177
+
}
178
+
.session-label {
179
+
font-weight: 500;
180
+
color: var(--text-secondary);
181
+
}
182
+
.badge {
183
+
display: inline-block;
184
+
padding: 0.125rem 0.5rem;
185
+
border-radius: 4px;
186
+
font-size: 0.75rem;
187
+
font-weight: 500;
188
+
}
189
+
.badge.current {
190
+
background: var(--accent);
191
+
color: white;
192
+
}
193
+
.session-details {
194
+
display: flex;
195
+
flex-direction: column;
196
+
gap: 0.25rem;
197
+
}
198
+
.detail {
199
+
font-size: 0.875rem;
200
+
}
201
+
.detail .label {
202
+
color: var(--text-secondary);
203
+
margin-right: 0.5rem;
204
+
}
205
+
.detail .value {
206
+
color: var(--text-primary);
207
+
}
208
+
.revoke-btn {
209
+
padding: 0.5rem 1rem;
210
+
border: 1px solid var(--border-color);
211
+
border-radius: 4px;
212
+
background: transparent;
213
+
color: var(--text-primary);
214
+
cursor: pointer;
215
+
font-size: 0.875rem;
216
+
}
217
+
.revoke-btn:hover {
218
+
background: var(--bg-card);
219
+
}
220
+
.revoke-btn.danger {
221
+
border-color: var(--error-text);
222
+
color: var(--error-text);
223
+
}
224
+
.revoke-btn.danger:hover {
225
+
background: var(--error-bg);
226
+
}
227
+
.refresh-btn {
228
+
margin-top: 1rem;
229
+
padding: 0.5rem 1rem;
230
+
background: transparent;
231
+
border: 1px solid var(--border-color);
232
+
border-radius: 4px;
233
+
cursor: pointer;
234
+
color: var(--text-primary);
235
+
}
236
+
.refresh-btn:hover {
237
+
background: var(--bg-card);
238
+
border-color: var(--accent);
239
+
}
240
+
</style>
+110
-1
frontend/src/routes/Settings.svelte
+110
-1
frontend/src/routes/Settings.svelte
···
14
14
let deletePassword = $state('')
15
15
let deleteToken = $state('')
16
16
let deleteTokenSent = $state(false)
17
+
let exportLoading = $state(false)
18
+
let passwordLoading = $state(false)
19
+
let currentPassword = $state('')
20
+
let newPassword = $state('')
21
+
let confirmNewPassword = $state('')
17
22
$effect(() => {
18
23
if (!auth.loading && !auth.session) {
19
24
navigate('/login')
···
110
115
deleteLoading = false
111
116
}
112
117
}
118
+
async function handleExportRepo() {
119
+
if (!auth.session) return
120
+
exportLoading = true
121
+
message = null
122
+
try {
123
+
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
124
+
headers: {
125
+
'Authorization': `Bearer ${auth.session.accessJwt}`
126
+
}
127
+
})
128
+
if (!response.ok) {
129
+
const err = await response.json().catch(() => ({ message: 'Export failed' }))
130
+
throw new Error(err.message || 'Export failed')
131
+
}
132
+
const blob = await response.blob()
133
+
const url = URL.createObjectURL(blob)
134
+
const a = document.createElement('a')
135
+
a.href = url
136
+
a.download = `${auth.session.handle}-repo.car`
137
+
document.body.appendChild(a)
138
+
a.click()
139
+
document.body.removeChild(a)
140
+
URL.revokeObjectURL(url)
141
+
showMessage('success', 'Repository exported successfully')
142
+
} catch (e) {
143
+
showMessage('error', e instanceof Error ? e.message : 'Failed to export repository')
144
+
} finally {
145
+
exportLoading = false
146
+
}
147
+
}
148
+
async function handleChangePassword(e: Event) {
149
+
e.preventDefault()
150
+
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
151
+
if (newPassword !== confirmNewPassword) {
152
+
showMessage('error', 'Passwords do not match')
153
+
return
154
+
}
155
+
if (newPassword.length < 8) {
156
+
showMessage('error', 'Password must be at least 8 characters')
157
+
return
158
+
}
159
+
passwordLoading = true
160
+
message = null
161
+
try {
162
+
await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
163
+
showMessage('success', 'Password changed successfully')
164
+
currentPassword = ''
165
+
newPassword = ''
166
+
confirmNewPassword = ''
167
+
} catch (e) {
168
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password')
169
+
} finally {
170
+
passwordLoading = false
171
+
}
172
+
}
113
173
</script>
114
174
<div class="page">
115
175
<header>
···
187
247
</button>
188
248
</form>
189
249
</section>
250
+
<section>
251
+
<h2>Change Password</h2>
252
+
<form onsubmit={handleChangePassword}>
253
+
<div class="field">
254
+
<label for="current-password">Current Password</label>
255
+
<input
256
+
id="current-password"
257
+
type="password"
258
+
bind:value={currentPassword}
259
+
placeholder="Enter current password"
260
+
disabled={passwordLoading}
261
+
required
262
+
/>
263
+
</div>
264
+
<div class="field">
265
+
<label for="new-password">New Password</label>
266
+
<input
267
+
id="new-password"
268
+
type="password"
269
+
bind:value={newPassword}
270
+
placeholder="At least 8 characters"
271
+
disabled={passwordLoading}
272
+
required
273
+
minlength="8"
274
+
/>
275
+
</div>
276
+
<div class="field">
277
+
<label for="confirm-new-password">Confirm New Password</label>
278
+
<input
279
+
id="confirm-new-password"
280
+
type="password"
281
+
bind:value={confirmNewPassword}
282
+
placeholder="Confirm new password"
283
+
disabled={passwordLoading}
284
+
required
285
+
/>
286
+
</div>
287
+
<button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
288
+
{passwordLoading ? 'Changing...' : 'Change Password'}
289
+
</button>
290
+
</form>
291
+
</section>
292
+
<section>
293
+
<h2>Export Data</h2>
294
+
<p class="description">Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.</p>
295
+
<button onclick={handleExportRepo} disabled={exportLoading}>
296
+
{exportLoading ? 'Exporting...' : 'Download Repository'}
297
+
</button>
298
+
</section>
190
299
<section class="danger-zone">
191
300
<h2>Delete Account</h2>
192
301
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
···
275
384
margin: 0 0 0.5rem 0;
276
385
font-size: 1.125rem;
277
386
}
278
-
.current {
387
+
.current, .description {
279
388
color: var(--text-secondary);
280
389
font-size: 0.875rem;
281
390
margin-bottom: 1rem;
+52
-19
src/api/actor/profile.rs
+52
-19
src/api/actor/profile.rs
···
2
2
use crate::state::AppState;
3
3
use axum::{
4
4
Json,
5
-
extract::{Query, State},
5
+
extract::{Query, RawQuery, State},
6
6
http::StatusCode,
7
7
response::{IntoResponse, Response},
8
8
};
···
15
15
#[derive(Deserialize)]
16
16
pub struct GetProfileParams {
17
17
pub actor: String,
18
-
}
19
-
20
-
#[derive(Deserialize)]
21
-
pub struct GetProfilesParams {
22
-
pub actors: String,
23
18
}
24
19
25
20
#[derive(Serialize, Deserialize, Clone)]
···
71
66
}
72
67
73
68
async fn proxy_to_appview(
69
+
state: &AppState,
74
70
method: &str,
75
71
params: &HashMap<String, String>,
76
72
auth_did: &str,
77
73
auth_key_bytes: Option<&[u8]>,
78
74
) -> Result<(StatusCode, Value), Response> {
79
-
let appview_url = match std::env::var("APPVIEW_URL") {
80
-
Ok(url) => url,
81
-
Err(_) => {
75
+
let resolved = match state.appview_registry.get_appview_for_method(method).await {
76
+
Some(r) => r,
77
+
None => {
78
+
return Err((
79
+
StatusCode::BAD_GATEWAY,
80
+
Json(
81
+
json!({"error": "UpstreamError", "message": "No upstream AppView configured"}),
82
+
),
83
+
)
84
+
.into_response());
85
+
}
86
+
};
87
+
let target_url = format!("{}/xrpc/{}", resolved.url, method);
88
+
info!("Proxying GET request to {}", target_url);
89
+
let client = proxy_client();
90
+
let request_builder = client.get(&target_url).query(params);
91
+
proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await
92
+
}
93
+
94
+
async fn proxy_to_appview_raw(
95
+
state: &AppState,
96
+
method: &str,
97
+
raw_query: Option<&str>,
98
+
auth_did: &str,
99
+
auth_key_bytes: Option<&[u8]>,
100
+
) -> Result<(StatusCode, Value), Response> {
101
+
let resolved = match state.appview_registry.get_appview_for_method(method).await {
102
+
Some(r) => r,
103
+
None => {
82
104
return Err((
83
105
StatusCode::BAD_GATEWAY,
84
106
Json(
···
88
110
.into_response());
89
111
}
90
112
};
91
-
let target_url = format!("{}/xrpc/{}", appview_url, method);
113
+
let target_url = match raw_query {
114
+
Some(q) => format!("{}/xrpc/{}?{}", resolved.url, method, q),
115
+
None => format!("{}/xrpc/{}", resolved.url, method),
116
+
};
92
117
info!("Proxying GET request to {}", target_url);
93
118
let client = proxy_client();
94
-
let mut request_builder = client.get(&target_url).query(params);
119
+
let request_builder = client.get(&target_url);
120
+
proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await
121
+
}
122
+
123
+
async fn proxy_request(
124
+
mut request_builder: reqwest::RequestBuilder,
125
+
auth_did: &str,
126
+
auth_key_bytes: Option<&[u8]>,
127
+
method: &str,
128
+
appview_did: &str,
129
+
) -> Result<(StatusCode, Value), Response> {
95
130
if let Some(key_bytes) = auth_key_bytes {
96
-
let appview_did =
97
-
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
98
-
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
131
+
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
99
132
Ok(service_token) => {
100
133
request_builder =
101
134
request_builder.header("Authorization", format!("Bearer {}", service_token));
···
167
200
let mut query_params = HashMap::new();
168
201
query_params.insert("actor".to_string(), params.actor.clone());
169
202
let (status, body) = match proxy_to_appview(
203
+
&state,
170
204
"app.bsky.actor.getProfile",
171
205
&query_params,
172
206
auth_did.as_deref().unwrap_or(""),
···
201
235
pub async fn get_profiles(
202
236
State(state): State<AppState>,
203
237
headers: axum::http::HeaderMap,
204
-
Query(params): Query<GetProfilesParams>,
238
+
RawQuery(raw_query): RawQuery,
205
239
) -> Response {
206
240
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
207
241
let auth_user = if let Some(h) = auth_header {
···
217
251
};
218
252
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
219
253
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
220
-
let mut query_params = HashMap::new();
221
-
query_params.insert("actors".to_string(), params.actors.clone());
222
-
let (status, body) = match proxy_to_appview(
254
+
let (status, body) = match proxy_to_appview_raw(
255
+
&state,
223
256
"app.bsky.actor.getProfiles",
224
-
&query_params,
257
+
raw_query.as_deref(),
225
258
auth_did.as_deref().unwrap_or(""),
226
259
auth_key_bytes.as_deref(),
227
260
)
+21
-7
src/api/admin/account/info.rs
+21
-7
src/api/admin/account/info.rs
···
2
2
use crate::state::AppState;
3
3
use axum::{
4
4
Json,
5
-
extract::{Query, State},
5
+
extract::{Query, RawQuery, State},
6
6
http::StatusCode,
7
7
response::{IntoResponse, Response},
8
8
};
···
88
88
}
89
89
}
90
90
91
-
#[derive(Deserialize)]
92
-
pub struct GetAccountInfosParams {
93
-
pub dids: String,
91
+
fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec<String> {
92
+
query
93
+
.map(|q| {
94
+
q.split('&')
95
+
.filter_map(|pair| {
96
+
let mut parts = pair.splitn(2, '=');
97
+
let k = parts.next()?;
98
+
let v = parts.next()?;
99
+
if k == key {
100
+
Some(urlencoding::decode(v).ok()?.into_owned())
101
+
} else {
102
+
None
103
+
}
104
+
})
105
+
.collect()
106
+
})
107
+
.unwrap_or_default()
94
108
}
95
109
96
110
pub async fn get_account_infos(
97
111
State(state): State<AppState>,
98
112
_auth: BearerAuthAdmin,
99
-
Query(params): Query<GetAccountInfosParams>,
113
+
RawQuery(raw_query): RawQuery,
100
114
) -> Response {
101
-
let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
115
+
let dids = parse_repeated_param(raw_query.as_deref(), "dids");
102
116
if dids.is_empty() {
103
117
return (
104
118
StatusCode::BAD_REQUEST,
···
107
121
.into_response();
108
122
}
109
123
let mut infos = Vec::new();
110
-
for did in dids {
124
+
for did in &dids {
111
125
if did.is_empty() {
112
126
continue;
113
127
}
+3
-7
src/api/admin/account/mod.rs
+3
-7
src/api/admin/account/mod.rs
···
1
1
mod delete;
2
2
mod email;
3
3
mod info;
4
-
mod profile;
4
+
mod search;
5
5
mod update;
6
6
7
7
pub use delete::{DeleteAccountInput, delete_account};
8
8
pub use email::{SendEmailInput, SendEmailOutput, send_email};
9
9
pub use info::{
10
-
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams,
11
-
get_account_info, get_account_infos,
12
-
};
13
-
pub use profile::{
14
-
CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile,
15
-
create_record_admin,
10
+
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, get_account_info, get_account_infos,
16
11
};
12
+
pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts};
17
13
pub use update::{
18
14
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
19
15
update_account_email, update_account_handle, update_account_password,
-133
src/api/admin/account/profile.rs
-133
src/api/admin/account/profile.rs
···
1
-
use crate::api::repo::record::create_record_internal;
2
-
use crate::auth::BearerAuthAdmin;
3
-
use crate::state::AppState;
4
-
use axum::{
5
-
Json,
6
-
extract::State,
7
-
http::StatusCode,
8
-
response::{IntoResponse, Response},
9
-
};
10
-
use serde::{Deserialize, Serialize};
11
-
use serde_json::json;
12
-
use tracing::{error, info};
13
-
14
-
#[derive(Deserialize)]
15
-
#[serde(rename_all = "camelCase")]
16
-
pub struct CreateProfileInput {
17
-
pub did: String,
18
-
pub display_name: Option<String>,
19
-
pub description: Option<String>,
20
-
}
21
-
22
-
#[derive(Deserialize)]
23
-
#[serde(rename_all = "camelCase")]
24
-
pub struct CreateRecordAdminInput {
25
-
pub did: String,
26
-
pub collection: String,
27
-
pub rkey: Option<String>,
28
-
pub record: serde_json::Value,
29
-
}
30
-
31
-
#[derive(Serialize)]
32
-
#[serde(rename_all = "camelCase")]
33
-
pub struct CreateProfileOutput {
34
-
pub uri: String,
35
-
pub cid: String,
36
-
}
37
-
38
-
pub async fn create_profile(
39
-
State(state): State<AppState>,
40
-
_auth: BearerAuthAdmin,
41
-
Json(input): Json<CreateProfileInput>,
42
-
) -> Response {
43
-
let did = input.did.trim();
44
-
if did.is_empty() {
45
-
return (
46
-
StatusCode::BAD_REQUEST,
47
-
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
48
-
)
49
-
.into_response();
50
-
}
51
-
52
-
let mut profile_record = json!({
53
-
"$type": "app.bsky.actor.profile"
54
-
});
55
-
56
-
if let Some(display_name) = &input.display_name {
57
-
profile_record["displayName"] = json!(display_name);
58
-
}
59
-
if let Some(description) = &input.description {
60
-
profile_record["description"] = json!(description);
61
-
}
62
-
63
-
match create_record_internal(
64
-
&state,
65
-
did,
66
-
"app.bsky.actor.profile",
67
-
"self",
68
-
&profile_record,
69
-
)
70
-
.await
71
-
{
72
-
Ok((uri, commit_cid)) => {
73
-
info!(did = %did, uri = %uri, "Created profile for user");
74
-
(
75
-
StatusCode::OK,
76
-
Json(CreateProfileOutput {
77
-
uri,
78
-
cid: commit_cid.to_string(),
79
-
}),
80
-
)
81
-
.into_response()
82
-
}
83
-
Err(e) => {
84
-
error!("Failed to create profile for {}: {}", did, e);
85
-
(
86
-
StatusCode::INTERNAL_SERVER_ERROR,
87
-
Json(json!({"error": "InternalError", "message": e})),
88
-
)
89
-
.into_response()
90
-
}
91
-
}
92
-
}
93
-
94
-
pub async fn create_record_admin(
95
-
State(state): State<AppState>,
96
-
_auth: BearerAuthAdmin,
97
-
Json(input): Json<CreateRecordAdminInput>,
98
-
) -> Response {
99
-
let did = input.did.trim();
100
-
if did.is_empty() {
101
-
return (
102
-
StatusCode::BAD_REQUEST,
103
-
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
104
-
)
105
-
.into_response();
106
-
}
107
-
108
-
let rkey = input
109
-
.rkey
110
-
.unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string());
111
-
112
-
match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await {
113
-
Ok((uri, commit_cid)) => {
114
-
info!(did = %did, uri = %uri, "Admin created record");
115
-
(
116
-
StatusCode::OK,
117
-
Json(CreateProfileOutput {
118
-
uri,
119
-
cid: commit_cid.to_string(),
120
-
}),
121
-
)
122
-
.into_response()
123
-
}
124
-
Err(e) => {
125
-
error!("Failed to create record for {}: {}", did, e);
126
-
(
127
-
StatusCode::INTERNAL_SERVER_ERROR,
128
-
Json(json!({"error": "InternalError", "message": e})),
129
-
)
130
-
.into_response()
131
-
}
132
-
}
133
-
}
+114
src/api/admin/account/search.rs
+114
src/api/admin/account/search.rs
···
1
+
use crate::auth::BearerAuthAdmin;
2
+
use crate::state::AppState;
3
+
use axum::{
4
+
Json,
5
+
extract::{Query, State},
6
+
http::StatusCode,
7
+
response::{IntoResponse, Response},
8
+
};
9
+
use serde::{Deserialize, Serialize};
10
+
use serde_json::json;
11
+
use tracing::error;
12
+
13
+
#[derive(Deserialize)]
14
+
pub struct SearchAccountsParams {
15
+
pub handle: Option<String>,
16
+
pub cursor: Option<String>,
17
+
#[serde(default = "default_limit")]
18
+
pub limit: i64,
19
+
}
20
+
21
+
fn default_limit() -> i64 {
22
+
50
23
+
}
24
+
25
+
#[derive(Serialize)]
26
+
#[serde(rename_all = "camelCase")]
27
+
pub struct AccountView {
28
+
pub did: String,
29
+
pub handle: String,
30
+
#[serde(skip_serializing_if = "Option::is_none")]
31
+
pub email: Option<String>,
32
+
pub indexed_at: String,
33
+
#[serde(skip_serializing_if = "Option::is_none")]
34
+
pub email_confirmed_at: Option<String>,
35
+
#[serde(skip_serializing_if = "Option::is_none")]
36
+
pub deactivated_at: Option<String>,
37
+
#[serde(skip_serializing_if = "Option::is_none")]
38
+
pub invites_disabled: Option<bool>,
39
+
}
40
+
41
+
#[derive(Serialize)]
42
+
#[serde(rename_all = "camelCase")]
43
+
pub struct SearchAccountsOutput {
44
+
#[serde(skip_serializing_if = "Option::is_none")]
45
+
pub cursor: Option<String>,
46
+
pub accounts: Vec<AccountView>,
47
+
}
48
+
49
+
pub async fn search_accounts(
50
+
State(state): State<AppState>,
51
+
_auth: BearerAuthAdmin,
52
+
Query(params): Query<SearchAccountsParams>,
53
+
) -> Response {
54
+
let limit = params.limit.clamp(1, 100);
55
+
let cursor_did = params.cursor.as_deref().unwrap_or("");
56
+
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
57
+
let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>(
58
+
r#"
59
+
SELECT did, handle, email, created_at, email_confirmed, deactivated_at
60
+
FROM users
61
+
WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2)
62
+
ORDER BY did ASC
63
+
LIMIT $3
64
+
"#,
65
+
)
66
+
.bind(cursor_did)
67
+
.bind(&handle_filter)
68
+
.bind(limit + 1)
69
+
.fetch_all(&state.db)
70
+
.await;
71
+
match result {
72
+
Ok(rows) => {
73
+
let has_more = rows.len() > limit as usize;
74
+
let accounts: Vec<AccountView> = rows
75
+
.into_iter()
76
+
.take(limit as usize)
77
+
.map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView {
78
+
did: did.clone(),
79
+
handle,
80
+
email,
81
+
indexed_at: created_at.to_rfc3339(),
82
+
email_confirmed_at: if email_confirmed {
83
+
Some(created_at.to_rfc3339())
84
+
} else {
85
+
None
86
+
},
87
+
deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()),
88
+
invites_disabled: None,
89
+
})
90
+
.collect();
91
+
let next_cursor = if has_more {
92
+
accounts.last().map(|a| a.did.clone())
93
+
} else {
94
+
None
95
+
};
96
+
(
97
+
StatusCode::OK,
98
+
Json(SearchAccountsOutput {
99
+
cursor: next_cursor,
100
+
accounts,
101
+
}),
102
+
)
103
+
.into_response()
104
+
}
105
+
Err(e) => {
106
+
error!("DB error in search_accounts: {:?}", e);
107
+
(
108
+
StatusCode::INTERNAL_SERVER_ERROR,
109
+
Json(json!({"error": "InternalError"})),
110
+
)
111
+
.into_response()
112
+
}
113
+
}
114
+
}
+2
-2
src/api/admin/mod.rs
+2
-2
src/api/admin/mod.rs
···
4
4
pub mod status;
5
5
6
6
pub use account::{
7
-
create_profile, create_record_admin, delete_account, get_account_info, get_account_infos,
8
-
send_email, update_account_email, update_account_handle, update_account_password,
7
+
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
8
+
update_account_email, update_account_handle, update_account_password,
9
9
};
10
10
pub use invite::{
11
11
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
+3
-2
src/api/feed/actor_likes.rs
+3
-2
src/api/feed/actor_likes.rs
···
1
1
use crate::api::read_after_write::{
2
2
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
3
-
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
3
+
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
4
4
};
5
5
use crate::state::AppState;
6
6
use axum::{
···
87
87
if let Some(cursor) = ¶ms.cursor {
88
88
query_params.insert("cursor".to_string(), cursor.clone());
89
89
}
90
-
let proxy_result = match proxy_to_appview(
90
+
let proxy_result = match proxy_to_appview_via_registry(
91
+
&state,
91
92
"app.bsky.feed.getActorLikes",
92
93
&query_params,
93
94
auth_did.as_deref().unwrap_or(""),
+7
-9
src/api/feed/custom_feed.rs
+7
-9
src/api/feed/custom_feed.rs
···
37
37
if let Err(e) = validate_at_uri(¶ms.feed) {
38
38
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
39
39
}
40
-
let appview_url = match std::env::var("APPVIEW_URL") {
41
-
Ok(url) => url,
42
-
Err(_) => {
43
-
return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string())
40
+
let resolved = match state.appview_registry.get_appview_for_method("app.bsky.feed.getFeed").await {
41
+
Some(r) => r,
42
+
None => {
43
+
return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.feed.getFeed".to_string())
44
44
.into_response();
45
45
}
46
46
};
47
-
if let Err(e) = is_ssrf_safe(&appview_url) {
47
+
if let Err(e) = is_ssrf_safe(&resolved.url) {
48
48
error!("SSRF check failed for appview URL: {}", e);
49
49
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
50
50
.into_response();
···
56
56
if let Some(cursor) = ¶ms.cursor {
57
57
query_params.insert("cursor".to_string(), cursor.clone());
58
58
}
59
-
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", appview_url);
59
+
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", resolved.url);
60
60
info!(target = %target_url, feed = %params.feed, "Proxying getFeed request");
61
61
let client = proxy_client();
62
62
let mut request_builder = client.get(&target_url).query(&query_params);
63
63
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
64
-
let appview_did =
65
-
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
66
64
match crate::auth::create_service_token(
67
65
&auth_user.did,
68
-
&appview_did,
66
+
&resolved.did,
69
67
"app.bsky.feed.getFeed",
70
68
key_bytes,
71
69
) {
+3
-2
src/api/feed/post_thread.rs
+3
-2
src/api/feed/post_thread.rs
···
1
1
use crate::api::read_after_write::{
2
2
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
3
-
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
3
+
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
4
4
};
5
5
use crate::state::AppState;
6
6
use axum::{
···
153
153
if let Some(parent_height) = params.parent_height {
154
154
query_params.insert("parentHeight".to_string(), parent_height.to_string());
155
155
}
156
-
let proxy_result = match proxy_to_appview(
156
+
let proxy_result = match proxy_to_appview_via_registry(
157
+
&state,
157
158
"app.bsky.feed.getPostThread",
158
159
&query_params,
159
160
auth_did.as_deref().unwrap_or(""),
+11
-13
src/api/feed/timeline.rs
+11
-13
src/api/feed/timeline.rs
···
1
1
use crate::api::read_after_write::{
2
2
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
3
3
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
4
-
proxy_to_appview,
4
+
proxy_to_appview_via_registry,
5
5
};
6
6
use crate::state::AppState;
7
7
use axum::{
···
50
50
.into_response();
51
51
}
52
52
};
53
-
match std::env::var("APPVIEW_URL") {
54
-
Ok(url) if !url.starts_with("http://127.0.0.1") => {
55
-
return get_timeline_with_appview(
56
-
&state,
57
-
¶ms,
58
-
&auth_user.did,
59
-
auth_user.key_bytes.as_deref(),
60
-
)
61
-
.await;
62
-
}
63
-
_ => {}
53
+
if state.appview_registry.get_appview_for_method("app.bsky.feed.getTimeline").await.is_some() {
54
+
return get_timeline_with_appview(
55
+
&state,
56
+
¶ms,
57
+
&auth_user.did,
58
+
auth_user.key_bytes.as_deref(),
59
+
)
60
+
.await;
64
61
}
65
62
get_timeline_local_only(&state, &auth_user.did).await
66
63
}
···
81
78
if let Some(cursor) = ¶ms.cursor {
82
79
query_params.insert("cursor".to_string(), cursor.clone());
83
80
}
84
-
let proxy_result = match proxy_to_appview(
81
+
let proxy_result = match proxy_to_appview_via_registry(
82
+
state,
85
83
"app.bsky.feed.getTimeline",
86
84
&query_params,
87
85
auth_did,
+6
-6
src/api/notification/register_push.rs
+6
-6
src/api/notification/register_push.rs
···
53
53
if input.app_id.is_empty() || input.app_id.len() > 256 {
54
54
return ApiError::InvalidRequest("Invalid appId".to_string()).into_response();
55
55
}
56
-
let appview_url = match std::env::var("APPVIEW_URL") {
57
-
Ok(url) => url,
58
-
Err(_) => {
59
-
return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string())
56
+
let resolved = match state.appview_registry.get_appview_for_method("app.bsky.notification.registerPush").await {
57
+
Some(r) => r,
58
+
None => {
59
+
return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.notification.registerPush".to_string())
60
60
.into_response();
61
61
}
62
62
};
63
-
if let Err(e) = is_ssrf_safe(&appview_url) {
63
+
if let Err(e) = is_ssrf_safe(&resolved.url) {
64
64
error!("SSRF check failed for appview URL: {}", e);
65
65
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
66
66
.into_response();
···
102
102
return ApiError::InternalError.into_response();
103
103
}
104
104
};
105
-
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url);
105
+
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", resolved.url);
106
106
info!(
107
107
target = %target_url,
108
108
service_did = %input.service_did,
+51
-45
src/api/proxy.rs
+51
-45
src/api/proxy.rs
···
2
2
use crate::state::AppState;
3
3
use axum::{
4
4
body::Bytes,
5
-
extract::{Path, Query, State},
5
+
extract::{Path, RawQuery, State},
6
6
http::{HeaderMap, Method, StatusCode},
7
7
response::{IntoResponse, Response},
8
8
};
9
-
use std::collections::HashMap;
10
-
use tracing::error;
11
-
12
-
fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> {
13
-
if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") {
14
-
let host = without_prefix.split('#').next()?;
15
-
let url = format!("https://{}", host);
16
-
let did_without_fragment = format!("did:web:{}", host);
17
-
Some((url, did_without_fragment))
18
-
} else {
19
-
None
20
-
}
21
-
}
9
+
use tracing::{error, info, warn};
22
10
23
11
pub async fn proxy_handler(
24
12
State(state): State<AppState>,
25
13
Path(method): Path<String>,
26
14
method_verb: Method,
27
15
headers: HeaderMap,
28
-
Query(params): Query<HashMap<String, String>>,
16
+
RawQuery(query): RawQuery,
29
17
body: Bytes,
30
18
) -> Response {
31
19
let proxy_header = headers
···
34
22
.map(|s| s.to_string());
35
23
let (appview_url, service_aud) = match &proxy_header {
36
24
Some(did_str) => {
37
-
let (url, did_without_fragment) = match resolve_service_did(did_str) {
38
-
Some(resolved) => resolved,
25
+
let did_without_fragment = did_str.split('#').next().unwrap_or(did_str).to_string();
26
+
match state.appview_registry.resolve_appview_did(&did_without_fragment).await {
27
+
Some(resolved) => (resolved.url, Some(resolved.did)),
39
28
None => {
40
29
error!(did = %did_str, "Could not resolve service DID");
41
30
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
42
31
.into_response();
43
32
}
44
-
};
45
-
(url, Some(did_without_fragment))
33
+
}
46
34
}
47
35
None => {
48
-
let url = match std::env::var("APPVIEW_URL") {
49
-
Ok(url) => url,
50
-
Err(_) => {
51
-
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured")
36
+
match state.appview_registry.get_appview_for_method(&method).await {
37
+
Some(resolved) => (resolved.url, Some(resolved.did)),
38
+
None => {
39
+
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured for this method")
52
40
.into_response();
53
41
}
54
-
};
55
-
let aud = std::env::var("APPVIEW_DID").ok();
56
-
(url, aud)
42
+
}
57
43
}
58
44
};
59
-
let target_url = format!("{}/xrpc/{}", appview_url, method);
45
+
let target_url = match &query {
46
+
Some(q) => format!("{}/xrpc/{}?{}", appview_url, method, q),
47
+
None => format!("{}/xrpc/{}", appview_url, method),
48
+
};
49
+
info!("Proxying {} request to {}", method_verb, target_url);
60
50
let client = proxy_client();
61
-
let mut request_builder = client.request(method_verb, &target_url).query(¶ms);
51
+
let mut request_builder = client.request(method_verb, &target_url);
62
52
let mut auth_header_val = headers.get("Authorization").cloned();
63
-
if let Some(aud) = &service_aud
64
-
&& let Some(token) = crate::auth::extract_bearer_token_from_header(
53
+
if let Some(aud) = &service_aud {
54
+
if let Some(token) = crate::auth::extract_bearer_token_from_header(
65
55
headers.get("Authorization").and_then(|h| h.to_str().ok()),
66
-
)
67
-
&& let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await
68
-
&& let Some(key_bytes) = auth_user.key_bytes
69
-
&& let Ok(new_token) =
70
-
crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes)
71
-
&& let Ok(val) =
72
-
axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
73
-
{
74
-
auth_header_val = Some(val);
56
+
) {
57
+
match crate::auth::validate_bearer_token(&state.db, &token).await {
58
+
Ok(auth_user) => {
59
+
if let Some(key_bytes) = auth_user.key_bytes {
60
+
match crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) {
61
+
Ok(new_token) => {
62
+
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) {
63
+
auth_header_val = Some(val);
64
+
}
65
+
}
66
+
Err(e) => {
67
+
warn!("Failed to create service token: {:?}", e);
68
+
}
75
69
}
70
+
}
71
+
}
72
+
Err(e) => {
73
+
warn!("Token validation failed: {:?}", e);
74
+
}
75
+
}
76
+
}
77
+
}
76
78
if let Some(val) = auth_header_val {
77
79
request_builder = request_builder.header("Authorization", val);
78
80
}
79
-
for (key, value) in headers.iter() {
80
-
if key != "host" && key != "content-length" && key != "authorization" {
81
-
request_builder = request_builder.header(key, value);
81
+
for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD {
82
+
if let Some(val) = headers.get(*header_name) {
83
+
request_builder = request_builder.header(*header_name, val);
82
84
}
83
85
}
84
-
request_builder = request_builder.body(body);
86
+
if !body.is_empty() {
87
+
request_builder = request_builder.body(body);
88
+
}
85
89
match request_builder.send().await {
86
90
Ok(resp) => {
87
91
let status = resp.status();
···
95
99
}
96
100
};
97
101
let mut response_builder = Response::builder().status(status);
98
-
for (key, value) in headers.iter() {
99
-
response_builder = response_builder.header(key, value);
102
+
for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD {
103
+
if let Some(val) = headers.get(*header_name) {
104
+
response_builder = response_builder.header(*header_name, val);
105
+
}
100
106
}
101
107
match response_builder.body(axum::body::Body::from(body)) {
102
108
Ok(r) => r,
+3
src/api/proxy_client.rs
+3
src/api/proxy_client.rs
···
121
121
"accept-language",
122
122
"atproto-accept-labelers",
123
123
"x-bsky-topics",
124
+
"content-type",
124
125
];
125
126
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
126
127
"atproto-repo-rev",
127
128
"atproto-content-labelers",
128
129
"retry-after",
129
130
"content-type",
131
+
"cache-control",
132
+
"etag",
130
133
];
131
134
132
135
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
+17
-7
src/api/read_after_write.rs
+17
-7
src/api/read_after_write.rs
···
238
238
}
239
239
}
240
240
241
-
pub async fn proxy_to_appview(
241
+
pub async fn proxy_to_appview_via_registry(
242
+
state: &AppState,
242
243
method: &str,
243
244
params: &HashMap<String, String>,
244
245
auth_did: &str,
245
246
auth_key_bytes: Option<&[u8]>,
246
247
) -> Result<ProxyResponse, Response> {
247
-
let appview_url = std::env::var("APPVIEW_URL").map_err(|_| {
248
-
ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_response()
248
+
let resolved = state.appview_registry.get_appview_for_method(method).await.ok_or_else(|| {
249
+
ApiError::UpstreamUnavailable(format!("No AppView configured for method: {}", method)).into_response()
249
250
})?;
250
-
if let Err(e) = is_ssrf_safe(&appview_url) {
251
+
proxy_to_appview_with_url(method, params, auth_did, auth_key_bytes, &resolved.url, &resolved.did).await
252
+
}
253
+
254
+
pub async fn proxy_to_appview_with_url(
255
+
method: &str,
256
+
params: &HashMap<String, String>,
257
+
auth_did: &str,
258
+
auth_key_bytes: Option<&[u8]>,
259
+
appview_url: &str,
260
+
appview_did: &str,
261
+
) -> Result<ProxyResponse, Response> {
262
+
if let Err(e) = is_ssrf_safe(appview_url) {
251
263
error!("SSRF check failed for appview URL: {}", e);
252
264
return Err(
253
265
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
···
258
270
let client = proxy_client();
259
271
let mut request_builder = client.get(&target_url).query(params);
260
272
if let Some(key_bytes) = auth_key_bytes {
261
-
let appview_did =
262
-
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
263
-
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
273
+
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
264
274
Ok(service_token) => {
265
275
request_builder =
266
276
request_builder.header("Authorization", format!("Bearer {}", service_token));
+56
-6
src/api/repo/meta.rs
+56
-6
src/api/repo/meta.rs
···
1
+
use crate::api::proxy_client::proxy_client;
1
2
use crate::state::AppState;
2
3
use axum::{
3
4
Json,
4
-
extract::{Query, State},
5
+
extract::{Query, RawQuery, State},
5
6
http::StatusCode,
6
7
response::{IntoResponse, Response},
7
8
};
8
9
use serde::Deserialize;
9
10
use serde_json::json;
11
+
use tracing::{error, info};
10
12
11
13
#[derive(Deserialize)]
12
14
pub struct DescribeRepoInput {
13
15
pub repo: String,
14
16
}
15
17
18
+
async fn proxy_describe_repo_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
19
+
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.describeRepo").await {
20
+
Some(r) => r,
21
+
None => {
22
+
return (
23
+
StatusCode::NOT_FOUND,
24
+
Json(json!({"error": "NotFound", "message": "Repo not found"})),
25
+
)
26
+
.into_response();
27
+
}
28
+
};
29
+
let target_url = match raw_query {
30
+
Some(q) => format!("{}/xrpc/com.atproto.repo.describeRepo?{}", resolved.url, q),
31
+
None => format!("{}/xrpc/com.atproto.repo.describeRepo", resolved.url),
32
+
};
33
+
info!("Proxying describeRepo to AppView: {}", target_url);
34
+
let client = proxy_client();
35
+
match client.get(&target_url).send().await {
36
+
Ok(resp) => {
37
+
let status =
38
+
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
39
+
let content_type = resp
40
+
.headers()
41
+
.get("content-type")
42
+
.and_then(|v| v.to_str().ok())
43
+
.map(|s| s.to_string());
44
+
match resp.bytes().await {
45
+
Ok(body) => {
46
+
let mut builder = Response::builder().status(status);
47
+
if let Some(ct) = content_type {
48
+
builder = builder.header("content-type", ct);
49
+
}
50
+
builder
51
+
.body(axum::body::Body::from(body))
52
+
.unwrap_or_else(|_| {
53
+
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
54
+
})
55
+
}
56
+
Err(e) => {
57
+
error!("Error reading AppView response: {:?}", e);
58
+
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
59
+
}
60
+
}
61
+
}
62
+
Err(e) => {
63
+
error!("Error proxying to AppView: {:?}", e);
64
+
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
65
+
}
66
+
}
67
+
}
68
+
16
69
pub async fn describe_repo(
17
70
State(state): State<AppState>,
18
71
Query(input): Query<DescribeRepoInput>,
72
+
RawQuery(raw_query): RawQuery,
19
73
) -> Response {
20
74
let user_row = if input.repo.starts_with("did:") {
21
75
sqlx::query!(
···
37
91
let (user_id, handle, did) = match user_row {
38
92
Ok(Some((id, handle, did))) => (id, handle, did),
39
93
_ => {
40
-
return (
41
-
StatusCode::NOT_FOUND,
42
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
43
-
)
44
-
.into_response();
94
+
return proxy_describe_repo_to_appview(&state, raw_query.as_deref()).await;
45
95
}
46
96
};
47
97
let collections_query = sqlx::query!(
+118
-12
src/api/repo/record/read.rs
+118
-12
src/api/repo/record/read.rs
···
1
+
use crate::api::proxy_client::proxy_client;
1
2
use crate::state::AppState;
2
3
use axum::{
3
4
Json,
4
-
extract::{Query, State},
5
+
extract::{Query, RawQuery, State},
5
6
http::StatusCode,
6
7
response::{IntoResponse, Response},
7
8
};
···
11
12
use serde_json::json;
12
13
use std::collections::HashMap;
13
14
use std::str::FromStr;
14
-
use tracing::error;
15
+
use tracing::{error, info};
15
16
16
17
#[derive(Deserialize)]
17
18
pub struct GetRecordInput {
···
21
22
pub cid: Option<String>,
22
23
}
23
24
25
+
async fn proxy_get_record_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
26
+
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.getRecord").await {
27
+
Some(r) => r,
28
+
None => {
29
+
return (
30
+
StatusCode::NOT_FOUND,
31
+
Json(json!({"error": "NotFound", "message": "Repo not found"})),
32
+
)
33
+
.into_response();
34
+
}
35
+
};
36
+
let target_url = match raw_query {
37
+
Some(q) => format!("{}/xrpc/com.atproto.repo.getRecord?{}", resolved.url, q),
38
+
None => format!("{}/xrpc/com.atproto.repo.getRecord", resolved.url),
39
+
};
40
+
info!("Proxying getRecord to AppView: {}", target_url);
41
+
let client = proxy_client();
42
+
match client.get(&target_url).send().await {
43
+
Ok(resp) => {
44
+
let status =
45
+
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
46
+
let content_type = resp
47
+
.headers()
48
+
.get("content-type")
49
+
.and_then(|v| v.to_str().ok())
50
+
.map(|s| s.to_string());
51
+
match resp.bytes().await {
52
+
Ok(body) => {
53
+
let mut builder = Response::builder().status(status);
54
+
if let Some(ct) = content_type {
55
+
builder = builder.header("content-type", ct);
56
+
}
57
+
builder
58
+
.body(axum::body::Body::from(body))
59
+
.unwrap_or_else(|_| {
60
+
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
61
+
})
62
+
}
63
+
Err(e) => {
64
+
error!("Error reading AppView response: {:?}", e);
65
+
(
66
+
StatusCode::BAD_GATEWAY,
67
+
Json(json!({"error": "UpstreamError"})),
68
+
)
69
+
.into_response()
70
+
}
71
+
}
72
+
}
73
+
Err(e) => {
74
+
error!("Error proxying to AppView: {:?}", e);
75
+
(
76
+
StatusCode::BAD_GATEWAY,
77
+
Json(json!({"error": "UpstreamError"})),
78
+
)
79
+
.into_response()
80
+
}
81
+
}
82
+
}
83
+
24
84
pub async fn get_record(
25
85
State(state): State<AppState>,
26
86
Query(input): Query<GetRecordInput>,
87
+
RawQuery(raw_query): RawQuery,
27
88
) -> Response {
28
89
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
29
90
let user_id_opt = if input.repo.starts_with("did:") {
···
46
107
let user_id: uuid::Uuid = match user_id_opt {
47
108
Ok(Some(id)) => id,
48
109
_ => {
49
-
return (
50
-
StatusCode::NOT_FOUND,
51
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
52
-
)
53
-
.into_response();
110
+
return proxy_get_record_to_appview(&state, raw_query.as_deref()).await;
54
111
}
55
112
};
56
113
let record_row = sqlx::query!(
···
134
191
pub cursor: Option<String>,
135
192
pub records: Vec<serde_json::Value>,
136
193
}
194
+
195
+
async fn proxy_list_records_to_appview(state: &AppState, raw_query: Option<&str>) -> Response {
196
+
let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.listRecords").await {
197
+
Some(r) => r,
198
+
None => {
199
+
return (
200
+
StatusCode::NOT_FOUND,
201
+
Json(json!({"error": "NotFound", "message": "Repo not found"})),
202
+
)
203
+
.into_response();
204
+
}
205
+
};
206
+
let target_url = match raw_query {
207
+
Some(q) => format!("{}/xrpc/com.atproto.repo.listRecords?{}", resolved.url, q),
208
+
None => format!("{}/xrpc/com.atproto.repo.listRecords", resolved.url),
209
+
};
210
+
info!("Proxying listRecords to AppView: {}", target_url);
211
+
let client = proxy_client();
212
+
match client.get(&target_url).send().await {
213
+
Ok(resp) => {
214
+
let status =
215
+
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
216
+
let content_type = resp
217
+
.headers()
218
+
.get("content-type")
219
+
.and_then(|v| v.to_str().ok())
220
+
.map(|s| s.to_string());
221
+
match resp.bytes().await {
222
+
Ok(body) => {
223
+
let mut builder = Response::builder().status(status);
224
+
if let Some(ct) = content_type {
225
+
builder = builder.header("content-type", ct);
226
+
}
227
+
builder
228
+
.body(axum::body::Body::from(body))
229
+
.unwrap_or_else(|_| {
230
+
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
231
+
})
232
+
}
233
+
Err(e) => {
234
+
error!("Error reading AppView response: {:?}", e);
235
+
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
236
+
}
237
+
}
238
+
}
239
+
Err(e) => {
240
+
error!("Error proxying to AppView: {:?}", e);
241
+
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()
242
+
}
243
+
}
244
+
}
245
+
137
246
pub async fn list_records(
138
247
State(state): State<AppState>,
139
248
Query(input): Query<ListRecordsInput>,
249
+
RawQuery(raw_query): RawQuery,
140
250
) -> Response {
141
251
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
142
252
let user_id_opt = if input.repo.starts_with("did:") {
···
159
269
let user_id: uuid::Uuid = match user_id_opt {
160
270
Ok(Some(id)) => id,
161
271
_ => {
162
-
return (
163
-
StatusCode::NOT_FOUND,
164
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
165
-
)
166
-
.into_response();
272
+
return proxy_list_records_to_appview(&state, raw_query.as_deref()).await;
167
273
}
168
274
};
169
275
let limit = input.limit.unwrap_or(50).clamp(1, 100);
+3
-3
src/api/server/mod.rs
+3
-3
src/api/server/mod.rs
···
16
16
pub use email::{confirm_email, request_email_update, update_email};
17
17
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
18
18
pub use meta::{describe_server, health, robots_txt};
19
-
pub use password::{request_password_reset, reset_password};
19
+
pub use password::{change_password, request_password_reset, reset_password};
20
20
pub use service_auth::get_service_auth;
21
21
pub use session::{
22
-
confirm_signup, create_session, delete_session, get_session, refresh_session,
23
-
resend_verification,
22
+
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
23
+
resend_verification, revoke_session,
24
24
};
25
25
pub use signing_key::reserve_signing_key;
+108
-1
src/api/server/password.rs
+108
-1
src/api/server/password.rs
···
1
+
use crate::auth::BearerAuth;
1
2
use crate::state::{AppState, RateLimitKind};
2
3
use axum::{
3
4
Json,
···
5
6
http::{HeaderMap, StatusCode},
6
7
response::{IntoResponse, Response},
7
8
};
8
-
use bcrypt::{DEFAULT_COST, hash};
9
+
use bcrypt::{DEFAULT_COST, hash, verify};
9
10
use chrono::{Duration, Utc};
11
+
use uuid::Uuid;
10
12
use serde::Deserialize;
11
13
use serde_json::json;
12
14
use tracing::{error, info, warn};
···
297
299
info!("Password reset completed for user {}", user_id);
298
300
(StatusCode::OK, Json(json!({}))).into_response()
299
301
}
302
+
303
+
#[derive(Deserialize)]
304
+
#[serde(rename_all = "camelCase")]
305
+
pub struct ChangePasswordInput {
306
+
pub current_password: String,
307
+
pub new_password: String,
308
+
}
309
+
310
+
pub async fn change_password(
311
+
State(state): State<AppState>,
312
+
auth: BearerAuth,
313
+
Json(input): Json<ChangePasswordInput>,
314
+
) -> Response {
315
+
let current_password = &input.current_password;
316
+
let new_password = &input.new_password;
317
+
if current_password.is_empty() {
318
+
return (
319
+
StatusCode::BAD_REQUEST,
320
+
Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
321
+
)
322
+
.into_response();
323
+
}
324
+
if new_password.is_empty() {
325
+
return (
326
+
StatusCode::BAD_REQUEST,
327
+
Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
328
+
)
329
+
.into_response();
330
+
}
331
+
if new_password.len() < 8 {
332
+
return (
333
+
StatusCode::BAD_REQUEST,
334
+
Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})),
335
+
)
336
+
.into_response();
337
+
}
338
+
let user = sqlx::query_as::<_, (Uuid, String)>(
339
+
"SELECT id, password_hash FROM users WHERE did = $1",
340
+
)
341
+
.bind(&auth.0.did)
342
+
.fetch_optional(&state.db)
343
+
.await;
344
+
let (user_id, password_hash) = match user {
345
+
Ok(Some(row)) => row,
346
+
Ok(None) => {
347
+
return (
348
+
StatusCode::NOT_FOUND,
349
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
350
+
)
351
+
.into_response();
352
+
}
353
+
Err(e) => {
354
+
error!("DB error in change_password: {:?}", e);
355
+
return (
356
+
StatusCode::INTERNAL_SERVER_ERROR,
357
+
Json(json!({"error": "InternalError"})),
358
+
)
359
+
.into_response();
360
+
}
361
+
};
362
+
let valid = match verify(current_password, &password_hash) {
363
+
Ok(v) => v,
364
+
Err(e) => {
365
+
error!("Password verification error: {:?}", e);
366
+
return (
367
+
StatusCode::INTERNAL_SERVER_ERROR,
368
+
Json(json!({"error": "InternalError"})),
369
+
)
370
+
.into_response();
371
+
}
372
+
};
373
+
if !valid {
374
+
return (
375
+
StatusCode::UNAUTHORIZED,
376
+
Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
377
+
)
378
+
.into_response();
379
+
}
380
+
let new_hash = match hash(new_password, DEFAULT_COST) {
381
+
Ok(h) => h,
382
+
Err(e) => {
383
+
error!("Failed to hash password: {:?}", e);
384
+
return (
385
+
StatusCode::INTERNAL_SERVER_ERROR,
386
+
Json(json!({"error": "InternalError"})),
387
+
)
388
+
.into_response();
389
+
}
390
+
};
391
+
if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
392
+
.bind(&new_hash)
393
+
.bind(user_id)
394
+
.execute(&state.db)
395
+
.await
396
+
{
397
+
error!("DB error updating password: {:?}", e);
398
+
return (
399
+
StatusCode::INTERNAL_SERVER_ERROR,
400
+
Json(json!({"error": "InternalError"})),
401
+
)
402
+
.into_response();
403
+
}
404
+
info!(did = %auth.0.did, "Password changed successfully");
405
+
(StatusCode::OK, Json(json!({}))).into_response()
406
+
}
+130
-2
src/api/server/session.rs
+130
-2
src/api/server/session.rs
···
185
185
) -> Response {
186
186
match sqlx::query!(
187
187
r#"SELECT
188
-
handle, email, email_confirmed,
188
+
handle, email, email_confirmed, is_admin,
189
189
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
190
190
discord_verified, telegram_verified, signal_verified
191
191
FROM users WHERE did = $1"#,
···
210
210
"emailConfirmed": row.email_confirmed,
211
211
"preferredChannel": preferred_channel,
212
212
"preferredChannelVerified": preferred_channel_verified,
213
+
"isAdmin": row.is_admin,
213
214
"active": true,
214
215
"didDoc": {}
215
216
})).into_response()
···
406
407
}
407
408
match sqlx::query!(
408
409
r#"SELECT
409
-
handle, email, email_confirmed,
410
+
handle, email, email_confirmed, is_admin,
410
411
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
411
412
discord_verified, telegram_verified, signal_verified
412
413
FROM users WHERE did = $1"#,
···
433
434
"emailConfirmed": u.email_confirmed,
434
435
"preferredChannel": preferred_channel,
435
436
"preferredChannelVerified": preferred_channel_verified,
437
+
"isAdmin": u.is_admin,
436
438
"active": true
437
439
})).into_response()
438
440
}
···
702
704
}
703
705
Json(json!({"success": true})).into_response()
704
706
}
707
+
708
+
#[derive(Serialize)]
709
+
#[serde(rename_all = "camelCase")]
710
+
pub struct SessionInfo {
711
+
pub id: String,
712
+
pub created_at: String,
713
+
pub expires_at: String,
714
+
pub is_current: bool,
715
+
}
716
+
717
+
#[derive(Serialize)]
718
+
#[serde(rename_all = "camelCase")]
719
+
pub struct ListSessionsOutput {
720
+
pub sessions: Vec<SessionInfo>,
721
+
}
722
+
723
+
pub async fn list_sessions(
724
+
State(state): State<AppState>,
725
+
headers: HeaderMap,
726
+
auth: BearerAuth,
727
+
) -> Response {
728
+
let current_jti = headers
729
+
.get("authorization")
730
+
.and_then(|v| v.to_str().ok())
731
+
.and_then(|v| v.strip_prefix("Bearer "))
732
+
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
733
+
let result = sqlx::query_as::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
734
+
r#"
735
+
SELECT id, access_jti, created_at, refresh_expires_at
736
+
FROM session_tokens
737
+
WHERE did = $1 AND refresh_expires_at > NOW()
738
+
ORDER BY created_at DESC
739
+
"#,
740
+
)
741
+
.bind(&auth.0.did)
742
+
.fetch_all(&state.db)
743
+
.await;
744
+
match result {
745
+
Ok(rows) => {
746
+
let sessions: Vec<SessionInfo> = rows
747
+
.into_iter()
748
+
.map(|(id, access_jti, created_at, expires_at)| SessionInfo {
749
+
id: id.to_string(),
750
+
created_at: created_at.to_rfc3339(),
751
+
expires_at: expires_at.to_rfc3339(),
752
+
is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti),
753
+
})
754
+
.collect();
755
+
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
756
+
}
757
+
Err(e) => {
758
+
error!("DB error in list_sessions: {:?}", e);
759
+
(
760
+
StatusCode::INTERNAL_SERVER_ERROR,
761
+
Json(json!({"error": "InternalError"})),
762
+
)
763
+
.into_response()
764
+
}
765
+
}
766
+
}
767
+
768
+
#[derive(Deserialize)]
769
+
#[serde(rename_all = "camelCase")]
770
+
pub struct RevokeSessionInput {
771
+
pub session_id: String,
772
+
}
773
+
774
+
pub async fn revoke_session(
775
+
State(state): State<AppState>,
776
+
auth: BearerAuth,
777
+
Json(input): Json<RevokeSessionInput>,
778
+
) -> Response {
779
+
let session_id: i32 = match input.session_id.parse() {
780
+
Ok(id) => id,
781
+
Err(_) => {
782
+
return (
783
+
StatusCode::BAD_REQUEST,
784
+
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
785
+
)
786
+
.into_response();
787
+
}
788
+
};
789
+
let session = sqlx::query_as::<_, (String,)>(
790
+
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
791
+
)
792
+
.bind(session_id)
793
+
.bind(&auth.0.did)
794
+
.fetch_optional(&state.db)
795
+
.await;
796
+
let access_jti = match session {
797
+
Ok(Some((jti,))) => jti,
798
+
Ok(None) => {
799
+
return (
800
+
StatusCode::NOT_FOUND,
801
+
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
802
+
)
803
+
.into_response();
804
+
}
805
+
Err(e) => {
806
+
error!("DB error in revoke_session: {:?}", e);
807
+
return (
808
+
StatusCode::INTERNAL_SERVER_ERROR,
809
+
Json(json!({"error": "InternalError"})),
810
+
)
811
+
.into_response();
812
+
}
813
+
};
814
+
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
815
+
.bind(session_id)
816
+
.execute(&state.db)
817
+
.await
818
+
{
819
+
error!("DB error deleting session: {:?}", e);
820
+
return (
821
+
StatusCode::INTERNAL_SERVER_ERROR,
822
+
Json(json!({"error": "InternalError"})),
823
+
)
824
+
.into_response();
825
+
}
826
+
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
827
+
if let Err(e) = state.cache.delete(&cache_key).await {
828
+
warn!("Failed to invalidate session cache: {:?}", e);
829
+
}
830
+
info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
831
+
(StatusCode::OK, Json(json!({}))).into_response()
832
+
}
+413
src/appview/mod.rs
+413
src/appview/mod.rs
···
1
+
use reqwest::Client;
2
+
use serde::{Deserialize, Serialize};
3
+
use std::collections::HashMap;
4
+
use std::time::{Duration, Instant};
5
+
use tokio::sync::RwLock;
6
+
use tracing::{debug, error, info, warn};
7
+
8
+
#[derive(Debug, Clone, Serialize, Deserialize)]
9
+
pub struct DidDocument {
10
+
pub id: String,
11
+
#[serde(default)]
12
+
pub service: Vec<DidService>,
13
+
}
14
+
15
+
#[derive(Debug, Clone, Serialize, Deserialize)]
16
+
#[serde(rename_all = "camelCase")]
17
+
pub struct DidService {
18
+
pub id: String,
19
+
#[serde(rename = "type")]
20
+
pub service_type: String,
21
+
pub service_endpoint: String,
22
+
}
23
+
24
+
#[derive(Clone)]
25
+
struct CachedAppView {
26
+
url: String,
27
+
did: String,
28
+
resolved_at: Instant,
29
+
}
30
+
31
+
pub struct AppViewRegistry {
32
+
namespace_to_did: HashMap<String, String>,
33
+
did_cache: RwLock<HashMap<String, CachedAppView>>,
34
+
client: Client,
35
+
cache_ttl: Duration,
36
+
plc_directory_url: String,
37
+
}
38
+
39
+
impl Clone for AppViewRegistry {
40
+
fn clone(&self) -> Self {
41
+
Self {
42
+
namespace_to_did: self.namespace_to_did.clone(),
43
+
did_cache: RwLock::new(HashMap::new()),
44
+
client: self.client.clone(),
45
+
cache_ttl: self.cache_ttl,
46
+
plc_directory_url: self.plc_directory_url.clone(),
47
+
}
48
+
}
49
+
}
50
+
51
+
#[derive(Debug, Clone)]
52
+
pub struct ResolvedAppView {
53
+
pub url: String,
54
+
pub did: String,
55
+
}
56
+
57
+
impl AppViewRegistry {
58
+
pub fn new() -> Self {
59
+
let mut namespace_to_did = HashMap::new();
60
+
61
+
let bsky_did = std::env::var("APPVIEW_DID_BSKY")
62
+
.unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
63
+
namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone());
64
+
namespace_to_did.insert("com.atproto".to_string(), bsky_did);
65
+
66
+
for (key, value) in std::env::vars() {
67
+
if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") {
68
+
let namespace = namespace.to_lowercase().replace('_', ".");
69
+
if namespace != "bsky" {
70
+
namespace_to_did.insert(namespace, value);
71
+
}
72
+
}
73
+
}
74
+
75
+
let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS")
76
+
.ok()
77
+
.and_then(|v| v.parse().ok())
78
+
.unwrap_or(300);
79
+
80
+
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
81
+
.unwrap_or_else(|_| "https://plc.directory".to_string());
82
+
83
+
let client = Client::builder()
84
+
.timeout(Duration::from_secs(10))
85
+
.connect_timeout(Duration::from_secs(5))
86
+
.pool_max_idle_per_host(10)
87
+
.build()
88
+
.unwrap_or_else(|_| Client::new());
89
+
90
+
info!(
91
+
"AppView registry initialized with {} namespace mappings",
92
+
namespace_to_did.len()
93
+
);
94
+
for (ns, did) in &namespace_to_did {
95
+
debug!(" {} -> {}", ns, did);
96
+
}
97
+
98
+
Self {
99
+
namespace_to_did,
100
+
did_cache: RwLock::new(HashMap::new()),
101
+
client,
102
+
cache_ttl: Duration::from_secs(cache_ttl_secs),
103
+
plc_directory_url,
104
+
}
105
+
}
106
+
107
+
pub fn register_namespace(&mut self, namespace: &str, did: &str) {
108
+
info!("Registering AppView: {} -> {}", namespace, did);
109
+
self.namespace_to_did
110
+
.insert(namespace.to_string(), did.to_string());
111
+
}
112
+
113
+
pub async fn get_appview_for_method(&self, method: &str) -> Option<ResolvedAppView> {
114
+
let namespace = self.extract_namespace(method)?;
115
+
self.get_appview_for_namespace(&namespace).await
116
+
}
117
+
118
+
pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option<ResolvedAppView> {
119
+
let did = self.get_did_for_namespace(namespace)?;
120
+
self.resolve_appview_did(&did).await
121
+
}
122
+
123
+
pub fn get_did_for_namespace(&self, namespace: &str) -> Option<String> {
124
+
if let Some(did) = self.namespace_to_did.get(namespace) {
125
+
return Some(did.clone());
126
+
}
127
+
128
+
let mut parts: Vec<&str> = namespace.split('.').collect();
129
+
while !parts.is_empty() {
130
+
let prefix = parts.join(".");
131
+
if let Some(did) = self.namespace_to_did.get(&prefix) {
132
+
return Some(did.clone());
133
+
}
134
+
parts.pop();
135
+
}
136
+
137
+
None
138
+
}
139
+
140
+
pub async fn resolve_appview_did(&self, did: &str) -> Option<ResolvedAppView> {
141
+
{
142
+
let cache = self.did_cache.read().await;
143
+
if let Some(cached) = cache.get(did) {
144
+
if cached.resolved_at.elapsed() < self.cache_ttl {
145
+
return Some(ResolvedAppView {
146
+
url: cached.url.clone(),
147
+
did: cached.did.clone(),
148
+
});
149
+
}
150
+
}
151
+
}
152
+
153
+
let resolved = self.resolve_did_internal(did).await?;
154
+
155
+
{
156
+
let mut cache = self.did_cache.write().await;
157
+
cache.insert(
158
+
did.to_string(),
159
+
CachedAppView {
160
+
url: resolved.url.clone(),
161
+
did: resolved.did.clone(),
162
+
resolved_at: Instant::now(),
163
+
},
164
+
);
165
+
}
166
+
167
+
Some(resolved)
168
+
}
169
+
170
+
async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedAppView> {
171
+
let did_doc = if did.starts_with("did:web:") {
172
+
self.resolve_did_web(did).await
173
+
} else if did.starts_with("did:plc:") {
174
+
self.resolve_did_plc(did).await
175
+
} else {
176
+
warn!("Unsupported DID method: {}", did);
177
+
return None;
178
+
};
179
+
180
+
let doc = match did_doc {
181
+
Ok(doc) => doc,
182
+
Err(e) => {
183
+
error!("Failed to resolve DID {}: {}", did, e);
184
+
return None;
185
+
}
186
+
};
187
+
188
+
self.extract_appview_endpoint(&doc)
189
+
}
190
+
191
+
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
192
+
let host = did
193
+
.strip_prefix("did:web:")
194
+
.ok_or("Invalid did:web format")?;
195
+
196
+
let (host, path) = if host.contains(':') {
197
+
let decoded = host.replace("%3A", ":");
198
+
let parts: Vec<&str> = decoded.splitn(2, '/').collect();
199
+
if parts.len() > 1 {
200
+
(parts[0].to_string(), format!("/{}", parts[1]))
201
+
} else {
202
+
(decoded, String::new())
203
+
}
204
+
} else {
205
+
let parts: Vec<&str> = host.splitn(2, ':').collect();
206
+
if parts.len() > 1 && parts[1].contains('/') {
207
+
let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
208
+
if path_parts.len() > 1 {
209
+
(
210
+
format!("{}:{}", parts[0], path_parts[0]),
211
+
format!("/{}", path_parts[1]),
212
+
)
213
+
} else {
214
+
(host.to_string(), String::new())
215
+
}
216
+
} else {
217
+
(host.to_string(), String::new())
218
+
}
219
+
};
220
+
221
+
let scheme =
222
+
if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':')
223
+
{
224
+
"http"
225
+
} else {
226
+
"https"
227
+
};
228
+
229
+
let url = if path.is_empty() {
230
+
format!("{}://{}/.well-known/did.json", scheme, host)
231
+
} else {
232
+
format!("{}://{}{}/did.json", scheme, host, path)
233
+
};
234
+
235
+
debug!("Resolving did:web {} via {}", did, url);
236
+
237
+
let resp = self
238
+
.client
239
+
.get(&url)
240
+
.send()
241
+
.await
242
+
.map_err(|e| format!("HTTP request failed: {}", e))?;
243
+
244
+
if !resp.status().is_success() {
245
+
return Err(format!("HTTP {}", resp.status()));
246
+
}
247
+
248
+
resp.json::<DidDocument>()
249
+
.await
250
+
.map_err(|e| format!("Failed to parse DID document: {}", e))
251
+
}
252
+
253
+
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
254
+
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
255
+
256
+
debug!("Resolving did:plc {} via {}", did, url);
257
+
258
+
let resp = self
259
+
.client
260
+
.get(&url)
261
+
.send()
262
+
.await
263
+
.map_err(|e| format!("HTTP request failed: {}", e))?;
264
+
265
+
if resp.status() == reqwest::StatusCode::NOT_FOUND {
266
+
return Err("DID not found".to_string());
267
+
}
268
+
269
+
if !resp.status().is_success() {
270
+
return Err(format!("HTTP {}", resp.status()));
271
+
}
272
+
273
+
resp.json::<DidDocument>()
274
+
.await
275
+
.map_err(|e| format!("Failed to parse DID document: {}", e))
276
+
}
277
+
278
+
fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option<ResolvedAppView> {
279
+
for service in &doc.service {
280
+
if service.service_type == "AtprotoAppView"
281
+
|| service.id.contains("atproto_appview")
282
+
|| service.id.ends_with("#bsky_appview")
283
+
{
284
+
return Some(ResolvedAppView {
285
+
url: service.service_endpoint.clone(),
286
+
did: doc.id.clone(),
287
+
});
288
+
}
289
+
}
290
+
291
+
for service in &doc.service {
292
+
if service.service_type.contains("AppView") || service.id.contains("appview") {
293
+
return Some(ResolvedAppView {
294
+
url: service.service_endpoint.clone(),
295
+
did: doc.id.clone(),
296
+
});
297
+
}
298
+
}
299
+
300
+
if let Some(service) = doc.service.first() {
301
+
if service.service_endpoint.starts_with("http") {
302
+
warn!(
303
+
"No explicit AppView service found for {}, using first service: {}",
304
+
doc.id, service.service_endpoint
305
+
);
306
+
return Some(ResolvedAppView {
307
+
url: service.service_endpoint.clone(),
308
+
did: doc.id.clone(),
309
+
});
310
+
}
311
+
}
312
+
313
+
if doc.id.starts_with("did:web:") {
314
+
let host = doc.id.strip_prefix("did:web:")?;
315
+
let decoded_host = host.replace("%3A", ":");
316
+
let base_host = decoded_host.split('/').next()?;
317
+
let scheme = if base_host.starts_with("localhost")
318
+
|| base_host.starts_with("127.0.0.1")
319
+
|| base_host.contains(':')
320
+
{
321
+
"http"
322
+
} else {
323
+
"https"
324
+
};
325
+
warn!(
326
+
"No service found for {}, deriving URL from DID: {}://{}",
327
+
doc.id, scheme, base_host
328
+
);
329
+
return Some(ResolvedAppView {
330
+
url: format!("{}://{}", scheme, base_host),
331
+
did: doc.id.clone(),
332
+
});
333
+
}
334
+
335
+
None
336
+
}
337
+
338
+
fn extract_namespace(&self, method: &str) -> Option<String> {
339
+
let parts: Vec<&str> = method.split('.').collect();
340
+
if parts.len() >= 2 {
341
+
Some(format!("{}.{}", parts[0], parts[1]))
342
+
} else {
343
+
None
344
+
}
345
+
}
346
+
347
+
pub fn list_namespaces(&self) -> Vec<(String, String)> {
348
+
self.namespace_to_did
349
+
.iter()
350
+
.map(|(k, v)| (k.clone(), v.clone()))
351
+
.collect()
352
+
}
353
+
354
+
pub async fn invalidate_cache(&self, did: &str) {
355
+
let mut cache = self.did_cache.write().await;
356
+
cache.remove(did);
357
+
}
358
+
359
+
pub async fn invalidate_all_cache(&self) {
360
+
let mut cache = self.did_cache.write().await;
361
+
cache.clear();
362
+
}
363
+
}
364
+
365
+
impl Default for AppViewRegistry {
366
+
fn default() -> Self {
367
+
Self::new()
368
+
}
369
+
}
370
+
371
+
pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
372
+
registry.get_appview_for_method(method).await.map(|r| r.url)
373
+
}
374
+
375
+
pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option<String> {
376
+
registry.get_appview_for_method(method).await.map(|r| r.did)
377
+
}
378
+
379
+
#[cfg(test)]
380
+
mod tests {
381
+
use super::*;
382
+
383
+
#[test]
384
+
fn test_extract_namespace() {
385
+
let registry = AppViewRegistry::new();
386
+
assert_eq!(
387
+
registry.extract_namespace("app.bsky.actor.getProfile"),
388
+
Some("app.bsky".to_string())
389
+
);
390
+
assert_eq!(
391
+
registry.extract_namespace("com.atproto.repo.createRecord"),
392
+
Some("com.atproto".to_string())
393
+
);
394
+
assert_eq!(
395
+
registry.extract_namespace("com.whtwnd.blog.getPost"),
396
+
Some("com.whtwnd".to_string())
397
+
);
398
+
assert_eq!(registry.extract_namespace("invalid"), None);
399
+
}
400
+
401
+
#[test]
402
+
fn test_get_did_for_namespace() {
403
+
let mut registry = AppViewRegistry::new();
404
+
registry.register_namespace("com.whtwnd", "did:web:whtwnd.com");
405
+
406
+
assert!(registry.get_did_for_namespace("app.bsky").is_some());
407
+
assert_eq!(
408
+
registry.get_did_for_namespace("com.whtwnd"),
409
+
Some("did:web:whtwnd.com".to_string())
410
+
);
411
+
assert!(registry.get_did_for_namespace("unknown.namespace").is_none());
412
+
}
413
+
}
+19
-6
src/lib.rs
+19
-6
src/lib.rs
···
1
1
pub mod api;
2
+
pub mod appview;
2
3
pub mod auth;
3
4
pub mod cache;
4
5
pub mod circuit_breaker;
···
48
49
.route(
49
50
"/xrpc/com.atproto.server.getSession",
50
51
get(api::server::get_session),
52
+
)
53
+
.route(
54
+
"/xrpc/com.bspds.account.listSessions",
55
+
get(api::server::list_sessions),
56
+
)
57
+
.route(
58
+
"/xrpc/com.bspds.account.revokeSession",
59
+
post(api::server::revoke_session),
51
60
)
52
61
.route(
53
62
"/xrpc/com.atproto.server.deleteSession",
···
161
170
get(api::admin::get_account_infos),
162
171
)
163
172
.route(
164
-
"/xrpc/com.bspds.admin.createProfile",
165
-
post(api::admin::create_profile),
166
-
)
167
-
.route(
168
-
"/xrpc/com.bspds.admin.createRecord",
169
-
post(api::admin::create_record_admin),
173
+
"/xrpc/com.atproto.admin.searchAccounts",
174
+
get(api::admin::search_accounts),
170
175
)
171
176
.route(
172
177
"/xrpc/com.atproto.server.activateAccount",
···
191
196
.route(
192
197
"/xrpc/com.atproto.server.resetPassword",
193
198
post(api::server::reset_password),
199
+
)
200
+
.route(
201
+
"/xrpc/com.bspds.account.changePassword",
202
+
post(api::server::change_password),
194
203
)
195
204
.route(
196
205
"/xrpc/com.atproto.server.requestEmailUpdate",
···
352
361
get(oauth::endpoints::oauth_authorization_server),
353
362
)
354
363
.route("/oauth/jwks", get(oauth::endpoints::oauth_jwks))
364
+
.route(
365
+
"/oauth/client-metadata.json",
366
+
get(oauth::endpoints::frontend_client_metadata),
367
+
)
355
368
.route(
356
369
"/oauth/par",
357
370
post(oauth::endpoints::pushed_authorization_request),
+37
src/oauth/endpoints/metadata.rs
+37
src/oauth/endpoints/metadata.rs
···
127
127
};
128
128
Json(create_jwk_set(vec![server_key]))
129
129
}
130
+
131
+
#[derive(Debug, Serialize, Deserialize)]
132
+
pub struct FrontendClientMetadata {
133
+
pub client_id: String,
134
+
pub client_name: String,
135
+
pub client_uri: String,
136
+
pub redirect_uris: Vec<String>,
137
+
pub grant_types: Vec<String>,
138
+
pub response_types: Vec<String>,
139
+
pub scope: String,
140
+
pub token_endpoint_auth_method: String,
141
+
pub application_type: String,
142
+
pub dpop_bound_access_tokens: bool,
143
+
}
144
+
145
+
pub async fn frontend_client_metadata(
146
+
State(_state): State<AppState>,
147
+
) -> Json<FrontendClientMetadata> {
148
+
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
149
+
let base_url = format!("https://{}", pds_hostname);
150
+
let client_id = format!("{}/oauth/client-metadata.json", base_url);
151
+
Json(FrontendClientMetadata {
152
+
client_id,
153
+
client_name: "PDS Account Manager".to_string(),
154
+
client_uri: base_url.clone(),
155
+
redirect_uris: vec![format!("{}/", base_url)],
156
+
grant_types: vec![
157
+
"authorization_code".to_string(),
158
+
"refresh_token".to_string(),
159
+
],
160
+
response_types: vec!["code".to_string()],
161
+
scope: "atproto transition:generic".to_string(),
162
+
token_endpoint_auth_method: "none".to_string(),
163
+
application_type: "web".to_string(),
164
+
dpop_bound_access_tokens: false,
165
+
})
166
+
}
+199
-253
src/oauth/templates.rs
+199
-253
src/oauth/templates.rs
···
1
1
use chrono::{DateTime, Utc};
2
2
3
+
fn format_scope_for_display(scope: Option<&str>) -> String {
4
+
let scope = scope.unwrap_or("");
5
+
if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") {
6
+
return "access your account".to_string();
7
+
}
8
+
let parts: Vec<&str> = scope.split_whitespace().collect();
9
+
let friendly: Vec<&str> = parts
10
+
.iter()
11
+
.filter_map(|s| {
12
+
match *s {
13
+
"atproto" | "transition:generic" | "transition:chat.bsky" => None,
14
+
"read" => Some("read your data"),
15
+
"write" => Some("write data"),
16
+
other => Some(other),
17
+
}
18
+
})
19
+
.collect();
20
+
if friendly.is_empty() {
21
+
"access your account".to_string()
22
+
} else {
23
+
friendly.join(", ")
24
+
}
25
+
}
26
+
3
27
fn base_styles() -> &'static str {
4
28
r#"
5
29
:root {
6
-
--primary: #0085ff;
7
-
--primary-hover: #0077e6;
8
-
--primary-contrast: #ffffff;
9
-
--primary-100: #dbeafe;
10
-
--primary-400: #60a5fa;
11
-
--primary-600-30: rgba(37, 99, 235, 0.3);
12
-
--contrast-0: #ffffff;
13
-
--contrast-25: #f8f9fa;
14
-
--contrast-50: #f1f3f5;
15
-
--contrast-100: #e9ecef;
16
-
--contrast-200: #dee2e6;
17
-
--contrast-300: #ced4da;
18
-
--contrast-400: #adb5bd;
19
-
--contrast-500: #6b7280;
20
-
--contrast-600: #4b5563;
21
-
--contrast-700: #374151;
22
-
--contrast-800: #1f2937;
23
-
--contrast-900: #111827;
24
-
--error: #dc2626;
25
-
--error-bg: #fef2f2;
26
-
--success: #059669;
27
-
--success-bg: #ecfdf5;
30
+
--bg-primary: #fafafa;
31
+
--bg-secondary: #f9f9f9;
32
+
--bg-card: #ffffff;
33
+
--bg-input: #ffffff;
34
+
--text-primary: #333333;
35
+
--text-secondary: #666666;
36
+
--text-muted: #999999;
37
+
--border-color: #dddddd;
38
+
--border-color-light: #cccccc;
39
+
--accent: #0066cc;
40
+
--accent-hover: #0052a3;
41
+
--success-bg: #dfd;
42
+
--success-border: #8c8;
43
+
--success-text: #060;
44
+
--error-bg: #fee;
45
+
--error-border: #fcc;
46
+
--error-text: #c00;
28
47
}
29
48
@media (prefers-color-scheme: dark) {
30
49
:root {
31
-
--contrast-0: #111827;
32
-
--contrast-25: #1f2937;
33
-
--contrast-50: #374151;
34
-
--contrast-100: #4b5563;
35
-
--contrast-200: #6b7280;
36
-
--contrast-300: #9ca3af;
37
-
--contrast-400: #d1d5db;
38
-
--contrast-500: #e5e7eb;
39
-
--contrast-600: #f3f4f6;
40
-
--contrast-700: #f9fafb;
41
-
--contrast-800: #ffffff;
42
-
--contrast-900: #ffffff;
43
-
--error-bg: #451a1a;
44
-
--success-bg: #064e3b;
50
+
--bg-primary: #1a1a1a;
51
+
--bg-secondary: #242424;
52
+
--bg-card: #2a2a2a;
53
+
--bg-input: #333333;
54
+
--text-primary: #e0e0e0;
55
+
--text-secondary: #a0a0a0;
56
+
--text-muted: #707070;
57
+
--border-color: #404040;
58
+
--border-color-light: #505050;
59
+
--accent: #4da6ff;
60
+
--accent-hover: #7abbff;
61
+
--success-bg: #1a3d1a;
62
+
--success-border: #2d5a2d;
63
+
--success-text: #7bc67b;
64
+
--error-bg: #3d1a1a;
65
+
--error-border: #5a2d2d;
66
+
--error-text: #ff7b7b;
45
67
}
46
68
}
47
69
* {
···
50
72
padding: 0;
51
73
}
52
74
body {
53
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
54
-
background: var(--contrast-50);
55
-
color: var(--contrast-900);
75
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
76
+
background: var(--bg-primary);
77
+
color: var(--text-primary);
56
78
min-height: 100vh;
57
-
display: flex;
58
-
align-items: center;
59
-
justify-content: center;
60
-
padding: 1rem;
61
79
line-height: 1.5;
62
80
}
63
81
.container {
64
-
width: 100%;
65
82
max-width: 400px;
66
-
}
67
-
.card {
68
-
background: var(--contrast-0);
69
-
border: 1px solid var(--contrast-100);
70
-
border-radius: 0.75rem;
71
-
padding: 1.5rem;
72
-
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
73
-
}
74
-
@media (prefers-color-scheme: dark) {
75
-
.card {
76
-
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
77
-
}
83
+
margin: 4rem auto;
84
+
padding: 2rem;
78
85
}
79
86
h1 {
80
-
font-size: 1.5rem;
87
+
margin: 0 0 0.5rem 0;
81
88
font-weight: 600;
82
-
color: var(--contrast-900);
83
-
margin-bottom: 0.5rem;
84
89
}
85
90
.subtitle {
86
-
color: var(--contrast-500);
87
-
font-size: 0.875rem;
88
-
margin-bottom: 1.5rem;
91
+
color: var(--text-secondary);
92
+
margin: 0 0 2rem 0;
89
93
}
90
94
.subtitle strong {
91
-
color: var(--contrast-700);
95
+
color: var(--text-primary);
92
96
}
93
97
.client-info {
94
-
background: var(--contrast-25);
95
-
border-radius: 0.5rem;
98
+
background: var(--bg-secondary);
99
+
border: 1px solid var(--border-color);
100
+
border-radius: 8px;
96
101
padding: 1rem;
97
102
margin-bottom: 1.5rem;
98
103
}
99
104
.client-info .client-name {
100
105
font-weight: 500;
101
-
color: var(--contrast-900);
106
+
color: var(--text-primary);
102
107
display: block;
103
108
margin-bottom: 0.25rem;
104
109
}
105
110
.client-info .scope {
106
-
color: var(--contrast-500);
111
+
color: var(--text-secondary);
107
112
font-size: 0.875rem;
108
113
}
109
114
.error-banner {
110
115
background: var(--error-bg);
111
-
color: var(--error);
112
-
border-radius: 0.5rem;
113
-
padding: 0.75rem 1rem;
116
+
border: 1px solid var(--error-border);
117
+
color: var(--error-text);
118
+
border-radius: 4px;
119
+
padding: 0.75rem;
114
120
margin-bottom: 1rem;
115
-
font-size: 0.875rem;
116
121
}
117
122
.form-group {
118
-
margin-bottom: 1.25rem;
123
+
margin-bottom: 1rem;
119
124
}
120
125
label {
121
126
display: block;
122
127
font-size: 0.875rem;
123
128
font-weight: 500;
124
-
color: var(--contrast-700);
125
-
margin-bottom: 0.375rem;
129
+
margin-bottom: 0.25rem;
126
130
}
127
131
input[type="text"],
128
132
input[type="email"],
129
133
input[type="password"] {
130
134
width: 100%;
131
-
padding: 0.625rem 0.875rem;
132
-
border: 2px solid var(--contrast-200);
133
-
border-radius: 0.375rem;
135
+
padding: 0.75rem;
136
+
border: 1px solid var(--border-color-light);
137
+
border-radius: 4px;
134
138
font-size: 1rem;
135
-
color: var(--contrast-900);
136
-
background: var(--contrast-0);
137
-
transition: border-color 0.15s, box-shadow 0.15s;
139
+
color: var(--text-primary);
140
+
background: var(--bg-input);
138
141
}
139
142
input[type="text"]:focus,
140
143
input[type="email"]:focus,
141
144
input[type="password"]:focus {
142
145
outline: none;
143
-
border-color: var(--primary);
144
-
box-shadow: 0 0 0 3px var(--primary-600-30);
146
+
border-color: var(--accent);
145
147
}
146
148
input[type="text"]::placeholder,
147
149
input[type="email"]::placeholder,
148
150
input[type="password"]::placeholder {
149
-
color: var(--contrast-400);
151
+
color: var(--text-muted);
150
152
}
151
153
.checkbox-group {
152
154
display: flex;
···
155
157
margin-bottom: 1.5rem;
156
158
}
157
159
.checkbox-group input[type="checkbox"] {
158
-
width: 1.125rem;
159
-
height: 1.125rem;
160
-
accent-color: var(--primary);
160
+
width: 1rem;
161
+
height: 1rem;
162
+
accent-color: var(--accent);
161
163
}
162
164
.checkbox-group label {
163
165
margin-bottom: 0;
164
166
font-weight: normal;
165
-
color: var(--contrast-600);
167
+
color: var(--text-secondary);
166
168
cursor: pointer;
167
169
}
168
170
.buttons {
···
171
173
}
172
174
.btn {
173
175
flex: 1;
174
-
padding: 0.625rem 1.25rem;
175
-
border-radius: 0.375rem;
176
+
padding: 0.75rem;
177
+
border-radius: 4px;
176
178
font-size: 1rem;
177
-
font-weight: 500;
178
179
cursor: pointer;
179
-
transition: background-color 0.15s, transform 0.1s;
180
180
border: none;
181
181
text-align: center;
182
182
text-decoration: none;
183
-
display: inline-flex;
184
-
align-items: center;
185
-
justify-content: center;
186
-
}
187
-
.btn:active {
188
-
transform: scale(0.98);
189
183
}
190
184
.btn-primary {
191
-
background: var(--primary);
192
-
color: var(--primary-contrast);
185
+
background: var(--accent);
186
+
color: white;
193
187
}
194
188
.btn-primary:hover {
195
-
background: var(--primary-hover);
189
+
background: var(--accent-hover);
196
190
}
197
191
.btn-primary:disabled {
198
-
background: var(--primary-400);
192
+
opacity: 0.6;
199
193
cursor: not-allowed;
200
194
}
201
195
.btn-secondary {
202
-
background: var(--contrast-200);
203
-
color: var(--contrast-800);
196
+
background: transparent;
197
+
color: var(--accent);
198
+
border: 1px solid var(--accent);
204
199
}
205
200
.btn-secondary:hover {
206
-
background: var(--contrast-300);
201
+
background: var(--accent);
202
+
color: white;
207
203
}
208
204
.footer {
209
205
text-align: center;
210
206
margin-top: 1.5rem;
211
207
font-size: 0.75rem;
212
-
color: var(--contrast-400);
208
+
color: var(--text-muted);
213
209
}
214
210
.accounts {
215
211
display: flex;
···
220
216
.account-item {
221
217
display: flex;
222
218
align-items: center;
223
-
gap: 0.75rem;
219
+
justify-content: space-between;
224
220
width: 100%;
225
-
padding: 0.75rem;
226
-
background: var(--contrast-25);
227
-
border: 1px solid var(--contrast-100);
228
-
border-radius: 0.5rem;
221
+
padding: 1rem;
222
+
background: var(--bg-card);
223
+
border: 1px solid var(--border-color);
224
+
border-radius: 8px;
229
225
cursor: pointer;
230
-
transition: background-color 0.15s, border-color 0.15s;
226
+
transition: border-color 0.15s, box-shadow 0.15s;
231
227
text-align: left;
232
228
}
233
229
.account-item:hover {
234
-
background: var(--contrast-50);
235
-
border-color: var(--contrast-200);
236
-
}
237
-
.avatar {
238
-
width: 2.5rem;
239
-
height: 2.5rem;
240
-
border-radius: 50%;
241
-
background: var(--primary);
242
-
color: var(--primary-contrast);
243
-
display: flex;
244
-
align-items: center;
245
-
justify-content: center;
246
-
font-weight: 600;
247
-
font-size: 0.875rem;
248
-
flex-shrink: 0;
230
+
border-color: var(--accent);
231
+
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
249
232
}
250
233
.account-info {
234
+
display: flex;
235
+
flex-direction: column;
236
+
gap: 0.25rem;
251
237
flex: 1;
252
238
min-width: 0;
253
239
}
254
240
.account-info .handle {
255
-
display: block;
256
241
font-weight: 500;
257
-
color: var(--contrast-900);
242
+
color: var(--text-primary);
258
243
overflow: hidden;
259
244
text-overflow: ellipsis;
260
245
white-space: nowrap;
261
246
}
262
-
.account-info .email {
263
-
display: block;
264
-
font-size: 0.875rem;
265
-
color: var(--contrast-500);
247
+
.account-info .did {
248
+
font-size: 0.75rem;
249
+
color: var(--text-muted);
250
+
font-family: monospace;
266
251
overflow: hidden;
267
252
text-overflow: ellipsis;
268
-
white-space: nowrap;
269
253
}
270
254
.chevron {
271
-
color: var(--contrast-400);
255
+
color: var(--text-muted);
272
256
font-size: 1.25rem;
273
257
flex-shrink: 0;
258
+
margin-left: 0.5rem;
274
259
}
275
260
.divider {
276
261
height: 1px;
277
-
background: var(--contrast-100);
262
+
background: var(--border-color);
278
263
margin: 1rem 0;
279
264
}
280
-
.link-button {
281
-
background: none;
282
-
border: none;
283
-
color: var(--primary);
284
-
cursor: pointer;
285
-
font-size: inherit;
286
-
padding: 0;
287
-
text-decoration: underline;
288
-
}
289
-
.link-button:hover {
290
-
color: var(--primary-hover);
291
-
}
292
265
.new-account-link {
293
266
display: block;
294
267
text-align: center;
295
-
color: var(--primary);
268
+
color: var(--accent);
296
269
text-decoration: none;
297
270
font-size: 0.875rem;
298
271
}
···
303
276
text-align: center;
304
277
margin-top: 1rem;
305
278
font-size: 0.875rem;
306
-
color: var(--contrast-500);
279
+
color: var(--text-secondary);
307
280
}
308
281
.icon {
309
282
font-size: 3rem;
···
311
284
}
312
285
.error-code {
313
286
background: var(--error-bg);
314
-
color: var(--error);
287
+
border: 1px solid var(--error-border);
288
+
color: var(--error-text);
315
289
padding: 0.5rem 1rem;
316
-
border-radius: 0.375rem;
290
+
border-radius: 4px;
317
291
font-family: monospace;
318
292
display: inline-block;
319
293
margin-bottom: 1rem;
···
323
297
height: 3rem;
324
298
border-radius: 50%;
325
299
background: var(--success-bg);
326
-
color: var(--success);
300
+
border: 1px solid var(--success-border);
301
+
color: var(--success-text);
327
302
display: flex;
328
303
align-items: center;
329
304
justify-content: center;
···
351
326
login_hint: Option<&str>,
352
327
) -> String {
353
328
let client_display = client_name.unwrap_or(client_id);
354
-
let scope_display = scope.unwrap_or("access your account");
329
+
let scope_display = format_scope_for_display(scope);
355
330
let error_html = error_message
356
331
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
357
332
.unwrap_or_default();
···
368
343
</head>
369
344
<body>
370
345
<div class="container">
371
-
<div class="card">
372
-
<h1>Sign in</h1>
373
-
<p class="subtitle">to continue to <strong>{client_display}</strong></p>
374
-
<div class="client-info">
375
-
<span class="client-name">{client_display}</span>
376
-
<span class="scope">wants to {scope_display}</span>
346
+
<h1>Sign In</h1>
347
+
<p class="subtitle">Sign in to continue to <strong>{client_display}</strong></p>
348
+
<div class="client-info">
349
+
<span class="client-name">{client_display}</span>
350
+
<span class="scope">wants to {scope_display}</span>
351
+
</div>
352
+
{error_html}
353
+
<form method="POST" action="/oauth/authorize">
354
+
<input type="hidden" name="request_uri" value="{request_uri}">
355
+
<div class="form-group">
356
+
<label for="username">Handle</label>
357
+
<input type="text" id="username" name="username" value="{login_hint_value}"
358
+
required autocomplete="username" autofocus
359
+
placeholder="your.handle">
377
360
</div>
378
-
{error_html}
379
-
<form method="POST" action="/oauth/authorize">
380
-
<input type="hidden" name="request_uri" value="{request_uri}">
381
-
<div class="form-group">
382
-
<label for="username">Handle or Email</label>
383
-
<input type="text" id="username" name="username" value="{login_hint_value}"
384
-
required autocomplete="username" autofocus
385
-
placeholder="you@example.com">
386
-
</div>
387
-
<div class="form-group">
388
-
<label for="password">Password</label>
389
-
<input type="password" id="password" name="password" required
390
-
autocomplete="current-password" placeholder="Enter your password">
391
-
</div>
392
-
<div class="checkbox-group">
393
-
<input type="checkbox" id="remember_device" name="remember_device" value="true">
394
-
<label for="remember_device">Remember this device</label>
395
-
</div>
396
-
<div class="buttons">
397
-
<button type="submit" class="btn btn-primary">Sign in</button>
398
-
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
399
-
</div>
400
-
</form>
401
-
<div class="footer">
402
-
By signing in, you agree to share your account information with this application.
361
+
<div class="form-group">
362
+
<label for="password">Password</label>
363
+
<input type="password" id="password" name="password" required
364
+
autocomplete="current-password" placeholder="Enter your password">
403
365
</div>
404
-
</div>
366
+
<div class="checkbox-group">
367
+
<input type="checkbox" id="remember_device" name="remember_device" value="true">
368
+
<label for="remember_device">Remember this device</label>
369
+
</div>
370
+
<div class="buttons">
371
+
<button type="submit" class="btn btn-primary">Sign In</button>
372
+
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
373
+
</div>
374
+
</form>
375
+
<p class="help-text">
376
+
By signing in, you agree to share your account information with this application.
377
+
</p>
405
378
</div>
406
379
</body>
407
380
</html>"#,
408
381
styles = base_styles(),
409
382
client_display = html_escape(client_display),
410
-
scope_display = html_escape(scope_display),
383
+
scope_display = html_escape(&scope_display),
411
384
request_uri = html_escape(request_uri),
412
385
error_html = error_html,
413
386
login_hint_value = html_escape(login_hint_value),
···
431
404
let accounts_html: String = accounts
432
405
.iter()
433
406
.map(|account| {
434
-
let initials = get_initials(&account.handle);
435
-
let email_display = account.email.as_deref().unwrap_or("");
436
407
format!(
437
408
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
438
409
<input type="hidden" name="request_uri" value="{request_uri}">
439
410
<input type="hidden" name="did" value="{did}">
440
411
<button type="submit" class="account-item">
441
-
<div class="avatar">{initials}</div>
442
412
<div class="account-info">
443
413
<span class="handle">@{handle}</span>
444
-
<span class="email">{email}</span>
414
+
<span class="did">{did}</span>
445
415
</div>
446
416
<span class="chevron">›</span>
447
417
</button>
448
418
</form>"#,
449
419
request_uri = html_escape(request_uri),
450
420
did = html_escape(&account.did),
451
-
initials = html_escape(&initials),
452
421
handle = html_escape(&account.handle),
453
-
email = html_escape(email_display),
454
422
)
455
423
})
456
424
.collect();
···
466
434
</head>
467
435
<body>
468
436
<div class="container">
469
-
<div class="card">
470
-
<h1>Choose an account</h1>
471
-
<p class="subtitle">to continue to <strong>{client_display}</strong></p>
472
-
<div class="accounts">
473
-
{accounts_html}
474
-
</div>
475
-
<div class="divider"></div>
476
-
<a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link">
477
-
Sign in with another account
478
-
</a>
437
+
<h1>Sign In</h1>
438
+
<p class="subtitle">Choose an account to continue to <strong>{client_display}</strong></p>
439
+
<div class="accounts">
440
+
{accounts_html}
479
441
</div>
442
+
<div class="divider"></div>
443
+
<a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link">
444
+
Sign in to another account
445
+
</a>
480
446
</div>
481
447
</body>
482
448
</html>"#,
···
493
459
.unwrap_or_default();
494
460
let (title, subtitle) = match channel {
495
461
"email" => (
496
-
"Check your email",
462
+
"Check Your Email",
497
463
"We sent a verification code to your email",
498
464
),
499
465
"Discord" => (
···
505
471
"We sent a verification code to your Telegram",
506
472
),
507
473
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
508
-
_ => ("Check your messages", "We sent you a verification code"),
474
+
_ => ("Check Your Messages", "We sent you a verification code"),
509
475
};
510
476
format!(
511
477
r#"<!DOCTYPE html>
···
519
485
</head>
520
486
<body>
521
487
<div class="container">
522
-
<div class="card">
523
-
<h1>{title}</h1>
524
-
<p class="subtitle">{subtitle}</p>
525
-
{error_html}
526
-
<form method="POST" action="/oauth/authorize/2fa">
527
-
<input type="hidden" name="request_uri" value="{request_uri}">
528
-
<div class="form-group">
529
-
<label for="code">Verification code</label>
530
-
<input type="text" id="code" name="code" class="code-input"
531
-
placeholder="000000"
532
-
pattern="[0-9]{{6}}" maxlength="6"
533
-
inputmode="numeric" autocomplete="one-time-code"
534
-
autofocus required>
535
-
</div>
536
-
<button type="submit" class="btn btn-primary" style="width:100%">Verify</button>
537
-
</form>
538
-
<p class="help-text">
539
-
Code expires in 10 minutes.
540
-
</p>
541
-
</div>
488
+
<h1>{title}</h1>
489
+
<p class="subtitle">{subtitle}</p>
490
+
{error_html}
491
+
<form method="POST" action="/oauth/authorize/2fa">
492
+
<input type="hidden" name="request_uri" value="{request_uri}">
493
+
<div class="form-group">
494
+
<label for="code">Verification Code</label>
495
+
<input type="text" id="code" name="code" class="code-input"
496
+
placeholder="000000"
497
+
pattern="[0-9]{{6}}" maxlength="6"
498
+
inputmode="numeric" autocomplete="one-time-code"
499
+
autofocus required>
500
+
</div>
501
+
<button type="submit" class="btn btn-primary" style="width:100%">Verify</button>
502
+
</form>
503
+
<p class="help-text">
504
+
Code expires in 10 minutes.
505
+
</p>
542
506
</div>
543
507
</body>
544
508
</html>"#,
···
564
528
<style>{styles}</style>
565
529
</head>
566
530
<body>
567
-
<div class="container">
568
-
<div class="card text-center">
569
-
<div class="icon">⚠️</div>
570
-
<h1>Authorization Failed</h1>
571
-
<div class="error-code">{error}</div>
572
-
<p class="subtitle" style="margin-bottom:0">{description}</p>
573
-
<div style="margin-top:1.5rem">
574
-
<button onclick="window.close()" class="btn btn-secondary">Close this window</button>
575
-
</div>
531
+
<div class="container text-center">
532
+
<h1>Authorization Failed</h1>
533
+
<div class="error-code">{error}</div>
534
+
<p class="subtitle" style="margin-bottom:0">{description}</p>
535
+
<div style="margin-top:1.5rem">
536
+
<button onclick="window.close()" class="btn btn-secondary" style="width:100%">Close this window</button>
576
537
</div>
577
538
</div>
578
539
</body>
···
596
557
<style>{styles}</style>
597
558
</head>
598
559
<body>
599
-
<div class="container">
600
-
<div class="card text-center">
601
-
<div class="success-icon">✓</div>
602
-
<h1 style="color:var(--success)">Authorization Successful</h1>
603
-
<p class="subtitle">{client_display} has been granted access to your account.</p>
604
-
<p class="help-text">You can close this window and return to the application.</p>
605
-
</div>
560
+
<div class="container text-center">
561
+
<div class="success-icon">✓</div>
562
+
<h1 style="color:var(--success-text)">Authorization Successful</h1>
563
+
<p class="subtitle">{client_display} has been granted access to your account.</p>
564
+
<p class="help-text">You can close this window and return to the application.</p>
606
565
</div>
607
566
</body>
608
567
</html>"#,
···
617
576
.replace('>', ">")
618
577
.replace('"', """)
619
578
.replace('\'', "'")
620
-
}
621
-
622
-
fn get_initials(handle: &str) -> String {
623
-
let clean = handle.trim_start_matches('@');
624
-
if clean.is_empty() {
625
-
return "?".to_string();
626
-
}
627
-
clean
628
-
.chars()
629
-
.next()
630
-
.unwrap_or('?')
631
-
.to_uppercase()
632
-
.to_string()
633
579
}
634
580
635
581
pub fn mask_email(email: &str) -> String {
+4
src/state.rs
+4
src/state.rs
···
1
+
use crate::appview::AppViewRegistry;
1
2
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
2
3
use crate::circuit_breaker::CircuitBreakers;
3
4
use crate::config::AuthConfig;
···
19
20
pub circuit_breakers: Arc<CircuitBreakers>,
20
21
pub cache: Arc<dyn Cache>,
21
22
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
23
+
pub appview_registry: Arc<AppViewRegistry>,
22
24
}
23
25
24
26
pub enum RateLimitKind {
···
85
87
let rate_limiters = Arc::new(RateLimiters::new());
86
88
let circuit_breakers = Arc::new(CircuitBreakers::new());
87
89
let (cache, distributed_rate_limiter) = create_cache().await;
90
+
let appview_registry = Arc::new(AppViewRegistry::new());
88
91
89
92
Self {
90
93
db,
···
95
98
circuit_breakers,
96
99
cache,
97
100
distributed_rate_limiter,
101
+
appview_registry,
98
102
}
99
103
}
100
104
+163
tests/admin_search.rs
+163
tests/admin_search.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
use common::*;
4
+
use helpers::*;
5
+
use reqwest::StatusCode;
6
+
use serde_json::Value;
7
+
8
+
#[tokio::test]
9
+
async fn test_search_accounts_as_admin() {
10
+
let client = client();
11
+
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
12
+
let (user_did, _) = setup_new_user("search-target").await;
13
+
let res = client
14
+
.get(format!(
15
+
"{}/xrpc/com.atproto.admin.searchAccounts",
16
+
base_url().await
17
+
))
18
+
.bearer_auth(&admin_jwt)
19
+
.send()
20
+
.await
21
+
.expect("Failed to send request");
22
+
assert_eq!(res.status(), StatusCode::OK);
23
+
let body: Value = res.json().await.unwrap();
24
+
let accounts = body["accounts"].as_array().expect("accounts should be array");
25
+
assert!(!accounts.is_empty(), "Should return some accounts");
26
+
let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did));
27
+
assert!(found, "Should find the created user in results");
28
+
}
29
+
30
+
#[tokio::test]
31
+
async fn test_search_accounts_with_handle_filter() {
32
+
let client = client();
33
+
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
34
+
let ts = chrono::Utc::now().timestamp_millis();
35
+
let unique_handle = format!("unique-handle-{}.test", ts);
36
+
let create_payload = serde_json::json!({
37
+
"handle": unique_handle,
38
+
"email": format!("unique-{}@searchtest.com", ts),
39
+
"password": "test-password-123"
40
+
});
41
+
let create_res = client
42
+
.post(format!(
43
+
"{}/xrpc/com.atproto.server.createAccount",
44
+
base_url().await
45
+
))
46
+
.json(&create_payload)
47
+
.send()
48
+
.await
49
+
.expect("Failed to create account");
50
+
assert_eq!(create_res.status(), StatusCode::OK);
51
+
let res = client
52
+
.get(format!(
53
+
"{}/xrpc/com.atproto.admin.searchAccounts?handle={}",
54
+
base_url().await,
55
+
unique_handle
56
+
))
57
+
.bearer_auth(&admin_jwt)
58
+
.send()
59
+
.await
60
+
.expect("Failed to send request");
61
+
assert_eq!(res.status(), StatusCode::OK);
62
+
let body: Value = res.json().await.unwrap();
63
+
let accounts = body["accounts"].as_array().unwrap();
64
+
assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle");
65
+
assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str()));
66
+
}
67
+
68
+
#[tokio::test]
69
+
async fn test_search_accounts_pagination() {
70
+
let client = client();
71
+
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
72
+
for i in 0..3 {
73
+
let _ = setup_new_user(&format!("search-page-{}", i)).await;
74
+
}
75
+
let res = client
76
+
.get(format!(
77
+
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2",
78
+
base_url().await
79
+
))
80
+
.bearer_auth(&admin_jwt)
81
+
.send()
82
+
.await
83
+
.expect("Failed to send request");
84
+
assert_eq!(res.status(), StatusCode::OK);
85
+
let body: Value = res.json().await.unwrap();
86
+
let accounts = body["accounts"].as_array().unwrap();
87
+
assert_eq!(accounts.len(), 2, "Should return exactly 2 accounts");
88
+
let cursor = body["cursor"].as_str();
89
+
assert!(cursor.is_some(), "Should have cursor for more results");
90
+
let res2 = client
91
+
.get(format!(
92
+
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2&cursor={}",
93
+
base_url().await,
94
+
cursor.unwrap()
95
+
))
96
+
.bearer_auth(&admin_jwt)
97
+
.send()
98
+
.await
99
+
.expect("Failed to send request");
100
+
assert_eq!(res2.status(), StatusCode::OK);
101
+
let body2: Value = res2.json().await.unwrap();
102
+
let accounts2 = body2["accounts"].as_array().unwrap();
103
+
assert!(!accounts2.is_empty(), "Should return more accounts after cursor");
104
+
let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect();
105
+
let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect();
106
+
for did in &second_page_dids {
107
+
assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs");
108
+
}
109
+
}
110
+
111
+
#[tokio::test]
112
+
async fn test_search_accounts_requires_admin() {
113
+
let client = client();
114
+
let (_, user_jwt) = setup_new_user("search-nonadmin").await;
115
+
let res = client
116
+
.get(format!(
117
+
"{}/xrpc/com.atproto.admin.searchAccounts",
118
+
base_url().await
119
+
))
120
+
.bearer_auth(&user_jwt)
121
+
.send()
122
+
.await
123
+
.expect("Failed to send request");
124
+
assert_eq!(res.status(), StatusCode::FORBIDDEN);
125
+
}
126
+
127
+
#[tokio::test]
128
+
async fn test_search_accounts_requires_auth() {
129
+
let client = client();
130
+
let res = client
131
+
.get(format!(
132
+
"{}/xrpc/com.atproto.admin.searchAccounts",
133
+
base_url().await
134
+
))
135
+
.send()
136
+
.await
137
+
.expect("Failed to send request");
138
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
139
+
}
140
+
141
+
#[tokio::test]
142
+
async fn test_search_accounts_returns_expected_fields() {
143
+
let client = client();
144
+
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
145
+
let _ = setup_new_user("search-fields").await;
146
+
let res = client
147
+
.get(format!(
148
+
"{}/xrpc/com.atproto.admin.searchAccounts?limit=1",
149
+
base_url().await
150
+
))
151
+
.bearer_auth(&admin_jwt)
152
+
.send()
153
+
.await
154
+
.expect("Failed to send request");
155
+
assert_eq!(res.status(), StatusCode::OK);
156
+
let body: Value = res.json().await.unwrap();
157
+
let accounts = body["accounts"].as_array().unwrap();
158
+
assert!(!accounts.is_empty());
159
+
let account = &accounts[0];
160
+
assert!(account["did"].as_str().is_some(), "Should have did");
161
+
assert!(account["handle"].as_str().is_some(), "Should have handle");
162
+
assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt");
163
+
}
+197
tests/change_password.rs
+197
tests/change_password.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
use common::*;
4
+
use helpers::*;
5
+
use reqwest::StatusCode;
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_change_password_success() {
10
+
let client = client();
11
+
let ts = chrono::Utc::now().timestamp_millis();
12
+
let handle = format!("change-pw-{}.test", ts);
13
+
let email = format!("change-pw-{}@test.com", ts);
14
+
let old_password = "old-password-123";
15
+
let new_password = "new-password-456";
16
+
let create_payload = json!({
17
+
"handle": handle,
18
+
"email": email,
19
+
"password": old_password
20
+
});
21
+
let create_res = client
22
+
.post(format!(
23
+
"{}/xrpc/com.atproto.server.createAccount",
24
+
base_url().await
25
+
))
26
+
.json(&create_payload)
27
+
.send()
28
+
.await
29
+
.expect("Failed to create account");
30
+
assert_eq!(create_res.status(), StatusCode::OK);
31
+
let create_body: Value = create_res.json().await.unwrap();
32
+
let did = create_body["did"].as_str().unwrap();
33
+
let jwt = verify_new_account(&client, did).await;
34
+
let change_res = client
35
+
.post(format!(
36
+
"{}/xrpc/com.bspds.account.changePassword",
37
+
base_url().await
38
+
))
39
+
.bearer_auth(&jwt)
40
+
.json(&json!({
41
+
"currentPassword": old_password,
42
+
"newPassword": new_password
43
+
}))
44
+
.send()
45
+
.await
46
+
.expect("Failed to change password");
47
+
assert_eq!(change_res.status(), StatusCode::OK);
48
+
let login_old = client
49
+
.post(format!(
50
+
"{}/xrpc/com.atproto.server.createSession",
51
+
base_url().await
52
+
))
53
+
.json(&json!({
54
+
"identifier": handle,
55
+
"password": old_password
56
+
}))
57
+
.send()
58
+
.await
59
+
.expect("Failed to try old password");
60
+
assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work");
61
+
let login_new = client
62
+
.post(format!(
63
+
"{}/xrpc/com.atproto.server.createSession",
64
+
base_url().await
65
+
))
66
+
.json(&json!({
67
+
"identifier": handle,
68
+
"password": new_password
69
+
}))
70
+
.send()
71
+
.await
72
+
.expect("Failed to try new password");
73
+
assert_eq!(login_new.status(), StatusCode::OK, "New password should work");
74
+
}
75
+
76
+
#[tokio::test]
77
+
async fn test_change_password_wrong_current() {
78
+
let client = client();
79
+
let (_, jwt) = setup_new_user("change-pw-wrong").await;
80
+
let res = client
81
+
.post(format!(
82
+
"{}/xrpc/com.bspds.account.changePassword",
83
+
base_url().await
84
+
))
85
+
.bearer_auth(&jwt)
86
+
.json(&json!({
87
+
"currentPassword": "wrong-password",
88
+
"newPassword": "new-password-123"
89
+
}))
90
+
.send()
91
+
.await
92
+
.expect("Failed to send request");
93
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
94
+
let body: Value = res.json().await.unwrap();
95
+
assert_eq!(body["error"].as_str(), Some("InvalidPassword"));
96
+
}
97
+
98
+
#[tokio::test]
99
+
async fn test_change_password_too_short() {
100
+
let client = client();
101
+
let ts = chrono::Utc::now().timestamp_millis();
102
+
let handle = format!("change-pw-short-{}.test", ts);
103
+
let email = format!("change-pw-short-{}@test.com", ts);
104
+
let password = "correct-password";
105
+
let create_payload = json!({
106
+
"handle": handle,
107
+
"email": email,
108
+
"password": password
109
+
});
110
+
let create_res = client
111
+
.post(format!(
112
+
"{}/xrpc/com.atproto.server.createAccount",
113
+
base_url().await
114
+
))
115
+
.json(&create_payload)
116
+
.send()
117
+
.await
118
+
.expect("Failed to create account");
119
+
assert_eq!(create_res.status(), StatusCode::OK);
120
+
let create_body: Value = create_res.json().await.unwrap();
121
+
let did = create_body["did"].as_str().unwrap();
122
+
let jwt = verify_new_account(&client, did).await;
123
+
let res = client
124
+
.post(format!(
125
+
"{}/xrpc/com.bspds.account.changePassword",
126
+
base_url().await
127
+
))
128
+
.bearer_auth(&jwt)
129
+
.json(&json!({
130
+
"currentPassword": password,
131
+
"newPassword": "short"
132
+
}))
133
+
.send()
134
+
.await
135
+
.expect("Failed to send request");
136
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
137
+
let body: Value = res.json().await.unwrap();
138
+
assert!(body["message"].as_str().unwrap().contains("8 characters"));
139
+
}
140
+
141
+
#[tokio::test]
142
+
async fn test_change_password_empty_current() {
143
+
let client = client();
144
+
let (_, jwt) = setup_new_user("change-pw-empty").await;
145
+
let res = client
146
+
.post(format!(
147
+
"{}/xrpc/com.bspds.account.changePassword",
148
+
base_url().await
149
+
))
150
+
.bearer_auth(&jwt)
151
+
.json(&json!({
152
+
"currentPassword": "",
153
+
"newPassword": "new-password-123"
154
+
}))
155
+
.send()
156
+
.await
157
+
.expect("Failed to send request");
158
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
159
+
}
160
+
161
+
#[tokio::test]
162
+
async fn test_change_password_empty_new() {
163
+
let client = client();
164
+
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
165
+
let res = client
166
+
.post(format!(
167
+
"{}/xrpc/com.bspds.account.changePassword",
168
+
base_url().await
169
+
))
170
+
.bearer_auth(&jwt)
171
+
.json(&json!({
172
+
"currentPassword": "e2e-password-123",
173
+
"newPassword": ""
174
+
}))
175
+
.send()
176
+
.await
177
+
.expect("Failed to send request");
178
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
179
+
}
180
+
181
+
#[tokio::test]
182
+
async fn test_change_password_requires_auth() {
183
+
let client = client();
184
+
let res = client
185
+
.post(format!(
186
+
"{}/xrpc/com.bspds.account.changePassword",
187
+
base_url().await
188
+
))
189
+
.json(&json!({
190
+
"currentPassword": "old",
191
+
"newPassword": "new-password-123"
192
+
}))
193
+
.send()
194
+
.await
195
+
.expect("Failed to send request");
196
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
197
+
}
+25
-2
tests/common/mod.rs
+25
-2
tests/common/mod.rs
···
137
137
}
138
138
let mock_server = MockServer::start().await;
139
139
setup_mock_appview(&mock_server).await;
140
+
let mock_uri = mock_server.uri();
141
+
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
142
+
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
143
+
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
140
144
unsafe {
141
-
std::env::set_var("APPVIEW_URL", mock_server.uri());
145
+
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
142
146
}
143
147
MOCK_APPVIEW.set(mock_server).ok();
144
148
spawn_app(database_url).await
···
186
190
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
187
191
let mock_server = MockServer::start().await;
188
192
setup_mock_appview(&mock_server).await;
193
+
let mock_uri = mock_server.uri();
194
+
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
195
+
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
196
+
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
189
197
unsafe {
190
-
std::env::set_var("APPVIEW_URL", mock_server.uri());
198
+
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
191
199
}
192
200
MOCK_APPVIEW.set(mock_server).ok();
193
201
S3_CONTAINER.set(s3_container).ok();
···
213
221
panic!(
214
222
"Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."
215
223
);
224
+
}
225
+
226
+
async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_endpoint: &str) {
227
+
Mock::given(method("GET"))
228
+
.and(path("/.well-known/did.json"))
229
+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
230
+
"id": did,
231
+
"service": [{
232
+
"id": "#atproto_appview",
233
+
"type": "AtprotoAppView",
234
+
"serviceEndpoint": service_endpoint
235
+
}]
236
+
})))
237
+
.mount(mock_server)
238
+
.await;
216
239
}
217
240
218
241
async fn setup_mock_appview(mock_server: &MockServer) {
+75
tests/oauth_client_metadata.rs
+75
tests/oauth_client_metadata.rs
···
1
+
mod common;
2
+
use common::*;
3
+
use reqwest::StatusCode;
4
+
use serde_json::Value;
5
+
6
+
#[tokio::test]
7
+
async fn test_frontend_client_metadata_returns_valid_json() {
8
+
let client = client();
9
+
let res = client
10
+
.get(format!(
11
+
"{}/oauth/client-metadata.json",
12
+
base_url().await
13
+
))
14
+
.send()
15
+
.await
16
+
.expect("Failed to send request");
17
+
assert_eq!(res.status(), StatusCode::OK);
18
+
let body: Value = res.json().await.expect("Should return valid JSON");
19
+
assert!(body["client_id"].as_str().is_some(), "Should have client_id");
20
+
assert!(body["client_name"].as_str().is_some(), "Should have client_name");
21
+
assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris");
22
+
assert!(body["grant_types"].as_array().is_some(), "Should have grant_types");
23
+
assert!(body["response_types"].as_array().is_some(), "Should have response_types");
24
+
assert!(body["scope"].as_str().is_some(), "Should have scope");
25
+
assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method");
26
+
}
27
+
28
+
#[tokio::test]
29
+
async fn test_frontend_client_metadata_correct_values() {
30
+
let client = client();
31
+
let res = client
32
+
.get(format!(
33
+
"{}/oauth/client-metadata.json",
34
+
base_url().await
35
+
))
36
+
.send()
37
+
.await
38
+
.expect("Failed to send request");
39
+
assert_eq!(res.status(), StatusCode::OK);
40
+
let body: Value = res.json().await.unwrap();
41
+
let client_id = body["client_id"].as_str().unwrap();
42
+
assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json");
43
+
let grant_types = body["grant_types"].as_array().unwrap();
44
+
let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect();
45
+
assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant");
46
+
assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant");
47
+
let response_types = body["response_types"].as_array().unwrap();
48
+
let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect();
49
+
assert!(response_strs.contains(&"code"), "Should support code response type");
50
+
assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)");
51
+
assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application");
52
+
assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP");
53
+
let scope = body["scope"].as_str().unwrap();
54
+
assert!(scope.contains("atproto"), "Scope should include atproto");
55
+
}
56
+
57
+
#[tokio::test]
58
+
async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
59
+
let client = client();
60
+
let res = client
61
+
.get(format!(
62
+
"{}/oauth/client-metadata.json",
63
+
base_url().await
64
+
))
65
+
.send()
66
+
.await
67
+
.expect("Failed to send request");
68
+
assert_eq!(res.status(), StatusCode::OK);
69
+
let body: Value = res.json().await.unwrap();
70
+
let client_uri = body["client_uri"].as_str().unwrap();
71
+
let redirect_uris = body["redirect_uris"].as_array().unwrap();
72
+
assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI");
73
+
let redirect_uri = redirect_uris[0].as_str().unwrap();
74
+
assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri");
75
+
}
+234
tests/session_management.rs
+234
tests/session_management.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
use common::*;
4
+
use helpers::*;
5
+
use reqwest::StatusCode;
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_list_sessions_returns_current_session() {
10
+
let client = client();
11
+
let (did, jwt) = setup_new_user("list-sessions").await;
12
+
let res = client
13
+
.get(format!(
14
+
"{}/xrpc/com.bspds.account.listSessions",
15
+
base_url().await
16
+
))
17
+
.bearer_auth(&jwt)
18
+
.send()
19
+
.await
20
+
.expect("Failed to send request");
21
+
assert_eq!(res.status(), StatusCode::OK);
22
+
let body: Value = res.json().await.unwrap();
23
+
let sessions = body["sessions"].as_array().expect("sessions should be array");
24
+
assert!(!sessions.is_empty(), "Should have at least one session");
25
+
let current = sessions.iter().find(|s| s["isCurrent"].as_bool() == Some(true));
26
+
assert!(current.is_some(), "Should have a current session marked");
27
+
let session = current.unwrap();
28
+
assert!(session["id"].as_str().is_some(), "Session should have id");
29
+
assert!(session["createdAt"].as_str().is_some(), "Session should have createdAt");
30
+
assert!(session["expiresAt"].as_str().is_some(), "Session should have expiresAt");
31
+
let _ = did;
32
+
}
33
+
34
+
#[tokio::test]
35
+
async fn test_list_sessions_multiple_sessions() {
36
+
let client = client();
37
+
let ts = chrono::Utc::now().timestamp_millis();
38
+
let handle = format!("multi-list-{}.test", ts);
39
+
let email = format!("multi-list-{}@test.com", ts);
40
+
let password = "test-password-123";
41
+
let create_payload = json!({
42
+
"handle": handle,
43
+
"email": email,
44
+
"password": password
45
+
});
46
+
let create_res = client
47
+
.post(format!(
48
+
"{}/xrpc/com.atproto.server.createAccount",
49
+
base_url().await
50
+
))
51
+
.json(&create_payload)
52
+
.send()
53
+
.await
54
+
.expect("Failed to create account");
55
+
assert_eq!(create_res.status(), StatusCode::OK);
56
+
let create_body: Value = create_res.json().await.unwrap();
57
+
let did = create_body["did"].as_str().unwrap();
58
+
let jwt1 = verify_new_account(&client, did).await;
59
+
let login_payload = json!({
60
+
"identifier": handle,
61
+
"password": password
62
+
});
63
+
let login_res = client
64
+
.post(format!(
65
+
"{}/xrpc/com.atproto.server.createSession",
66
+
base_url().await
67
+
))
68
+
.json(&login_payload)
69
+
.send()
70
+
.await
71
+
.expect("Failed to login");
72
+
assert_eq!(login_res.status(), StatusCode::OK);
73
+
let login_body: Value = login_res.json().await.unwrap();
74
+
let jwt2 = login_body["accessJwt"].as_str().unwrap();
75
+
let list_res = client
76
+
.get(format!(
77
+
"{}/xrpc/com.bspds.account.listSessions",
78
+
base_url().await
79
+
))
80
+
.bearer_auth(jwt2)
81
+
.send()
82
+
.await
83
+
.expect("Failed to list sessions");
84
+
assert_eq!(list_res.status(), StatusCode::OK);
85
+
let list_body: Value = list_res.json().await.unwrap();
86
+
let sessions = list_body["sessions"].as_array().unwrap();
87
+
assert!(sessions.len() >= 2, "Should have at least 2 sessions, got {}", sessions.len());
88
+
let _ = jwt1;
89
+
}
90
+
91
+
#[tokio::test]
92
+
async fn test_list_sessions_requires_auth() {
93
+
let client = client();
94
+
let res = client
95
+
.get(format!(
96
+
"{}/xrpc/com.bspds.account.listSessions",
97
+
base_url().await
98
+
))
99
+
.send()
100
+
.await
101
+
.expect("Failed to send request");
102
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
103
+
}
104
+
105
+
#[tokio::test]
106
+
async fn test_revoke_session_success() {
107
+
let client = client();
108
+
let ts = chrono::Utc::now().timestamp_millis();
109
+
let handle = format!("revoke-sess-{}.test", ts);
110
+
let email = format!("revoke-sess-{}@test.com", ts);
111
+
let password = "test-password-123";
112
+
let create_payload = json!({
113
+
"handle": handle,
114
+
"email": email,
115
+
"password": password
116
+
});
117
+
let create_res = client
118
+
.post(format!(
119
+
"{}/xrpc/com.atproto.server.createAccount",
120
+
base_url().await
121
+
))
122
+
.json(&create_payload)
123
+
.send()
124
+
.await
125
+
.expect("Failed to create account");
126
+
assert_eq!(create_res.status(), StatusCode::OK);
127
+
let create_body: Value = create_res.json().await.unwrap();
128
+
let did = create_body["did"].as_str().unwrap();
129
+
let jwt1 = verify_new_account(&client, did).await;
130
+
let login_payload = json!({
131
+
"identifier": handle,
132
+
"password": password
133
+
});
134
+
let login_res = client
135
+
.post(format!(
136
+
"{}/xrpc/com.atproto.server.createSession",
137
+
base_url().await
138
+
))
139
+
.json(&login_payload)
140
+
.send()
141
+
.await
142
+
.expect("Failed to login");
143
+
assert_eq!(login_res.status(), StatusCode::OK);
144
+
let login_body: Value = login_res.json().await.unwrap();
145
+
let jwt2 = login_body["accessJwt"].as_str().unwrap();
146
+
let list_res = client
147
+
.get(format!(
148
+
"{}/xrpc/com.bspds.account.listSessions",
149
+
base_url().await
150
+
))
151
+
.bearer_auth(jwt2)
152
+
.send()
153
+
.await
154
+
.expect("Failed to list sessions");
155
+
let list_body: Value = list_res.json().await.unwrap();
156
+
let sessions = list_body["sessions"].as_array().unwrap();
157
+
let other_session = sessions.iter().find(|s| s["isCurrent"].as_bool() != Some(true));
158
+
assert!(other_session.is_some(), "Should have another session to revoke");
159
+
let session_id = other_session.unwrap()["id"].as_str().unwrap();
160
+
let revoke_res = client
161
+
.post(format!(
162
+
"{}/xrpc/com.bspds.account.revokeSession",
163
+
base_url().await
164
+
))
165
+
.bearer_auth(jwt2)
166
+
.json(&json!({"sessionId": session_id}))
167
+
.send()
168
+
.await
169
+
.expect("Failed to revoke session");
170
+
assert_eq!(revoke_res.status(), StatusCode::OK);
171
+
let list_after_res = client
172
+
.get(format!(
173
+
"{}/xrpc/com.bspds.account.listSessions",
174
+
base_url().await
175
+
))
176
+
.bearer_auth(jwt2)
177
+
.send()
178
+
.await
179
+
.expect("Failed to list sessions after revoke");
180
+
let list_after_body: Value = list_after_res.json().await.unwrap();
181
+
let sessions_after = list_after_body["sessions"].as_array().unwrap();
182
+
let revoked_still_exists = sessions_after.iter().any(|s| s["id"].as_str() == Some(session_id));
183
+
assert!(!revoked_still_exists, "Revoked session should not appear in list");
184
+
let _ = jwt1;
185
+
}
186
+
187
+
#[tokio::test]
188
+
async fn test_revoke_session_invalid_id() {
189
+
let client = client();
190
+
let (_, jwt) = setup_new_user("revoke-invalid").await;
191
+
let res = client
192
+
.post(format!(
193
+
"{}/xrpc/com.bspds.account.revokeSession",
194
+
base_url().await
195
+
))
196
+
.bearer_auth(&jwt)
197
+
.json(&json!({"sessionId": "not-a-number"}))
198
+
.send()
199
+
.await
200
+
.expect("Failed to send request");
201
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
202
+
}
203
+
204
+
#[tokio::test]
205
+
async fn test_revoke_session_not_found() {
206
+
let client = client();
207
+
let (_, jwt) = setup_new_user("revoke-notfound").await;
208
+
let res = client
209
+
.post(format!(
210
+
"{}/xrpc/com.bspds.account.revokeSession",
211
+
base_url().await
212
+
))
213
+
.bearer_auth(&jwt)
214
+
.json(&json!({"sessionId": "999999999"}))
215
+
.send()
216
+
.await
217
+
.expect("Failed to send request");
218
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
219
+
}
220
+
221
+
#[tokio::test]
222
+
async fn test_revoke_session_requires_auth() {
223
+
let client = client();
224
+
let res = client
225
+
.post(format!(
226
+
"{}/xrpc/com.bspds.account.revokeSession",
227
+
base_url().await
228
+
))
229
+
.json(&json!({"sessionId": "1"}))
230
+
.send()
231
+
.await
232
+
.expect("Failed to send request");
233
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
234
+
}