+18
-3
.env.example
+18
-3
.env.example
···
48
# Optional: rotation key for PLC operations (defaults to user's key)
49
# PLC_ROTATION_KEY=did:key:...
50
# =============================================================================
51
-
# Federation
52
# =============================================================================
53
-
# Appview URL for proxying app.bsky.* requests
54
-
# APPVIEW_URL=https://api.bsky.app
55
# Comma-separated list of relay URLs to notify via requestCrawl
56
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
57
# =============================================================================
···
48
# Optional: rotation key for PLC operations (defaults to user's key)
49
# PLC_ROTATION_KEY=did:key:...
50
# =============================================================================
51
+
# AppView Federation
52
# =============================================================================
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
+
#
70
# Comma-separated list of relay URLs to notify via requestCrawl
71
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
72
# =============================================================================
+11
-5
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
+11
-5
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
···
1
{
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",
4
"describe": {
5
"columns": [
6
{
···
20
},
21
{
22
"ordinal": 3,
23
"name": "preferred_channel: crate::notifications::NotificationChannel",
24
"type_info": {
25
"Custom": {
···
36
}
37
},
38
{
39
-
"ordinal": 4,
40
"name": "discord_verified",
41
"type_info": "Bool"
42
},
43
{
44
-
"ordinal": 5,
45
"name": "telegram_verified",
46
"type_info": "Bool"
47
},
48
{
49
-
"ordinal": 6,
50
"name": "signal_verified",
51
"type_info": "Bool"
52
}
···
63
false,
64
false,
65
false,
66
false
67
]
68
},
69
-
"hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b"
70
}
···
1
{
2
"db_name": "PostgreSQL",
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
"describe": {
5
"columns": [
6
{
···
20
},
21
{
22
"ordinal": 3,
23
+
"name": "is_admin",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
"name": "preferred_channel: crate::notifications::NotificationChannel",
29
"type_info": {
30
"Custom": {
···
41
}
42
},
43
{
44
+
"ordinal": 5,
45
"name": "discord_verified",
46
"type_info": "Bool"
47
},
48
{
49
+
"ordinal": 6,
50
"name": "telegram_verified",
51
"type_info": "Bool"
52
},
53
{
54
+
"ordinal": 7,
55
"name": "signal_verified",
56
"type_info": "Bool"
57
}
···
68
false,
69
false,
70
false,
71
+
false,
72
false
73
]
74
},
75
+
"hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1"
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
import { initAuth, getAuthState } from './lib/auth.svelte'
4
import Login from './routes/Login.svelte'
5
import Register from './routes/Register.svelte'
6
import Dashboard from './routes/Dashboard.svelte'
7
import AppPasswords from './routes/AppPasswords.svelte'
8
import InviteCodes from './routes/InviteCodes.svelte'
9
import Settings from './routes/Settings.svelte'
10
import Notifications from './routes/Notifications.svelte'
11
import RepoExplorer from './routes/RepoExplorer.svelte'
12
13
const auth = getAuthState()
14
···
22
return Login
23
case '/register':
24
return Register
25
case '/dashboard':
26
return Dashboard
27
case '/app-passwords':
···
30
return InviteCodes
31
case '/settings':
32
return Settings
33
case '/notifications':
34
return Notifications
35
case '/repo':
36
return RepoExplorer
37
default:
38
return auth.session ? Dashboard : Login
39
}
···
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
import Login from './routes/Login.svelte'
5
import Register from './routes/Register.svelte'
6
+
import ResetPassword from './routes/ResetPassword.svelte'
7
import Dashboard from './routes/Dashboard.svelte'
8
import AppPasswords from './routes/AppPasswords.svelte'
9
import InviteCodes from './routes/InviteCodes.svelte'
10
import Settings from './routes/Settings.svelte'
11
+
import Sessions from './routes/Sessions.svelte'
12
import Notifications from './routes/Notifications.svelte'
13
import RepoExplorer from './routes/RepoExplorer.svelte'
14
+
import Admin from './routes/Admin.svelte'
15
16
const auth = getAuthState()
17
···
25
return Login
26
case '/register':
27
return Register
28
+
case '/reset-password':
29
+
return ResetPassword
30
case '/dashboard':
31
return Dashboard
32
case '/app-passwords':
···
35
return InviteCodes
36
case '/settings':
37
return Settings
38
+
case '/sessions':
39
+
return Sessions
40
case '/notifications':
41
return Notifications
42
case '/repo':
43
return RepoExplorer
44
+
case '/admin':
45
+
return Admin
46
default:
47
return auth.session ? Dashboard : Login
48
}
+147
frontend/src/lib/api.ts
+147
frontend/src/lib/api.ts
···
47
emailConfirmed?: boolean
48
preferredChannel?: string
49
preferredChannelVerified?: boolean
50
+
isAdmin?: boolean
51
accessJwt: string
52
refreshJwt: string
53
}
···
268
method: 'POST',
269
token,
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 },
417
})
418
},
419
+148
-4
frontend/src/lib/auth.svelte.ts
+148
-4
frontend/src/lib/auth.svelte.ts
···
1
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
2
3
const STORAGE_KEY = 'bspds_session'
4
5
interface AuthState {
6
session: Session | null
7
loading: boolean
8
error: string | null
9
}
10
11
let state = $state<AuthState>({
12
session: null,
13
loading: true,
14
error: null,
15
})
16
17
function saveSession(session: Session | null) {
···
34
return null
35
}
36
37
export async function initAuth() {
38
state.loading = true
39
state.error = null
40
const stored = loadSession()
41
if (stored) {
42
try {
43
const session = await api.getSession(stored.accessJwt)
44
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
45
} catch (e) {
46
if (e instanceof ApiError && e.status === 401) {
47
try {
48
-
const refreshed = await api.refreshSession(stored.refreshJwt)
49
-
state.session = refreshed
50
-
saveSession(refreshed)
51
} catch {
52
saveSession(null)
53
state.session = null
···
68
const session = await api.createSession(identifier, password)
69
state.session = session
70
saveSession(session)
71
} catch (e) {
72
if (e instanceof ApiError) {
73
state.error = e.message
···
80
}
81
}
82
83
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
84
try {
85
const result = await api.createAccount(params)
···
111
}
112
state.session = session
113
saveSession(session)
114
} catch (e) {
115
if (e instanceof ApiError) {
116
state.error = e.message
···
146
saveSession(null)
147
}
148
149
export function getAuthState() {
150
return state
151
}
···
158
return state.session !== null
159
}
160
161
-
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
162
state.session = newState.session
163
state.loading = newState.loading
164
state.error = newState.error
165
}
166
167
export function _testReset() {
168
state.session = null
169
state.loading = true
170
state.error = null
171
localStorage.removeItem(STORAGE_KEY)
172
}
···
1
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
2
+
import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth'
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
+
}
13
14
interface AuthState {
15
session: Session | null
16
loading: boolean
17
error: string | null
18
+
savedAccounts: SavedAccount[]
19
}
20
21
let state = $state<AuthState>({
22
session: null,
23
loading: true,
24
error: null,
25
+
savedAccounts: [],
26
})
27
28
function saveSession(session: Session | null) {
···
45
return null
46
}
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
+
88
export async function initAuth() {
89
state.loading = true
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
+
116
const stored = loadSession()
117
if (stored) {
118
try {
119
const session = await api.getSession(stored.accessJwt)
120
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
121
+
addOrUpdateSavedAccount(state.session)
122
} catch (e) {
123
if (e instanceof ApiError && e.status === 401) {
124
try {
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)
135
} catch {
136
saveSession(null)
137
state.session = null
···
152
const session = await api.createSession(identifier, password)
153
state.session = session
154
saveSession(session)
155
+
addOrUpdateSavedAccount(session)
156
} catch (e) {
157
if (e instanceof ApiError) {
158
state.error = e.message
···
165
}
166
}
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
+
180
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
181
try {
182
const result = await api.createAccount(params)
···
208
}
209
state.session = session
210
saveSession(session)
211
+
addOrUpdateSavedAccount(session)
212
} catch (e) {
213
if (e instanceof ApiError) {
214
state.error = e.message
···
244
saveSession(null)
245
}
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
+
290
export function getAuthState() {
291
return state
292
}
···
299
return state.session !== null
300
}
301
302
+
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) {
303
state.session = newState.session
304
state.loading = newState.loading
305
state.error = newState.error
306
+
state.savedAccounts = newState.savedAccounts ?? []
307
}
308
309
export function _testReset() {
310
state.session = null
311
state.loading = true
312
state.error = null
313
+
state.savedAccounts = []
314
localStorage.removeItem(STORAGE_KEY)
315
+
localStorage.removeItem(ACCOUNTS_KEY)
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
<script lang="ts">
2
-
import { getAuthState, logout } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
const auth = getAuthState()
5
$effect(() => {
6
if (!auth.loading && !auth.session) {
7
navigate('/login')
···
11
await logout()
12
navigate('/login')
13
}
14
</script>
15
{#if auth.session}
16
<div class="dashboard">
17
<header>
18
<h1>Dashboard</h1>
19
-
<button class="logout" onclick={handleLogout}>Sign Out</button>
20
</header>
21
<section class="account-overview">
22
<h2>Account Overview</h2>
23
<dl>
24
<dt>Handle</dt>
25
-
<dd>@{auth.session.handle}</dd>
26
<dt>DID</dt>
27
<dd class="mono">{auth.session.did}</dd>
28
{#if auth.session.preferredChannel}
···
63
<h3>App Passwords</h3>
64
<p>Manage passwords for third-party apps</p>
65
</a>
66
<a href="#/invite-codes" class="nav-card">
67
<h3>Invite Codes</h3>
68
<p>View and create invite codes</p>
···
79
<h3>Repository Explorer</h3>
80
<p>Browse and manage raw AT Protocol records</p>
81
</a>
82
</nav>
83
</div>
84
{:else if auth.loading}
···
99
header h1 {
100
margin: 0;
101
}
102
-
.logout {
103
padding: 0.5rem 1rem;
104
background: transparent;
105
border: 1px solid var(--border-color-light);
···
107
cursor: pointer;
108
color: var(--text-primary);
109
}
110
-
.logout:hover {
111
background: var(--bg-secondary);
112
}
113
section {
114
background: var(--bg-secondary);
115
padding: 1.5rem;
···
153
background: var(--warning-bg);
154
color: var(--warning-text);
155
}
156
.nav-grid {
157
display: grid;
158
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
180
margin: 0;
181
color: var(--text-secondary);
182
font-size: 0.875rem;
183
}
184
.loading {
185
text-align: center;
···
1
<script lang="ts">
2
+
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
const auth = getAuthState()
5
+
let dropdownOpen = $state(false)
6
+
let switching = $state(false)
7
$effect(() => {
8
if (!auth.loading && !auth.session) {
9
navigate('/login')
···
13
await logout()
14
navigate('/login')
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
+
)
45
</script>
46
{#if auth.session}
47
<div class="dashboard">
48
<header>
49
<h1>Dashboard</h1>
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>
86
</header>
87
<section class="account-overview">
88
<h2>Account Overview</h2>
89
<dl>
90
<dt>Handle</dt>
91
+
<dd>
92
+
@{auth.session.handle}
93
+
{#if auth.session.isAdmin}
94
+
<span class="badge admin">Admin</span>
95
+
{/if}
96
+
</dd>
97
<dt>DID</dt>
98
<dd class="mono">{auth.session.did}</dd>
99
{#if auth.session.preferredChannel}
···
134
<h3>App Passwords</h3>
135
<p>Manage passwords for third-party apps</p>
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>
141
<a href="#/invite-codes" class="nav-card">
142
<h3>Invite Codes</h3>
143
<p>View and create invite codes</p>
···
154
<h3>Repository Explorer</h3>
155
<p>Browse and manage raw AT Protocol records</p>
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}
163
</nav>
164
</div>
165
{:else if auth.loading}
···
180
header h1 {
181
margin: 0;
182
}
183
+
.account-dropdown {
184
+
position: relative;
185
+
}
186
+
.account-trigger {
187
+
display: flex;
188
+
align-items: center;
189
+
gap: 0.5rem;
190
padding: 0.5rem 1rem;
191
background: transparent;
192
border: 1px solid var(--border-color-light);
···
194
cursor: pointer;
195
color: var(--text-primary);
196
}
197
+
.account-trigger:hover:not(:disabled) {
198
background: var(--bg-secondary);
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
+
}
257
section {
258
background: var(--bg-secondary);
259
padding: 1.5rem;
···
297
background: var(--warning-bg);
298
color: var(--warning-text);
299
}
300
+
.badge.admin {
301
+
background: var(--accent);
302
+
color: white;
303
+
}
304
.nav-grid {
305
display: grid;
306
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
328
margin: 0;
329
color: var(--text-secondary);
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);
338
}
339
.loading {
340
text-align: center;
+149
-59
frontend/src/routes/Login.svelte
+149
-59
frontend/src/routes/Login.svelte
···
1
<script lang="ts">
2
-
import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
-
import { ApiError } from '../lib/api'
5
-
let identifier = $state('')
6
-
let password = $state('')
7
let submitting = $state(false)
8
-
let error = $state<string | null>(null)
9
let pendingVerification = $state<{ did: string } | null>(null)
10
let verificationCode = $state('')
11
let resendingCode = $state(false)
12
let resendMessage = $state<string | null>(null)
13
const auth = getAuthState()
14
$effect(() => {
15
if (auth.session) {
16
navigate('/dashboard')
17
}
18
})
19
-
async function handleSubmit(e: Event) {
20
-
e.preventDefault()
21
-
if (!identifier || !password) return
22
submitting = true
23
-
error = null
24
-
pendingVerification = null
25
try {
26
-
await login(identifier, password)
27
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 {
39
submitting = false
40
}
41
}
···
43
e.preventDefault()
44
if (!pendingVerification || !verificationCode.trim()) return
45
submitting = true
46
-
error = null
47
try {
48
await confirmSignup(pendingVerification.did, verificationCode.trim())
49
navigate('/dashboard')
50
-
} catch (e: any) {
51
-
error = e.message || 'Verification failed'
52
-
} finally {
53
submitting = false
54
}
55
}
···
57
if (!pendingVerification || resendingCode) return
58
resendingCode = true
59
resendMessage = null
60
-
error = null
61
try {
62
await resendVerification(pendingVerification.did)
63
resendMessage = 'Verification code resent!'
64
-
} catch (e: any) {
65
-
error = e.message || 'Failed to resend code'
66
} finally {
67
resendingCode = false
68
}
···
70
function backToLogin() {
71
pendingVerification = null
72
verificationCode = ''
73
-
error = null
74
resendMessage = null
75
}
76
</script>
77
<div class="login-container">
78
-
{#if error}
79
-
<div class="error">{error}</div>
80
{/if}
81
{#if pendingVerification}
82
<h1>Verify Your Account</h1>
···
111
Back to Login
112
</button>
113
</form>
114
{:else}
115
<h1>Sign In</h1>
116
<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'}
142
</button>
143
-
</form>
144
<p class="register-link">
145
Don't have an account? <a href="#/register">Create one</a>
146
</p>
···
219
button.tertiary:hover:not(:disabled) {
220
color: var(--text-primary);
221
}
222
.error {
223
padding: 0.75rem;
224
background: var(--error-bg);
···
233
border-radius: 4px;
234
color: var(--success-text);
235
}
236
.register-link {
237
text-align: center;
238
-
margin-top: 1.5rem;
239
color: var(--text-secondary);
240
}
241
.register-link a {
242
color: var(--accent);
243
}
244
</style>
···
1
<script lang="ts">
2
+
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
let submitting = $state(false)
5
let pendingVerification = $state<{ did: string } | null>(null)
6
let verificationCode = $state('')
7
let resendingCode = $state(false)
8
let resendMessage = $state<string | null>(null)
9
+
let showNewLogin = $state(false)
10
const auth = getAuthState()
11
$effect(() => {
12
if (auth.session) {
13
navigate('/dashboard')
14
}
15
})
16
+
async function handleSwitchAccount(did: string) {
17
submitting = true
18
try {
19
+
await switchAccount(did)
20
navigate('/dashboard')
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 {
34
submitting = false
35
}
36
}
···
38
e.preventDefault()
39
if (!pendingVerification || !verificationCode.trim()) return
40
submitting = true
41
try {
42
await confirmSignup(pendingVerification.did, verificationCode.trim())
43
navigate('/dashboard')
44
+
} catch {
45
submitting = false
46
}
47
}
···
49
if (!pendingVerification || resendingCode) return
50
resendingCode = true
51
resendMessage = null
52
try {
53
await resendVerification(pendingVerification.did)
54
resendMessage = 'Verification code resent!'
55
+
} catch {
56
+
resendMessage = null
57
} finally {
58
resendingCode = false
59
}
···
61
function backToLogin() {
62
pendingVerification = null
63
verificationCode = ''
64
resendMessage = null
65
}
66
</script>
67
<div class="login-container">
68
+
{#if auth.error}
69
+
<div class="error">{auth.error}</div>
70
{/if}
71
{#if pendingVerification}
72
<h1>Verify Your Account</h1>
···
101
Back to Login
102
</button>
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>
138
{:else}
139
<h1>Sign In</h1>
140
<p class="subtitle">Sign in to manage your PDS account</p>
141
+
{#if auth.savedAccounts.length > 0}
142
+
<button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}>
143
+
← Back to saved accounts
144
</button>
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>
152
<p class="register-link">
153
Don't have an account? <a href="#/register">Create one</a>
154
</p>
···
227
button.tertiary:hover:not(:disabled) {
228
color: var(--text-primary);
229
}
230
+
.oauth-btn {
231
+
width: 100%;
232
+
padding: 1rem;
233
+
font-size: 1.125rem;
234
+
font-weight: 500;
235
+
}
236
.error {
237
padding: 0.75rem;
238
background: var(--error-bg);
···
247
border-radius: 4px;
248
color: var(--success-text);
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
+
}
259
.register-link {
260
text-align: center;
261
+
margin-top: 0.5rem;
262
color: var(--text-secondary);
263
}
264
.register-link a {
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;
333
}
334
</style>
+269
frontend/src/routes/Notifications.svelte
+269
frontend/src/routes/Notifications.svelte
···
15
let telegramVerified = $state(false)
16
let signalNumber = $state('')
17
let signalVerified = $state(false)
18
$effect(() => {
19
if (!auth.loading && !auth.session) {
20
navigate('/login')
···
66
saving = false
67
}
68
}
69
const channels = [
70
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
71
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
···
77
if (channelId === 'discord') return !!discordId
78
if (channelId === 'telegram') return !!telegramUsername
79
if (channelId === 'signal') return !!signalNumber
80
return false
81
}
82
</script>
···
157
<span class="status verified">Verified</span>
158
{:else}
159
<span class="status unverified">Not verified</span>
160
{/if}
161
{/if}
162
</div>
163
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
164
</div>
165
<div class="config-item">
166
<label for="telegram">Telegram Username</label>
···
177
<span class="status verified">Verified</span>
178
{:else}
179
<span class="status unverified">Not verified</span>
180
{/if}
181
{/if}
182
</div>
183
<p class="config-hint">Your Telegram username without the @ symbol</p>
184
</div>
185
<div class="config-item">
186
<label for="signal">Signal Phone Number</label>
···
197
<span class="status verified">Verified</span>
198
{:else}
199
<span class="status unverified">Not verified</span>
200
{/if}
201
{/if}
202
</div>
203
<p class="config-hint">Your Signal phone number with country code</p>
204
</div>
205
</div>
206
</section>
207
<div class="actions">
208
<button type="submit" disabled={saving}>
···
210
</button>
211
</div>
212
</form>
213
{/if}
214
</div>
215
<style>
···
388
.actions button:disabled {
389
opacity: 0.6;
390
cursor: not-allowed;
391
}
392
</style>
···
15
let telegramVerified = $state(false)
16
let signalNumber = $state('')
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)
33
$effect(() => {
34
if (!auth.loading && !auth.session) {
35
navigate('/login')
···
81
saving = false
82
}
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
+
}
115
const channels = [
116
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
117
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
···
123
if (channelId === 'discord') return !!discordId
124
if (channelId === 'telegram') return !!telegramUsername
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
132
return false
133
}
134
</script>
···
209
<span class="status verified">Verified</span>
210
{:else}
211
<span class="status unverified">Not verified</span>
212
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button>
213
{/if}
214
{/if}
215
</div>
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}
229
</div>
230
<div class="config-item">
231
<label for="telegram">Telegram Username</label>
···
242
<span class="status verified">Verified</span>
243
{:else}
244
<span class="status unverified">Not verified</span>
245
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button>
246
{/if}
247
{/if}
248
</div>
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}
262
</div>
263
<div class="config-item">
264
<label for="signal">Signal Phone Number</label>
···
275
<span class="status verified">Verified</span>
276
{:else}
277
<span class="status unverified">Not verified</span>
278
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button>
279
{/if}
280
{/if}
281
</div>
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}
295
</div>
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}
303
</section>
304
<div class="actions">
305
<button type="submit" disabled={saving}>
···
307
</button>
308
</div>
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>
343
{/if}
344
</div>
345
<style>
···
518
.actions button:disabled {
519
opacity: 0.6;
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;
660
}
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
let deletePassword = $state('')
15
let deleteToken = $state('')
16
let deleteTokenSent = $state(false)
17
$effect(() => {
18
if (!auth.loading && !auth.session) {
19
navigate('/login')
···
110
deleteLoading = false
111
}
112
}
113
</script>
114
<div class="page">
115
<header>
···
187
</button>
188
</form>
189
</section>
190
<section class="danger-zone">
191
<h2>Delete Account</h2>
192
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
···
275
margin: 0 0 0.5rem 0;
276
font-size: 1.125rem;
277
}
278
-
.current {
279
color: var(--text-secondary);
280
font-size: 0.875rem;
281
margin-bottom: 1rem;
···
14
let deletePassword = $state('')
15
let deleteToken = $state('')
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('')
22
$effect(() => {
23
if (!auth.loading && !auth.session) {
24
navigate('/login')
···
115
deleteLoading = false
116
}
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
+
}
173
</script>
174
<div class="page">
175
<header>
···
247
</button>
248
</form>
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>
299
<section class="danger-zone">
300
<h2>Delete Account</h2>
301
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
···
384
margin: 0 0 0.5rem 0;
385
font-size: 1.125rem;
386
}
387
+
.current, .description {
388
color: var(--text-secondary);
389
font-size: 0.875rem;
390
margin-bottom: 1rem;
+52
-19
src/api/actor/profile.rs
+52
-19
src/api/actor/profile.rs
···
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
-
extract::{Query, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
···
15
#[derive(Deserialize)]
16
pub struct GetProfileParams {
17
pub actor: String,
18
-
}
19
-
20
-
#[derive(Deserialize)]
21
-
pub struct GetProfilesParams {
22
-
pub actors: String,
23
}
24
25
#[derive(Serialize, Deserialize, Clone)]
···
71
}
72
73
async fn proxy_to_appview(
74
method: &str,
75
params: &HashMap<String, String>,
76
auth_did: &str,
77
auth_key_bytes: Option<&[u8]>,
78
) -> Result<(StatusCode, Value), Response> {
79
-
let appview_url = match std::env::var("APPVIEW_URL") {
80
-
Ok(url) => url,
81
-
Err(_) => {
82
return Err((
83
StatusCode::BAD_GATEWAY,
84
Json(
···
88
.into_response());
89
}
90
};
91
-
let target_url = format!("{}/xrpc/{}", appview_url, method);
92
info!("Proxying GET request to {}", target_url);
93
let client = proxy_client();
94
-
let mut request_builder = client.get(&target_url).query(params);
95
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) {
99
Ok(service_token) => {
100
request_builder =
101
request_builder.header("Authorization", format!("Bearer {}", service_token));
···
167
let mut query_params = HashMap::new();
168
query_params.insert("actor".to_string(), params.actor.clone());
169
let (status, body) = match proxy_to_appview(
170
"app.bsky.actor.getProfile",
171
&query_params,
172
auth_did.as_deref().unwrap_or(""),
···
201
pub async fn get_profiles(
202
State(state): State<AppState>,
203
headers: axum::http::HeaderMap,
204
-
Query(params): Query<GetProfilesParams>,
205
) -> Response {
206
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
207
let auth_user = if let Some(h) = auth_header {
···
217
};
218
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
219
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(
223
"app.bsky.actor.getProfiles",
224
-
&query_params,
225
auth_did.as_deref().unwrap_or(""),
226
auth_key_bytes.as_deref(),
227
)
···
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
+
extract::{Query, RawQuery, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
···
15
#[derive(Deserialize)]
16
pub struct GetProfileParams {
17
pub actor: String,
18
}
19
20
#[derive(Serialize, Deserialize, Clone)]
···
66
}
67
68
async fn proxy_to_appview(
69
+
state: &AppState,
70
method: &str,
71
params: &HashMap<String, String>,
72
auth_did: &str,
73
auth_key_bytes: Option<&[u8]>,
74
) -> Result<(StatusCode, Value), Response> {
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 => {
104
return Err((
105
StatusCode::BAD_GATEWAY,
106
Json(
···
110
.into_response());
111
}
112
};
113
+
let target_url = match raw_query {
114
+
Some(q) => format!("{}/xrpc/{}?{}", resolved.url, method, q),
115
+
None => format!("{}/xrpc/{}", resolved.url, method),
116
+
};
117
info!("Proxying GET request to {}", target_url);
118
let client = proxy_client();
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> {
130
if let Some(key_bytes) = auth_key_bytes {
131
+
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
132
Ok(service_token) => {
133
request_builder =
134
request_builder.header("Authorization", format!("Bearer {}", service_token));
···
200
let mut query_params = HashMap::new();
201
query_params.insert("actor".to_string(), params.actor.clone());
202
let (status, body) = match proxy_to_appview(
203
+
&state,
204
"app.bsky.actor.getProfile",
205
&query_params,
206
auth_did.as_deref().unwrap_or(""),
···
235
pub async fn get_profiles(
236
State(state): State<AppState>,
237
headers: axum::http::HeaderMap,
238
+
RawQuery(raw_query): RawQuery,
239
) -> Response {
240
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
241
let auth_user = if let Some(h) = auth_header {
···
251
};
252
let auth_did = auth_user.as_ref().map(|u| u.did.clone());
253
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
254
+
let (status, body) = match proxy_to_appview_raw(
255
+
&state,
256
"app.bsky.actor.getProfiles",
257
+
raw_query.as_deref(),
258
auth_did.as_deref().unwrap_or(""),
259
auth_key_bytes.as_deref(),
260
)
+21
-7
src/api/admin/account/info.rs
+21
-7
src/api/admin/account/info.rs
···
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
-
extract::{Query, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
···
88
}
89
}
90
91
-
#[derive(Deserialize)]
92
-
pub struct GetAccountInfosParams {
93
-
pub dids: String,
94
}
95
96
pub async fn get_account_infos(
97
State(state): State<AppState>,
98
_auth: BearerAuthAdmin,
99
-
Query(params): Query<GetAccountInfosParams>,
100
) -> Response {
101
-
let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
102
if dids.is_empty() {
103
return (
104
StatusCode::BAD_REQUEST,
···
107
.into_response();
108
}
109
let mut infos = Vec::new();
110
-
for did in dids {
111
if did.is_empty() {
112
continue;
113
}
···
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
+
extract::{Query, RawQuery, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
···
88
}
89
}
90
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()
108
}
109
110
pub async fn get_account_infos(
111
State(state): State<AppState>,
112
_auth: BearerAuthAdmin,
113
+
RawQuery(raw_query): RawQuery,
114
) -> Response {
115
+
let dids = parse_repeated_param(raw_query.as_deref(), "dids");
116
if dids.is_empty() {
117
return (
118
StatusCode::BAD_REQUEST,
···
121
.into_response();
122
}
123
let mut infos = Vec::new();
124
+
for did in &dids {
125
if did.is_empty() {
126
continue;
127
}
+3
-7
src/api/admin/account/mod.rs
+3
-7
src/api/admin/account/mod.rs
···
1
mod delete;
2
mod email;
3
mod info;
4
-
mod profile;
5
mod update;
6
7
pub use delete::{DeleteAccountInput, delete_account};
8
pub use email::{SendEmailInput, SendEmailOutput, send_email};
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,
16
};
17
pub use update::{
18
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
19
update_account_email, update_account_handle, update_account_password,
···
1
mod delete;
2
mod email;
3
mod info;
4
+
mod search;
5
mod update;
6
7
pub use delete::{DeleteAccountInput, delete_account};
8
pub use email::{SendEmailInput, SendEmailOutput, send_email};
9
pub use info::{
10
+
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, get_account_info, get_account_infos,
11
};
12
+
pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts};
13
pub use update::{
14
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
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
pub mod status;
5
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,
9
};
10
pub use invite::{
11
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
···
4
pub mod status;
5
6
pub use account::{
7
+
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
8
+
update_account_email, update_account_handle, update_account_password,
9
};
10
pub use invite::{
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
use crate::api::read_after_write::{
2
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
3
-
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
4
};
5
use crate::state::AppState;
6
use axum::{
···
87
if let Some(cursor) = ¶ms.cursor {
88
query_params.insert("cursor".to_string(), cursor.clone());
89
}
90
-
let proxy_result = match proxy_to_appview(
91
"app.bsky.feed.getActorLikes",
92
&query_params,
93
auth_did.as_deref().unwrap_or(""),
···
1
use crate::api::read_after_write::{
2
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
3
+
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
4
};
5
use crate::state::AppState;
6
use axum::{
···
87
if let Some(cursor) = ¶ms.cursor {
88
query_params.insert("cursor".to_string(), cursor.clone());
89
}
90
+
let proxy_result = match proxy_to_appview_via_registry(
91
+
&state,
92
"app.bsky.feed.getActorLikes",
93
&query_params,
94
auth_did.as_deref().unwrap_or(""),
+7
-9
src/api/feed/custom_feed.rs
+7
-9
src/api/feed/custom_feed.rs
···
37
if let Err(e) = validate_at_uri(¶ms.feed) {
38
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
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())
44
.into_response();
45
}
46
};
47
-
if let Err(e) = is_ssrf_safe(&appview_url) {
48
error!("SSRF check failed for appview URL: {}", e);
49
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
50
.into_response();
···
56
if let Some(cursor) = ¶ms.cursor {
57
query_params.insert("cursor".to_string(), cursor.clone());
58
}
59
-
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", appview_url);
60
info!(target = %target_url, feed = %params.feed, "Proxying getFeed request");
61
let client = proxy_client();
62
let mut request_builder = client.get(&target_url).query(&query_params);
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
match crate::auth::create_service_token(
67
&auth_user.did,
68
-
&appview_did,
69
"app.bsky.feed.getFeed",
70
key_bytes,
71
) {
···
37
if let Err(e) = validate_at_uri(¶ms.feed) {
38
return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response();
39
}
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
.into_response();
45
}
46
};
47
+
if let Err(e) = is_ssrf_safe(&resolved.url) {
48
error!("SSRF check failed for appview URL: {}", e);
49
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
50
.into_response();
···
56
if let Some(cursor) = ¶ms.cursor {
57
query_params.insert("cursor".to_string(), cursor.clone());
58
}
59
+
let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", resolved.url);
60
info!(target = %target_url, feed = %params.feed, "Proxying getFeed request");
61
let client = proxy_client();
62
let mut request_builder = client.get(&target_url).query(&query_params);
63
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
64
match crate::auth::create_service_token(
65
&auth_user.did,
66
+
&resolved.did,
67
"app.bsky.feed.getFeed",
68
key_bytes,
69
) {
+3
-2
src/api/feed/post_thread.rs
+3
-2
src/api/feed/post_thread.rs
···
1
use crate::api::read_after_write::{
2
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
3
-
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
4
};
5
use crate::state::AppState;
6
use axum::{
···
153
if let Some(parent_height) = params.parent_height {
154
query_params.insert("parentHeight".to_string(), parent_height.to_string());
155
}
156
-
let proxy_result = match proxy_to_appview(
157
"app.bsky.feed.getPostThread",
158
&query_params,
159
auth_did.as_deref().unwrap_or(""),
···
1
use crate::api::read_after_write::{
2
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
3
+
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry,
4
};
5
use crate::state::AppState;
6
use axum::{
···
153
if let Some(parent_height) = params.parent_height {
154
query_params.insert("parentHeight".to_string(), parent_height.to_string());
155
}
156
+
let proxy_result = match proxy_to_appview_via_registry(
157
+
&state,
158
"app.bsky.feed.getPostThread",
159
&query_params,
160
auth_did.as_deref().unwrap_or(""),
+11
-13
src/api/feed/timeline.rs
+11
-13
src/api/feed/timeline.rs
···
1
use crate::api::read_after_write::{
2
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
3
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
4
-
proxy_to_appview,
5
};
6
use crate::state::AppState;
7
use axum::{
···
50
.into_response();
51
}
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
-
_ => {}
64
}
65
get_timeline_local_only(&state, &auth_user.did).await
66
}
···
81
if let Some(cursor) = ¶ms.cursor {
82
query_params.insert("cursor".to_string(), cursor.clone());
83
}
84
-
let proxy_result = match proxy_to_appview(
85
"app.bsky.feed.getTimeline",
86
&query_params,
87
auth_did,
···
1
use crate::api::read_after_write::{
2
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
3
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
4
+
proxy_to_appview_via_registry,
5
};
6
use crate::state::AppState;
7
use axum::{
···
50
.into_response();
51
}
52
};
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;
61
}
62
get_timeline_local_only(&state, &auth_user.did).await
63
}
···
78
if let Some(cursor) = ¶ms.cursor {
79
query_params.insert("cursor".to_string(), cursor.clone());
80
}
81
+
let proxy_result = match proxy_to_appview_via_registry(
82
+
state,
83
"app.bsky.feed.getTimeline",
84
&query_params,
85
auth_did,
+6
-6
src/api/notification/register_push.rs
+6
-6
src/api/notification/register_push.rs
···
53
if input.app_id.is_empty() || input.app_id.len() > 256 {
54
return ApiError::InvalidRequest("Invalid appId".to_string()).into_response();
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())
60
.into_response();
61
}
62
};
63
-
if let Err(e) = is_ssrf_safe(&appview_url) {
64
error!("SSRF check failed for appview URL: {}", e);
65
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
66
.into_response();
···
102
return ApiError::InternalError.into_response();
103
}
104
};
105
-
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url);
106
info!(
107
target = %target_url,
108
service_did = %input.service_did,
···
53
if input.app_id.is_empty() || input.app_id.len() > 256 {
54
return ApiError::InvalidRequest("Invalid appId".to_string()).into_response();
55
}
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
.into_response();
61
}
62
};
63
+
if let Err(e) = is_ssrf_safe(&resolved.url) {
64
error!("SSRF check failed for appview URL: {}", e);
65
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
66
.into_response();
···
102
return ApiError::InternalError.into_response();
103
}
104
};
105
+
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", resolved.url);
106
info!(
107
target = %target_url,
108
service_did = %input.service_did,
+51
-45
src/api/proxy.rs
+51
-45
src/api/proxy.rs
···
2
use crate::state::AppState;
3
use axum::{
4
body::Bytes,
5
-
extract::{Path, Query, State},
6
http::{HeaderMap, Method, StatusCode},
7
response::{IntoResponse, Response},
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
-
}
22
23
pub async fn proxy_handler(
24
State(state): State<AppState>,
25
Path(method): Path<String>,
26
method_verb: Method,
27
headers: HeaderMap,
28
-
Query(params): Query<HashMap<String, String>>,
29
body: Bytes,
30
) -> Response {
31
let proxy_header = headers
···
34
.map(|s| s.to_string());
35
let (appview_url, service_aud) = match &proxy_header {
36
Some(did_str) => {
37
-
let (url, did_without_fragment) = match resolve_service_did(did_str) {
38
-
Some(resolved) => resolved,
39
None => {
40
error!(did = %did_str, "Could not resolve service DID");
41
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
42
.into_response();
43
}
44
-
};
45
-
(url, Some(did_without_fragment))
46
}
47
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")
52
.into_response();
53
}
54
-
};
55
-
let aud = std::env::var("APPVIEW_DID").ok();
56
-
(url, aud)
57
}
58
};
59
-
let target_url = format!("{}/xrpc/{}", appview_url, method);
60
let client = proxy_client();
61
-
let mut request_builder = client.request(method_verb, &target_url).query(¶ms);
62
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(
65
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);
75
}
76
if let Some(val) = auth_header_val {
77
request_builder = request_builder.header("Authorization", val);
78
}
79
-
for (key, value) in headers.iter() {
80
-
if key != "host" && key != "content-length" && key != "authorization" {
81
-
request_builder = request_builder.header(key, value);
82
}
83
}
84
-
request_builder = request_builder.body(body);
85
match request_builder.send().await {
86
Ok(resp) => {
87
let status = resp.status();
···
95
}
96
};
97
let mut response_builder = Response::builder().status(status);
98
-
for (key, value) in headers.iter() {
99
-
response_builder = response_builder.header(key, value);
100
}
101
match response_builder.body(axum::body::Body::from(body)) {
102
Ok(r) => r,
···
2
use crate::state::AppState;
3
use axum::{
4
body::Bytes,
5
+
extract::{Path, RawQuery, State},
6
http::{HeaderMap, Method, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
+
use tracing::{error, info, warn};
10
11
pub async fn proxy_handler(
12
State(state): State<AppState>,
13
Path(method): Path<String>,
14
method_verb: Method,
15
headers: HeaderMap,
16
+
RawQuery(query): RawQuery,
17
body: Bytes,
18
) -> Response {
19
let proxy_header = headers
···
22
.map(|s| s.to_string());
23
let (appview_url, service_aud) = match &proxy_header {
24
Some(did_str) => {
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)),
28
None => {
29
error!(did = %did_str, "Could not resolve service DID");
30
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
31
.into_response();
32
}
33
+
}
34
}
35
None => {
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")
40
.into_response();
41
}
42
+
}
43
}
44
};
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);
50
let client = proxy_client();
51
+
let mut request_builder = client.request(method_verb, &target_url);
52
let mut auth_header_val = headers.get("Authorization").cloned();
53
+
if let Some(aud) = &service_aud {
54
+
if let Some(token) = crate::auth::extract_bearer_token_from_header(
55
headers.get("Authorization").and_then(|h| h.to_str().ok()),
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
+
}
69
}
70
+
}
71
+
}
72
+
Err(e) => {
73
+
warn!("Token validation failed: {:?}", e);
74
+
}
75
+
}
76
+
}
77
+
}
78
if let Some(val) = auth_header_val {
79
request_builder = request_builder.header("Authorization", val);
80
}
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);
84
}
85
}
86
+
if !body.is_empty() {
87
+
request_builder = request_builder.body(body);
88
+
}
89
match request_builder.send().await {
90
Ok(resp) => {
91
let status = resp.status();
···
99
}
100
};
101
let mut response_builder = Response::builder().status(status);
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
+
}
106
}
107
match response_builder.body(axum::body::Body::from(body)) {
108
Ok(r) => r,
+3
src/api/proxy_client.rs
+3
src/api/proxy_client.rs
···
121
"accept-language",
122
"atproto-accept-labelers",
123
"x-bsky-topics",
124
];
125
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
126
"atproto-repo-rev",
127
"atproto-content-labelers",
128
"retry-after",
129
"content-type",
130
];
131
132
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
···
121
"accept-language",
122
"atproto-accept-labelers",
123
"x-bsky-topics",
124
+
"content-type",
125
];
126
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
127
"atproto-repo-rev",
128
"atproto-content-labelers",
129
"retry-after",
130
"content-type",
131
+
"cache-control",
132
+
"etag",
133
];
134
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
}
239
}
240
241
-
pub async fn proxy_to_appview(
242
method: &str,
243
params: &HashMap<String, String>,
244
auth_did: &str,
245
auth_key_bytes: Option<&[u8]>,
246
) -> 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()
249
})?;
250
-
if let Err(e) = is_ssrf_safe(&appview_url) {
251
error!("SSRF check failed for appview URL: {}", e);
252
return Err(
253
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
···
258
let client = proxy_client();
259
let mut request_builder = client.get(&target_url).query(params);
260
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) {
264
Ok(service_token) => {
265
request_builder =
266
request_builder.header("Authorization", format!("Bearer {}", service_token));
···
238
}
239
}
240
241
+
pub async fn proxy_to_appview_via_registry(
242
+
state: &AppState,
243
method: &str,
244
params: &HashMap<String, String>,
245
auth_did: &str,
246
auth_key_bytes: Option<&[u8]>,
247
) -> Result<ProxyResponse, 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()
250
})?;
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) {
263
error!("SSRF check failed for appview URL: {}", e);
264
return Err(
265
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
···
270
let client = proxy_client();
271
let mut request_builder = client.get(&target_url).query(params);
272
if let Some(key_bytes) = auth_key_bytes {
273
+
match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) {
274
Ok(service_token) => {
275
request_builder =
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::state::AppState;
2
use axum::{
3
Json,
4
-
extract::{Query, State},
5
http::StatusCode,
6
response::{IntoResponse, Response},
7
};
8
use serde::Deserialize;
9
use serde_json::json;
10
11
#[derive(Deserialize)]
12
pub struct DescribeRepoInput {
13
pub repo: String,
14
}
15
16
pub async fn describe_repo(
17
State(state): State<AppState>,
18
Query(input): Query<DescribeRepoInput>,
19
) -> Response {
20
let user_row = if input.repo.starts_with("did:") {
21
sqlx::query!(
···
37
let (user_id, handle, did) = match user_row {
38
Ok(Some((id, handle, did))) => (id, handle, did),
39
_ => {
40
-
return (
41
-
StatusCode::NOT_FOUND,
42
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
43
-
)
44
-
.into_response();
45
}
46
};
47
let collections_query = sqlx::query!(
···
1
+
use crate::api::proxy_client::proxy_client;
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
+
extract::{Query, RawQuery, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
use serde::Deserialize;
10
use serde_json::json;
11
+
use tracing::{error, info};
12
13
#[derive(Deserialize)]
14
pub struct DescribeRepoInput {
15
pub repo: String,
16
}
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
+
69
pub async fn describe_repo(
70
State(state): State<AppState>,
71
Query(input): Query<DescribeRepoInput>,
72
+
RawQuery(raw_query): RawQuery,
73
) -> Response {
74
let user_row = if input.repo.starts_with("did:") {
75
sqlx::query!(
···
91
let (user_id, handle, did) = match user_row {
92
Ok(Some((id, handle, did))) => (id, handle, did),
93
_ => {
94
+
return proxy_describe_repo_to_appview(&state, raw_query.as_deref()).await;
95
}
96
};
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::state::AppState;
2
use axum::{
3
Json,
4
-
extract::{Query, State},
5
http::StatusCode,
6
response::{IntoResponse, Response},
7
};
···
11
use serde_json::json;
12
use std::collections::HashMap;
13
use std::str::FromStr;
14
-
use tracing::error;
15
16
#[derive(Deserialize)]
17
pub struct GetRecordInput {
···
21
pub cid: Option<String>,
22
}
23
24
pub async fn get_record(
25
State(state): State<AppState>,
26
Query(input): Query<GetRecordInput>,
27
) -> Response {
28
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
29
let user_id_opt = if input.repo.starts_with("did:") {
···
46
let user_id: uuid::Uuid = match user_id_opt {
47
Ok(Some(id)) => id,
48
_ => {
49
-
return (
50
-
StatusCode::NOT_FOUND,
51
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
52
-
)
53
-
.into_response();
54
}
55
};
56
let record_row = sqlx::query!(
···
134
pub cursor: Option<String>,
135
pub records: Vec<serde_json::Value>,
136
}
137
pub async fn list_records(
138
State(state): State<AppState>,
139
Query(input): Query<ListRecordsInput>,
140
) -> Response {
141
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
142
let user_id_opt = if input.repo.starts_with("did:") {
···
159
let user_id: uuid::Uuid = match user_id_opt {
160
Ok(Some(id)) => id,
161
_ => {
162
-
return (
163
-
StatusCode::NOT_FOUND,
164
-
Json(json!({"error": "NotFound", "message": "Repo not found"})),
165
-
)
166
-
.into_response();
167
}
168
};
169
let limit = input.limit.unwrap_or(50).clamp(1, 100);
···
1
+
use crate::api::proxy_client::proxy_client;
2
use crate::state::AppState;
3
use axum::{
4
Json,
5
+
extract::{Query, RawQuery, State},
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
···
12
use serde_json::json;
13
use std::collections::HashMap;
14
use std::str::FromStr;
15
+
use tracing::{error, info};
16
17
#[derive(Deserialize)]
18
pub struct GetRecordInput {
···
22
pub cid: Option<String>,
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
+
84
pub async fn get_record(
85
State(state): State<AppState>,
86
Query(input): Query<GetRecordInput>,
87
+
RawQuery(raw_query): RawQuery,
88
) -> Response {
89
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
90
let user_id_opt = if input.repo.starts_with("did:") {
···
107
let user_id: uuid::Uuid = match user_id_opt {
108
Ok(Some(id)) => id,
109
_ => {
110
+
return proxy_get_record_to_appview(&state, raw_query.as_deref()).await;
111
}
112
};
113
let record_row = sqlx::query!(
···
191
pub cursor: Option<String>,
192
pub records: Vec<serde_json::Value>,
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
+
246
pub async fn list_records(
247
State(state): State<AppState>,
248
Query(input): Query<ListRecordsInput>,
249
+
RawQuery(raw_query): RawQuery,
250
) -> Response {
251
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
252
let user_id_opt = if input.repo.starts_with("did:") {
···
269
let user_id: uuid::Uuid = match user_id_opt {
270
Ok(Some(id)) => id,
271
_ => {
272
+
return proxy_list_records_to_appview(&state, raw_query.as_deref()).await;
273
}
274
};
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
pub use email::{confirm_email, request_email_update, update_email};
17
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
18
pub use meta::{describe_server, health, robots_txt};
19
-
pub use password::{request_password_reset, reset_password};
20
pub use service_auth::get_service_auth;
21
pub use session::{
22
-
confirm_signup, create_session, delete_session, get_session, refresh_session,
23
-
resend_verification,
24
};
25
pub use signing_key::reserve_signing_key;
···
16
pub use email::{confirm_email, request_email_update, update_email};
17
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
18
pub use meta::{describe_server, health, robots_txt};
19
+
pub use password::{change_password, request_password_reset, reset_password};
20
pub use service_auth::get_service_auth;
21
pub use session::{
22
+
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
23
+
resend_verification, revoke_session,
24
};
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::state::{AppState, RateLimitKind};
2
use axum::{
3
Json,
···
5
http::{HeaderMap, StatusCode},
6
response::{IntoResponse, Response},
7
};
8
-
use bcrypt::{DEFAULT_COST, hash};
9
use chrono::{Duration, Utc};
10
use serde::Deserialize;
11
use serde_json::json;
12
use tracing::{error, info, warn};
···
297
info!("Password reset completed for user {}", user_id);
298
(StatusCode::OK, Json(json!({}))).into_response()
299
}
···
1
+
use crate::auth::BearerAuth;
2
use crate::state::{AppState, RateLimitKind};
3
use axum::{
4
Json,
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
+
use bcrypt::{DEFAULT_COST, hash, verify};
10
use chrono::{Duration, Utc};
11
+
use uuid::Uuid;
12
use serde::Deserialize;
13
use serde_json::json;
14
use tracing::{error, info, warn};
···
299
info!("Password reset completed for user {}", user_id);
300
(StatusCode::OK, Json(json!({}))).into_response()
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
) -> Response {
186
match sqlx::query!(
187
r#"SELECT
188
-
handle, email, email_confirmed,
189
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
190
discord_verified, telegram_verified, signal_verified
191
FROM users WHERE did = $1"#,
···
210
"emailConfirmed": row.email_confirmed,
211
"preferredChannel": preferred_channel,
212
"preferredChannelVerified": preferred_channel_verified,
213
"active": true,
214
"didDoc": {}
215
})).into_response()
···
406
}
407
match sqlx::query!(
408
r#"SELECT
409
-
handle, email, email_confirmed,
410
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
411
discord_verified, telegram_verified, signal_verified
412
FROM users WHERE did = $1"#,
···
433
"emailConfirmed": u.email_confirmed,
434
"preferredChannel": preferred_channel,
435
"preferredChannelVerified": preferred_channel_verified,
436
"active": true
437
})).into_response()
438
}
···
702
}
703
Json(json!({"success": true})).into_response()
704
}
···
185
) -> Response {
186
match sqlx::query!(
187
r#"SELECT
188
+
handle, email, email_confirmed, is_admin,
189
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
190
discord_verified, telegram_verified, signal_verified
191
FROM users WHERE did = $1"#,
···
210
"emailConfirmed": row.email_confirmed,
211
"preferredChannel": preferred_channel,
212
"preferredChannelVerified": preferred_channel_verified,
213
+
"isAdmin": row.is_admin,
214
"active": true,
215
"didDoc": {}
216
})).into_response()
···
407
}
408
match sqlx::query!(
409
r#"SELECT
410
+
handle, email, email_confirmed, is_admin,
411
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
412
discord_verified, telegram_verified, signal_verified
413
FROM users WHERE did = $1"#,
···
434
"emailConfirmed": u.email_confirmed,
435
"preferredChannel": preferred_channel,
436
"preferredChannelVerified": preferred_channel_verified,
437
+
"isAdmin": u.is_admin,
438
"active": true
439
})).into_response()
440
}
···
704
}
705
Json(json!({"success": true})).into_response()
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
pub mod api;
2
pub mod auth;
3
pub mod cache;
4
pub mod circuit_breaker;
···
48
.route(
49
"/xrpc/com.atproto.server.getSession",
50
get(api::server::get_session),
51
)
52
.route(
53
"/xrpc/com.atproto.server.deleteSession",
···
161
get(api::admin::get_account_infos),
162
)
163
.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),
170
)
171
.route(
172
"/xrpc/com.atproto.server.activateAccount",
···
191
.route(
192
"/xrpc/com.atproto.server.resetPassword",
193
post(api::server::reset_password),
194
)
195
.route(
196
"/xrpc/com.atproto.server.requestEmailUpdate",
···
352
get(oauth::endpoints::oauth_authorization_server),
353
)
354
.route("/oauth/jwks", get(oauth::endpoints::oauth_jwks))
355
.route(
356
"/oauth/par",
357
post(oauth::endpoints::pushed_authorization_request),
···
1
pub mod api;
2
+
pub mod appview;
3
pub mod auth;
4
pub mod cache;
5
pub mod circuit_breaker;
···
49
.route(
50
"/xrpc/com.atproto.server.getSession",
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),
60
)
61
.route(
62
"/xrpc/com.atproto.server.deleteSession",
···
170
get(api::admin::get_account_infos),
171
)
172
.route(
173
+
"/xrpc/com.atproto.admin.searchAccounts",
174
+
get(api::admin::search_accounts),
175
)
176
.route(
177
"/xrpc/com.atproto.server.activateAccount",
···
196
.route(
197
"/xrpc/com.atproto.server.resetPassword",
198
post(api::server::reset_password),
199
+
)
200
+
.route(
201
+
"/xrpc/com.bspds.account.changePassword",
202
+
post(api::server::change_password),
203
)
204
.route(
205
"/xrpc/com.atproto.server.requestEmailUpdate",
···
361
get(oauth::endpoints::oauth_authorization_server),
362
)
363
.route("/oauth/jwks", get(oauth::endpoints::oauth_jwks))
364
+
.route(
365
+
"/oauth/client-metadata.json",
366
+
get(oauth::endpoints::frontend_client_metadata),
367
+
)
368
.route(
369
"/oauth/par",
370
post(oauth::endpoints::pushed_authorization_request),
+37
src/oauth/endpoints/metadata.rs
+37
src/oauth/endpoints/metadata.rs
···
127
};
128
Json(create_jwk_set(vec![server_key]))
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
use chrono::{DateTime, Utc};
2
3
fn base_styles() -> &'static str {
4
r#"
5
: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;
28
}
29
@media (prefers-color-scheme: dark) {
30
: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;
45
}
46
}
47
* {
···
50
padding: 0;
51
}
52
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);
56
min-height: 100vh;
57
-
display: flex;
58
-
align-items: center;
59
-
justify-content: center;
60
-
padding: 1rem;
61
line-height: 1.5;
62
}
63
.container {
64
-
width: 100%;
65
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
-
}
78
}
79
h1 {
80
-
font-size: 1.5rem;
81
font-weight: 600;
82
-
color: var(--contrast-900);
83
-
margin-bottom: 0.5rem;
84
}
85
.subtitle {
86
-
color: var(--contrast-500);
87
-
font-size: 0.875rem;
88
-
margin-bottom: 1.5rem;
89
}
90
.subtitle strong {
91
-
color: var(--contrast-700);
92
}
93
.client-info {
94
-
background: var(--contrast-25);
95
-
border-radius: 0.5rem;
96
padding: 1rem;
97
margin-bottom: 1.5rem;
98
}
99
.client-info .client-name {
100
font-weight: 500;
101
-
color: var(--contrast-900);
102
display: block;
103
margin-bottom: 0.25rem;
104
}
105
.client-info .scope {
106
-
color: var(--contrast-500);
107
font-size: 0.875rem;
108
}
109
.error-banner {
110
background: var(--error-bg);
111
-
color: var(--error);
112
-
border-radius: 0.5rem;
113
-
padding: 0.75rem 1rem;
114
margin-bottom: 1rem;
115
-
font-size: 0.875rem;
116
}
117
.form-group {
118
-
margin-bottom: 1.25rem;
119
}
120
label {
121
display: block;
122
font-size: 0.875rem;
123
font-weight: 500;
124
-
color: var(--contrast-700);
125
-
margin-bottom: 0.375rem;
126
}
127
input[type="text"],
128
input[type="email"],
129
input[type="password"] {
130
width: 100%;
131
-
padding: 0.625rem 0.875rem;
132
-
border: 2px solid var(--contrast-200);
133
-
border-radius: 0.375rem;
134
font-size: 1rem;
135
-
color: var(--contrast-900);
136
-
background: var(--contrast-0);
137
-
transition: border-color 0.15s, box-shadow 0.15s;
138
}
139
input[type="text"]:focus,
140
input[type="email"]:focus,
141
input[type="password"]:focus {
142
outline: none;
143
-
border-color: var(--primary);
144
-
box-shadow: 0 0 0 3px var(--primary-600-30);
145
}
146
input[type="text"]::placeholder,
147
input[type="email"]::placeholder,
148
input[type="password"]::placeholder {
149
-
color: var(--contrast-400);
150
}
151
.checkbox-group {
152
display: flex;
···
155
margin-bottom: 1.5rem;
156
}
157
.checkbox-group input[type="checkbox"] {
158
-
width: 1.125rem;
159
-
height: 1.125rem;
160
-
accent-color: var(--primary);
161
}
162
.checkbox-group label {
163
margin-bottom: 0;
164
font-weight: normal;
165
-
color: var(--contrast-600);
166
cursor: pointer;
167
}
168
.buttons {
···
171
}
172
.btn {
173
flex: 1;
174
-
padding: 0.625rem 1.25rem;
175
-
border-radius: 0.375rem;
176
font-size: 1rem;
177
-
font-weight: 500;
178
cursor: pointer;
179
-
transition: background-color 0.15s, transform 0.1s;
180
border: none;
181
text-align: center;
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
}
190
.btn-primary {
191
-
background: var(--primary);
192
-
color: var(--primary-contrast);
193
}
194
.btn-primary:hover {
195
-
background: var(--primary-hover);
196
}
197
.btn-primary:disabled {
198
-
background: var(--primary-400);
199
cursor: not-allowed;
200
}
201
.btn-secondary {
202
-
background: var(--contrast-200);
203
-
color: var(--contrast-800);
204
}
205
.btn-secondary:hover {
206
-
background: var(--contrast-300);
207
}
208
.footer {
209
text-align: center;
210
margin-top: 1.5rem;
211
font-size: 0.75rem;
212
-
color: var(--contrast-400);
213
}
214
.accounts {
215
display: flex;
···
220
.account-item {
221
display: flex;
222
align-items: center;
223
-
gap: 0.75rem;
224
width: 100%;
225
-
padding: 0.75rem;
226
-
background: var(--contrast-25);
227
-
border: 1px solid var(--contrast-100);
228
-
border-radius: 0.5rem;
229
cursor: pointer;
230
-
transition: background-color 0.15s, border-color 0.15s;
231
text-align: left;
232
}
233
.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;
249
}
250
.account-info {
251
flex: 1;
252
min-width: 0;
253
}
254
.account-info .handle {
255
-
display: block;
256
font-weight: 500;
257
-
color: var(--contrast-900);
258
overflow: hidden;
259
text-overflow: ellipsis;
260
white-space: nowrap;
261
}
262
-
.account-info .email {
263
-
display: block;
264
-
font-size: 0.875rem;
265
-
color: var(--contrast-500);
266
overflow: hidden;
267
text-overflow: ellipsis;
268
-
white-space: nowrap;
269
}
270
.chevron {
271
-
color: var(--contrast-400);
272
font-size: 1.25rem;
273
flex-shrink: 0;
274
}
275
.divider {
276
height: 1px;
277
-
background: var(--contrast-100);
278
margin: 1rem 0;
279
}
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
.new-account-link {
293
display: block;
294
text-align: center;
295
-
color: var(--primary);
296
text-decoration: none;
297
font-size: 0.875rem;
298
}
···
303
text-align: center;
304
margin-top: 1rem;
305
font-size: 0.875rem;
306
-
color: var(--contrast-500);
307
}
308
.icon {
309
font-size: 3rem;
···
311
}
312
.error-code {
313
background: var(--error-bg);
314
-
color: var(--error);
315
padding: 0.5rem 1rem;
316
-
border-radius: 0.375rem;
317
font-family: monospace;
318
display: inline-block;
319
margin-bottom: 1rem;
···
323
height: 3rem;
324
border-radius: 50%;
325
background: var(--success-bg);
326
-
color: var(--success);
327
display: flex;
328
align-items: center;
329
justify-content: center;
···
351
login_hint: Option<&str>,
352
) -> String {
353
let client_display = client_name.unwrap_or(client_id);
354
-
let scope_display = scope.unwrap_or("access your account");
355
let error_html = error_message
356
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
357
.unwrap_or_default();
···
368
</head>
369
<body>
370
<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>
377
</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.
403
</div>
404
-
</div>
405
</div>
406
</body>
407
</html>"#,
408
styles = base_styles(),
409
client_display = html_escape(client_display),
410
-
scope_display = html_escape(scope_display),
411
request_uri = html_escape(request_uri),
412
error_html = error_html,
413
login_hint_value = html_escape(login_hint_value),
···
431
let accounts_html: String = accounts
432
.iter()
433
.map(|account| {
434
-
let initials = get_initials(&account.handle);
435
-
let email_display = account.email.as_deref().unwrap_or("");
436
format!(
437
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
438
<input type="hidden" name="request_uri" value="{request_uri}">
439
<input type="hidden" name="did" value="{did}">
440
<button type="submit" class="account-item">
441
-
<div class="avatar">{initials}</div>
442
<div class="account-info">
443
<span class="handle">@{handle}</span>
444
-
<span class="email">{email}</span>
445
</div>
446
<span class="chevron">›</span>
447
</button>
448
</form>"#,
449
request_uri = html_escape(request_uri),
450
did = html_escape(&account.did),
451
-
initials = html_escape(&initials),
452
handle = html_escape(&account.handle),
453
-
email = html_escape(email_display),
454
)
455
})
456
.collect();
···
466
</head>
467
<body>
468
<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>
479
</div>
480
</div>
481
</body>
482
</html>"#,
···
493
.unwrap_or_default();
494
let (title, subtitle) = match channel {
495
"email" => (
496
-
"Check your email",
497
"We sent a verification code to your email",
498
),
499
"Discord" => (
···
505
"We sent a verification code to your Telegram",
506
),
507
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
508
-
_ => ("Check your messages", "We sent you a verification code"),
509
};
510
format!(
511
r#"<!DOCTYPE html>
···
519
</head>
520
<body>
521
<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>
542
</div>
543
</body>
544
</html>"#,
···
564
<style>{styles}</style>
565
</head>
566
<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>
576
</div>
577
</div>
578
</body>
···
596
<style>{styles}</style>
597
</head>
598
<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>
606
</div>
607
</body>
608
</html>"#,
···
617
.replace('>', ">")
618
.replace('"', """)
619
.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
}
634
635
pub fn mask_email(email: &str) -> String {
···
1
use chrono::{DateTime, Utc};
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
+
27
fn base_styles() -> &'static str {
28
r#"
29
:root {
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;
47
}
48
@media (prefers-color-scheme: dark) {
49
:root {
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;
67
}
68
}
69
* {
···
72
padding: 0;
73
}
74
body {
75
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
76
+
background: var(--bg-primary);
77
+
color: var(--text-primary);
78
min-height: 100vh;
79
line-height: 1.5;
80
}
81
.container {
82
max-width: 400px;
83
+
margin: 4rem auto;
84
+
padding: 2rem;
85
}
86
h1 {
87
+
margin: 0 0 0.5rem 0;
88
font-weight: 600;
89
}
90
.subtitle {
91
+
color: var(--text-secondary);
92
+
margin: 0 0 2rem 0;
93
}
94
.subtitle strong {
95
+
color: var(--text-primary);
96
}
97
.client-info {
98
+
background: var(--bg-secondary);
99
+
border: 1px solid var(--border-color);
100
+
border-radius: 8px;
101
padding: 1rem;
102
margin-bottom: 1.5rem;
103
}
104
.client-info .client-name {
105
font-weight: 500;
106
+
color: var(--text-primary);
107
display: block;
108
margin-bottom: 0.25rem;
109
}
110
.client-info .scope {
111
+
color: var(--text-secondary);
112
font-size: 0.875rem;
113
}
114
.error-banner {
115
background: var(--error-bg);
116
+
border: 1px solid var(--error-border);
117
+
color: var(--error-text);
118
+
border-radius: 4px;
119
+
padding: 0.75rem;
120
margin-bottom: 1rem;
121
}
122
.form-group {
123
+
margin-bottom: 1rem;
124
}
125
label {
126
display: block;
127
font-size: 0.875rem;
128
font-weight: 500;
129
+
margin-bottom: 0.25rem;
130
}
131
input[type="text"],
132
input[type="email"],
133
input[type="password"] {
134
width: 100%;
135
+
padding: 0.75rem;
136
+
border: 1px solid var(--border-color-light);
137
+
border-radius: 4px;
138
font-size: 1rem;
139
+
color: var(--text-primary);
140
+
background: var(--bg-input);
141
}
142
input[type="text"]:focus,
143
input[type="email"]:focus,
144
input[type="password"]:focus {
145
outline: none;
146
+
border-color: var(--accent);
147
}
148
input[type="text"]::placeholder,
149
input[type="email"]::placeholder,
150
input[type="password"]::placeholder {
151
+
color: var(--text-muted);
152
}
153
.checkbox-group {
154
display: flex;
···
157
margin-bottom: 1.5rem;
158
}
159
.checkbox-group input[type="checkbox"] {
160
+
width: 1rem;
161
+
height: 1rem;
162
+
accent-color: var(--accent);
163
}
164
.checkbox-group label {
165
margin-bottom: 0;
166
font-weight: normal;
167
+
color: var(--text-secondary);
168
cursor: pointer;
169
}
170
.buttons {
···
173
}
174
.btn {
175
flex: 1;
176
+
padding: 0.75rem;
177
+
border-radius: 4px;
178
font-size: 1rem;
179
cursor: pointer;
180
border: none;
181
text-align: center;
182
text-decoration: none;
183
}
184
.btn-primary {
185
+
background: var(--accent);
186
+
color: white;
187
}
188
.btn-primary:hover {
189
+
background: var(--accent-hover);
190
}
191
.btn-primary:disabled {
192
+
opacity: 0.6;
193
cursor: not-allowed;
194
}
195
.btn-secondary {
196
+
background: transparent;
197
+
color: var(--accent);
198
+
border: 1px solid var(--accent);
199
}
200
.btn-secondary:hover {
201
+
background: var(--accent);
202
+
color: white;
203
}
204
.footer {
205
text-align: center;
206
margin-top: 1.5rem;
207
font-size: 0.75rem;
208
+
color: var(--text-muted);
209
}
210
.accounts {
211
display: flex;
···
216
.account-item {
217
display: flex;
218
align-items: center;
219
+
justify-content: space-between;
220
width: 100%;
221
+
padding: 1rem;
222
+
background: var(--bg-card);
223
+
border: 1px solid var(--border-color);
224
+
border-radius: 8px;
225
cursor: pointer;
226
+
transition: border-color 0.15s, box-shadow 0.15s;
227
text-align: left;
228
}
229
.account-item:hover {
230
+
border-color: var(--accent);
231
+
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
232
}
233
.account-info {
234
+
display: flex;
235
+
flex-direction: column;
236
+
gap: 0.25rem;
237
flex: 1;
238
min-width: 0;
239
}
240
.account-info .handle {
241
font-weight: 500;
242
+
color: var(--text-primary);
243
overflow: hidden;
244
text-overflow: ellipsis;
245
white-space: nowrap;
246
}
247
+
.account-info .did {
248
+
font-size: 0.75rem;
249
+
color: var(--text-muted);
250
+
font-family: monospace;
251
overflow: hidden;
252
text-overflow: ellipsis;
253
}
254
.chevron {
255
+
color: var(--text-muted);
256
font-size: 1.25rem;
257
flex-shrink: 0;
258
+
margin-left: 0.5rem;
259
}
260
.divider {
261
height: 1px;
262
+
background: var(--border-color);
263
margin: 1rem 0;
264
}
265
.new-account-link {
266
display: block;
267
text-align: center;
268
+
color: var(--accent);
269
text-decoration: none;
270
font-size: 0.875rem;
271
}
···
276
text-align: center;
277
margin-top: 1rem;
278
font-size: 0.875rem;
279
+
color: var(--text-secondary);
280
}
281
.icon {
282
font-size: 3rem;
···
284
}
285
.error-code {
286
background: var(--error-bg);
287
+
border: 1px solid var(--error-border);
288
+
color: var(--error-text);
289
padding: 0.5rem 1rem;
290
+
border-radius: 4px;
291
font-family: monospace;
292
display: inline-block;
293
margin-bottom: 1rem;
···
297
height: 3rem;
298
border-radius: 50%;
299
background: var(--success-bg);
300
+
border: 1px solid var(--success-border);
301
+
color: var(--success-text);
302
display: flex;
303
align-items: center;
304
justify-content: center;
···
326
login_hint: Option<&str>,
327
) -> String {
328
let client_display = client_name.unwrap_or(client_id);
329
+
let scope_display = format_scope_for_display(scope);
330
let error_html = error_message
331
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
332
.unwrap_or_default();
···
343
</head>
344
<body>
345
<div class="container">
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">
360
</div>
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">
365
</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>
378
</div>
379
</body>
380
</html>"#,
381
styles = base_styles(),
382
client_display = html_escape(client_display),
383
+
scope_display = html_escape(&scope_display),
384
request_uri = html_escape(request_uri),
385
error_html = error_html,
386
login_hint_value = html_escape(login_hint_value),
···
404
let accounts_html: String = accounts
405
.iter()
406
.map(|account| {
407
format!(
408
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
409
<input type="hidden" name="request_uri" value="{request_uri}">
410
<input type="hidden" name="did" value="{did}">
411
<button type="submit" class="account-item">
412
<div class="account-info">
413
<span class="handle">@{handle}</span>
414
+
<span class="did">{did}</span>
415
</div>
416
<span class="chevron">›</span>
417
</button>
418
</form>"#,
419
request_uri = html_escape(request_uri),
420
did = html_escape(&account.did),
421
handle = html_escape(&account.handle),
422
)
423
})
424
.collect();
···
434
</head>
435
<body>
436
<div class="container">
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}
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>
446
</div>
447
</body>
448
</html>"#,
···
459
.unwrap_or_default();
460
let (title, subtitle) = match channel {
461
"email" => (
462
+
"Check Your Email",
463
"We sent a verification code to your email",
464
),
465
"Discord" => (
···
471
"We sent a verification code to your Telegram",
472
),
473
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
474
+
_ => ("Check Your Messages", "We sent you a verification code"),
475
};
476
format!(
477
r#"<!DOCTYPE html>
···
485
</head>
486
<body>
487
<div class="container">
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>
506
</div>
507
</body>
508
</html>"#,
···
528
<style>{styles}</style>
529
</head>
530
<body>
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>
537
</div>
538
</div>
539
</body>
···
557
<style>{styles}</style>
558
</head>
559
<body>
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>
565
</div>
566
</body>
567
</html>"#,
···
576
.replace('>', ">")
577
.replace('"', """)
578
.replace('\'', "'")
579
}
580
581
pub fn mask_email(email: &str) -> String {
+4
src/state.rs
+4
src/state.rs
···
1
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
2
use crate::circuit_breaker::CircuitBreakers;
3
use crate::config::AuthConfig;
···
19
pub circuit_breakers: Arc<CircuitBreakers>,
20
pub cache: Arc<dyn Cache>,
21
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
22
}
23
24
pub enum RateLimitKind {
···
85
let rate_limiters = Arc::new(RateLimiters::new());
86
let circuit_breakers = Arc::new(CircuitBreakers::new());
87
let (cache, distributed_rate_limiter) = create_cache().await;
88
89
Self {
90
db,
···
95
circuit_breakers,
96
cache,
97
distributed_rate_limiter,
98
}
99
}
100
···
1
+
use crate::appview::AppViewRegistry;
2
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
3
use crate::circuit_breaker::CircuitBreakers;
4
use crate::config::AuthConfig;
···
20
pub circuit_breakers: Arc<CircuitBreakers>,
21
pub cache: Arc<dyn Cache>,
22
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
23
+
pub appview_registry: Arc<AppViewRegistry>,
24
}
25
26
pub enum RateLimitKind {
···
87
let rate_limiters = Arc::new(RateLimiters::new());
88
let circuit_breakers = Arc::new(CircuitBreakers::new());
89
let (cache, distributed_rate_limiter) = create_cache().await;
90
+
let appview_registry = Arc::new(AppViewRegistry::new());
91
92
Self {
93
db,
···
98
circuit_breakers,
99
cache,
100
distributed_rate_limiter,
101
+
appview_registry,
102
}
103
}
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
}
138
let mock_server = MockServer::start().await;
139
setup_mock_appview(&mock_server).await;
140
unsafe {
141
-
std::env::set_var("APPVIEW_URL", mock_server.uri());
142
}
143
MOCK_APPVIEW.set(mock_server).ok();
144
spawn_app(database_url).await
···
186
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
187
let mock_server = MockServer::start().await;
188
setup_mock_appview(&mock_server).await;
189
unsafe {
190
-
std::env::set_var("APPVIEW_URL", mock_server.uri());
191
}
192
MOCK_APPVIEW.set(mock_server).ok();
193
S3_CONTAINER.set(s3_container).ok();
···
213
panic!(
214
"Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."
215
);
216
}
217
218
async fn setup_mock_appview(mock_server: &MockServer) {
···
137
}
138
let mock_server = MockServer::start().await;
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;
144
unsafe {
145
+
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
146
}
147
MOCK_APPVIEW.set(mock_server).ok();
148
spawn_app(database_url).await
···
190
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
191
let mock_server = MockServer::start().await;
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;
197
unsafe {
198
+
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
199
}
200
MOCK_APPVIEW.set(mock_server).ok();
201
S3_CONTAINER.set(s3_container).ok();
···
221
panic!(
222
"Testcontainers disabled with external-infra feature. Set DATABASE_URL and S3_ENDPOINT."
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;
239
}
240
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
+
}