Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

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

Frontend on /app path for easy custom homepage

+1430 -841
+6
.env.example
··· 22 22 AWS_ACCESS_KEY_ID=minioadmin 23 23 AWS_SECRET_ACCESS_KEY=minioadmin 24 24 # ============================================================================= 25 + # Backups (S3-compatible) 26 + # ============================================================================= 27 + # Set to enable automatic repo backups to S3 28 + # BACKUP_S3_BUCKET=pds-backups 29 + # BACKUP_ENABLED=true 30 + # ============================================================================= 25 31 # Valkey (for caching and distributed rate limiting) 26 32 # ============================================================================= 27 33 # If not set, falls back to in-memory caching (single-node only)
+25 -1
docs/install-alpine.md
··· 68 68 rc-update add minio 69 69 rc-service minio start 70 70 ``` 71 - Create the blob bucket (wait a few seconds for minio to start): 71 + Create the buckets (wait a few seconds for minio to start): 72 72 ```sh 73 73 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 74 74 chmod +x mc 75 75 mv mc /usr/local/bin/ 76 76 mc alias set local http://localhost:9000 minioadmin your-minio-password 77 77 mc mb local/pds-blobs 78 + mc mb local/pds-backups 78 79 ``` 79 80 ## 5. Install valkey 80 81 ```sh ··· 239 240 ```sh 240 241 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 241 242 ``` 243 + 244 + ## Custom Homepage 245 + 246 + Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. 247 + 248 + ```sh 249 + cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF' 250 + <!DOCTYPE html> 251 + <html> 252 + <head> 253 + <title>Welcome to my PDS</title> 254 + <style> 255 + body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; } 256 + </style> 257 + </head> 258 + <body> 259 + <h1>Welcome to my amazing zoo pen</h1> 260 + <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p> 261 + <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p> 262 + </body> 263 + </html> 264 + EOF 265 + ```
+25 -4
docs/install-containers.md
··· 82 82 sleep 10 83 83 ``` 84 84 85 - Create the minio bucket: 85 + Create the minio buckets: 86 86 ```bash 87 87 podman run --rm --pod tranquil-pds \ 88 88 -e MINIO_ROOT_USER=minioadmin \ 89 89 -e MINIO_ROOT_PASSWORD=your-minio-password \ 90 90 docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ 91 - sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs" 91 + sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups" 92 92 ``` 93 93 94 94 Run migrations: ··· 230 230 sleep 15 231 231 ``` 232 232 233 - Create the minio bucket: 233 + Create the minio buckets: 234 234 ```sh 235 235 source /srv/tranquil-pds/config/tranquil-pds.env 236 236 podman run --rm --network tranquil-pds_default \ 237 237 -e MINIO_ROOT_USER="$MINIO_ROOT_USER" \ 238 238 -e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \ 239 239 docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ 240 - sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs' 240 + sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups' 241 241 ``` 242 242 243 243 Run migrations: ··· 350 350 ```sh 351 351 podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql 352 352 ``` 353 + 354 + ## Custom Homepage 355 + 356 + Mount a `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. 357 + 358 + ```html 359 + <!DOCTYPE html> 360 + <html> 361 + <head> 362 + <title>Welcome to my PDS</title> 363 + <style> 364 + body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; } 365 + </style> 366 + </head> 367 + <body> 368 + <h1>Welcome to my dark web popsocket store</h1> 369 + <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p> 370 + <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p> 371 + </body> 372 + </html> 373 + ```
+25 -1
docs/install-debian.md
··· 59 59 systemctl enable minio 60 60 systemctl start minio 61 61 ``` 62 - Create the blob bucket (wait a few seconds for minio to start): 62 + Create the buckets (wait a few seconds for minio to start): 63 63 ```bash 64 64 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 65 65 chmod +x mc 66 66 mv mc /usr/local/bin/ 67 67 mc alias set local http://localhost:9000 minioadmin your-minio-password 68 68 mc mb local/pds-blobs 69 + mc mb local/pds-backups 69 70 ``` 70 71 ## 5. Install valkey 71 72 ```bash ··· 212 213 ```bash 213 214 sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql 214 215 ``` 216 + 217 + ## Custom Homepage 218 + 219 + Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. 220 + 221 + ```bash 222 + cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF' 223 + <!DOCTYPE html> 224 + <html> 225 + <head> 226 + <title>Welcome to my PDS</title> 227 + <style> 228 + body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; } 229 + </style> 230 + </head> 231 + <body> 232 + <h1>Welcome to my secret PDS</h1> 233 + <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p> 234 + <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p> 235 + </body> 236 + </html> 237 + EOF 238 + ```
+22
docs/install-kubernetes.md
··· 12 12 The container image expects: 13 13 - `DATABASE_URL` - postgres connection string 14 14 - `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET` 15 + - `BACKUP_S3_BUCKET` - bucket for repo backups (optional but recommended) 15 16 - `VALKEY_URL` - redis:// connection string 16 17 - `PDS_HOSTNAME` - your PDS hostname (without protocol) 17 18 - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` ··· 20 21 21 22 Health check: `GET /xrpc/_health` 22 23 24 + ## Custom Homepage 25 + 26 + Mount a ConfigMap with your `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. 27 + 28 + ```yaml 29 + apiVersion: v1 30 + kind: ConfigMap 31 + metadata: 32 + name: pds-homepage 33 + data: 34 + homepage.html: | 35 + <!DOCTYPE html> 36 + <html> 37 + <head><title>Welcome to my PDS</title></head> 38 + <body> 39 + <h1>Welcome to my little evil secret lab!!!</h1> 40 + <p><a href="/app/">Sign in</a></p> 41 + </body> 42 + </html> 43 + ``` 44 +
+25 -1
docs/install-openbsd.md
··· 72 72 rcctl enable minio 73 73 rcctl start minio 74 74 ``` 75 - Create the blob bucket: 75 + Create the buckets: 76 76 ```sh 77 77 ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc 78 78 chmod +x /usr/local/bin/mc 79 79 mc alias set local http://localhost:9000 minioadmin your-minio-password 80 80 mc mb local/pds-blobs 81 + mc mb local/pds-backups 81 82 ``` 82 83 ## 5. Install redis 83 84 OpenBSD has redis in ports (valkey not available yet): ··· 251 252 ```sh 252 253 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 253 254 ``` 255 + 256 + ## Custom Homepage 257 + 258 + Drop a `homepage.html` in `/var/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. 259 + 260 + ```sh 261 + cat > /var/tranquil-pds/frontend/homepage.html << 'EOF' 262 + <!DOCTYPE html> 263 + <html> 264 + <head> 265 + <title>Welcome to my PDS</title> 266 + <style> 267 + body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; } 268 + </style> 269 + </head> 270 + <body> 271 + <h1>Welcome to my uma musume shipping site!</h1> 272 + <p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p> 273 + <p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p> 274 + </body> 275 + </html> 276 + EOF 277 + ```
+696
frontend/public/homepage.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Tranquil PDS</title> 7 + <style> 8 + :root { 9 + --space-0: 0; 10 + --space-1: 0.125rem; 11 + --space-2: 0.25rem; 12 + --space-3: 0.5rem; 13 + --space-4: 0.75rem; 14 + --space-5: 1rem; 15 + --space-6: 1.5rem; 16 + --space-7: 2rem; 17 + --space-8: 3rem; 18 + --space-9: 4rem; 19 + --text-xs: 0.75rem; 20 + --text-sm: 0.875rem; 21 + --text-base: 1rem; 22 + --text-lg: 1.125rem; 23 + --text-xl: 1.25rem; 24 + --text-2xl: 1.5rem; 25 + --text-3xl: 2rem; 26 + --text-4xl: 2.5rem; 27 + --font-normal: 400; 28 + --font-medium: 500; 29 + --font-semibold: 600; 30 + --font-bold: 700; 31 + --leading-tight: 1.25; 32 + --leading-normal: 1.5; 33 + --leading-relaxed: 1.75; 34 + --radius-sm: 3px; 35 + --radius-md: 4px; 36 + --radius-lg: 6px; 37 + --radius-xl: 8px; 38 + --width-xs: 360px; 39 + --width-sm: 480px; 40 + --width-md: 760px; 41 + --width-lg: 960px; 42 + --width-xl: 1100px; 43 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 44 + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); 45 + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); 46 + --shadow-focus: 0 0 0 2px var(--accent-muted); 47 + --transition-fast: 0.1s ease; 48 + --transition-normal: 0.15s ease; 49 + --transition-slow: 0.25s ease; 50 + --font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 51 + --bg-primary: #f9fafa; 52 + --bg-secondary: #f1f3f3; 53 + --bg-tertiary: #e8ebeb; 54 + --bg-hover: #e8ebeb; 55 + --bg-card: #ffffff; 56 + --bg-input: #ffffff; 57 + --bg-input-disabled: #f1f3f3; 58 + --text-primary: #1a1d1d; 59 + --text-secondary: #5a605f; 60 + --text-muted: #8a8f8e; 61 + --text-inverse: #ffffff; 62 + --border-color: #dce0df; 63 + --border-light: #e8ebeb; 64 + --border-dark: #c8cecc; 65 + --accent: #1a1d1d; 66 + --accent-hover: #2e3332; 67 + --accent-muted: rgba(26, 29, 29, 0.06); 68 + --accent-light: #3a403f; 69 + --secondary: #1a1d1d; 70 + --secondary-hover: #2e3332; 71 + --secondary-muted: rgba(26, 29, 29, 0.06); 72 + --success-bg: #dfd; 73 + --success-border: #8c8; 74 + --success-text: #060; 75 + --error-bg: #fee; 76 + --error-border: #fcc; 77 + --error-text: #c00; 78 + --warning-bg: #ffd; 79 + --warning-border: #d4a03c; 80 + --warning-text: #856404; 81 + --border-color-light: var(--border-dark); 82 + } 83 + @media (prefers-color-scheme: dark) { 84 + :root { 85 + --bg-primary: #0a0c0c; 86 + --bg-secondary: #131616; 87 + --bg-tertiary: #1a1d1d; 88 + --bg-hover: #1a1d1d; 89 + --bg-card: #131616; 90 + --bg-input: #1a1d1d; 91 + --bg-input-disabled: #131616; 92 + --text-primary: #e6e8e8; 93 + --text-secondary: #9ca1a0; 94 + --text-muted: #686d6c; 95 + --text-inverse: #0a0c0c; 96 + --border-color: #282c2b; 97 + --border-light: #1f2322; 98 + --border-dark: #343938; 99 + --accent: #e6e8e8; 100 + --accent-hover: #ffffff; 101 + --accent-muted: rgba(230, 232, 232, 0.1); 102 + --accent-light: #ffffff; 103 + --secondary: #e6e8e8; 104 + --secondary-hover: #ffffff; 105 + --secondary-muted: rgba(230, 232, 232, 0.1); 106 + --success-bg: #0f1f1a; 107 + --success-border: #1a3d2d; 108 + --success-text: #7bc6a0; 109 + --error-bg: #1f0f0f; 110 + --error-border: #3d1a1a; 111 + --error-text: #ff8a8a; 112 + --warning-bg: #1f1a0f; 113 + --warning-border: #3d351a; 114 + --warning-text: #c6b87b; 115 + } 116 + } 117 + 118 + *, *::before, *::after { 119 + box-sizing: border-box; 120 + } 121 + body { 122 + margin: 0; 123 + font-family: 124 + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 125 + sans-serif; 126 + background: var(--bg-primary); 127 + color: var(--text-primary); 128 + line-height: var(--leading-normal); 129 + -webkit-font-smoothing: antialiased; 130 + } 131 + 132 + .pattern-container { 133 + position: fixed; 134 + top: -32px; 135 + left: -32px; 136 + right: -32px; 137 + bottom: -32px; 138 + pointer-events: none; 139 + z-index: 1; 140 + overflow: hidden; 141 + } 142 + .pattern { 143 + position: absolute; 144 + top: 0; 145 + left: 0; 146 + width: calc(100% + 500px); 147 + height: 100%; 148 + animation: drift 80s linear infinite; 149 + } 150 + .dot { 151 + position: absolute; 152 + width: 10px; 153 + height: 10px; 154 + background: rgba(0, 0, 0, 0.06); 155 + border-radius: 50%; 156 + transition: transform 0.04s linear; 157 + } 158 + @media (prefers-color-scheme: dark) { 159 + .dot { 160 + background: rgba(255, 255, 255, 0.1); 161 + } 162 + } 163 + .pattern-fade { 164 + position: fixed; 165 + top: 0; 166 + left: 0; 167 + right: 0; 168 + bottom: 0; 169 + background: linear-gradient( 170 + 135deg, 171 + transparent 50%, 172 + var(--bg-primary) 75% 173 + ); 174 + pointer-events: none; 175 + z-index: 2; 176 + } 177 + @keyframes drift { 178 + 0% { 179 + transform: translateX(-500px); 180 + } 181 + 100% { 182 + transform: translateX(0); 183 + } 184 + } 185 + 186 + nav { 187 + position: fixed; 188 + top: 12px; 189 + left: 32px; 190 + right: 32px; 191 + background: var(--accent); 192 + padding: 10px 18px; 193 + z-index: 100; 194 + border-radius: var(--radius-xl); 195 + display: flex; 196 + justify-content: space-between; 197 + align-items: center; 198 + } 199 + .nav-left { 200 + display: flex; 201 + align-items: center; 202 + gap: var(--space-3); 203 + } 204 + .nav-logo { 205 + height: 28px; 206 + width: auto; 207 + object-fit: contain; 208 + border-radius: var(--radius-sm); 209 + } 210 + .hostname { 211 + font-weight: var(--font-semibold); 212 + font-size: var(--text-base); 213 + letter-spacing: 0.08em; 214 + color: var(--text-inverse); 215 + text-transform: uppercase; 216 + } 217 + .hostname.placeholder { 218 + opacity: 0.4; 219 + } 220 + .user-count { 221 + font-size: var(--text-sm); 222 + color: var(--text-inverse); 223 + opacity: 0.85; 224 + padding: 4px 10px; 225 + background: rgba(255, 255, 255, 0.15); 226 + border-radius: var(--radius-md); 227 + white-space: nowrap; 228 + } 229 + @media (prefers-color-scheme: dark) { 230 + .user-count { 231 + background: rgba(0, 0, 0, 0.15); 232 + } 233 + } 234 + .nav-meta { 235 + font-size: var(--text-sm); 236 + color: var(--text-inverse); 237 + opacity: 0.6; 238 + letter-spacing: 0.05em; 239 + } 240 + 241 + .home { 242 + position: relative; 243 + z-index: 10; 244 + max-width: var(--width-xl); 245 + margin: 0 auto; 246 + padding: 72px 32px 32px; 247 + } 248 + .hero { 249 + padding: var(--space-7) 0 var(--space-8); 250 + border-bottom: 1px solid var(--border-color); 251 + margin-bottom: var(--space-8); 252 + } 253 + h1 { 254 + font-size: var(--text-4xl); 255 + font-weight: var(--font-semibold); 256 + line-height: var(--leading-tight); 257 + margin-bottom: var(--space-6); 258 + letter-spacing: -0.02em; 259 + } 260 + .cycling-word-container { 261 + display: inline-block; 262 + width: 3.9em; 263 + text-align: left; 264 + } 265 + .cycling-word { 266 + display: inline-block; 267 + transition: opacity 0.1s ease, transform 0.1s ease; 268 + } 269 + .cycling-word.transitioning { 270 + opacity: 0; 271 + transform: scale(0.95); 272 + } 273 + .lede { 274 + font-size: var(--text-xl); 275 + font-weight: var(--font-medium); 276 + color: var(--text-primary); 277 + line-height: var(--leading-relaxed); 278 + margin-bottom: 0; 279 + } 280 + .actions { 281 + display: flex; 282 + gap: var(--space-4); 283 + margin-top: var(--space-7); 284 + } 285 + .btn { 286 + font-size: var(--text-sm); 287 + font-weight: var(--font-medium); 288 + text-transform: uppercase; 289 + letter-spacing: 0.06em; 290 + padding: var(--space-4) var(--space-6); 291 + border-radius: var(--radius-lg); 292 + text-decoration: none; 293 + transition: all var(--transition-normal); 294 + border: 1px solid transparent; 295 + } 296 + .btn.primary { 297 + background: var(--secondary); 298 + color: var(--text-inverse); 299 + border-color: var(--secondary); 300 + } 301 + .btn.primary:hover { 302 + background: var(--secondary-hover); 303 + border-color: var(--secondary-hover); 304 + } 305 + .btn.secondary { 306 + background: transparent; 307 + color: var(--text-primary); 308 + border-color: var(--border-color); 309 + } 310 + .btn.secondary:hover { 311 + background: var(--secondary-muted); 312 + border-color: var(--secondary); 313 + color: var(--secondary); 314 + } 315 + blockquote { 316 + margin: var(--space-8) 0 0 0; 317 + padding: var(--space-6); 318 + background: var(--accent-muted); 319 + border-left: 3px solid var(--accent); 320 + border-radius: 0 var(--radius-xl) var(--radius-xl) 0; 321 + } 322 + blockquote p { 323 + font-size: var(--text-lg); 324 + color: var(--text-primary); 325 + font-style: italic; 326 + margin-bottom: var(--space-3); 327 + } 328 + blockquote cite { 329 + font-size: var(--text-sm); 330 + color: var(--text-secondary); 331 + font-style: normal; 332 + text-transform: uppercase; 333 + letter-spacing: 0.05em; 334 + } 335 + .content h2 { 336 + font-size: var(--text-sm); 337 + font-weight: var(--font-bold); 338 + text-transform: uppercase; 339 + letter-spacing: 0.1em; 340 + color: var(--accent-light); 341 + margin: var(--space-8) 0 var(--space-5); 342 + } 343 + .content h2:first-child { 344 + margin-top: 0; 345 + } 346 + .content > p { 347 + font-size: var(--text-base); 348 + color: var(--text-secondary); 349 + margin-bottom: var(--space-5); 350 + line-height: var(--leading-relaxed); 351 + } 352 + .features { 353 + display: grid; 354 + grid-template-columns: repeat(2, 1fr); 355 + gap: var(--space-6); 356 + margin: var(--space-6) 0 var(--space-8); 357 + } 358 + .feature { 359 + padding: var(--space-5); 360 + background: var(--bg-secondary); 361 + border-radius: var(--radius-xl); 362 + border: 1px solid var(--border-color); 363 + } 364 + .feature h3 { 365 + font-size: var(--text-base); 366 + font-weight: var(--font-semibold); 367 + color: var(--text-primary); 368 + margin-bottom: var(--space-3); 369 + } 370 + .feature p { 371 + font-size: var(--text-sm); 372 + color: var(--text-secondary); 373 + margin: 0; 374 + line-height: var(--leading-relaxed); 375 + } 376 + @media (max-width: 700px) { 377 + .features { 378 + grid-template-columns: 1fr; 379 + } 380 + h1 { 381 + font-size: var(--text-3xl); 382 + } 383 + .actions { 384 + flex-direction: column; 385 + } 386 + .btn { 387 + text-align: center; 388 + } 389 + .user-count, .nav-meta { 390 + display: none; 391 + } 392 + } 393 + .site-footer { 394 + margin-top: var(--space-9); 395 + padding-top: var(--space-7); 396 + display: flex; 397 + justify-content: space-between; 398 + font-size: var(--text-sm); 399 + color: var(--text-muted); 400 + text-transform: uppercase; 401 + letter-spacing: 0.05em; 402 + border-top: 1px solid var(--border-color); 403 + } 404 + .hidden { 405 + display: none !important; 406 + } 407 + </style> 408 + </head> 409 + <body> 410 + <div class="pattern-container"> 411 + <div class="pattern" id="dotPattern"></div> 412 + </div> 413 + <div class="pattern-fade"></div> 414 + 415 + <nav> 416 + <div class="nav-left"> 417 + <img src="/logo" alt="Logo" class="nav-logo hidden" id="navLogo"> 418 + <span class="hostname" id="hostname">loading...</span> 419 + <span class="user-count hidden" id="userCount"></span> 420 + </div> 421 + <span class="nav-meta" id="version"></span> 422 + </nav> 423 + 424 + <div class="home"> 425 + <section class="hero"> 426 + <h1> 427 + A home for your <span class="cycling-word-container"><span 428 + class="cycling-word" 429 + id="cyclingWord" 430 + >Bluesky</span></span> account 431 + </h1> 432 + 433 + <p class="lede"> 434 + Tranquil PDS is a Personal Data Server, the thing that stores your 435 + posts, profile, and keys. Bluesky runs one for you, but you can run 436 + your own. 437 + </p> 438 + 439 + <div class="actions" id="heroActions"> 440 + <a href="/app/register" class="btn primary" id="heroPrimary" 441 + >Join This Server</a> 442 + <a 443 + href="https://tangled.org/lewis.moe/bspds-sandbox" 444 + class="btn secondary" 445 + id="heroSecondary" 446 + target="_blank" 447 + rel="noopener" 448 + >Run Your Own</a> 449 + </div> 450 + 451 + <blockquote> 452 + <p>"Nature does not hurry, yet everything is accomplished."</p> 453 + <cite>Lao Tzu</cite> 454 + </blockquote> 455 + </section> 456 + 457 + <section class="content"> 458 + <h2>What you get</h2> 459 + 460 + <div class="features"> 461 + <div class="feature"> 462 + <h3>Real security</h3> 463 + <p> 464 + Sign in with passkeys, add two-factor authentication, set up 465 + backup codes, and mark devices you trust. Your account stays 466 + yours. 467 + </p> 468 + </div> 469 + 470 + <div class="feature"> 471 + <h3>Your own identity</h3> 472 + <p> 473 + Use your own domain as your handle, or get a subdomain on ours. 474 + Either way, your identity moves with you if you ever leave. 475 + </p> 476 + </div> 477 + 478 + <div class="feature"> 479 + <h3>Stay in the loop</h3> 480 + <p> 481 + Get important alerts where you actually see them: email, Discord, 482 + Telegram, or Signal. 483 + </p> 484 + </div> 485 + 486 + <div class="feature"> 487 + <h3>You decide what apps can do</h3> 488 + <p> 489 + When an app asks for access, you'll see exactly what it wants in 490 + plain language. Grant what makes sense, deny what doesn't. 491 + </p> 492 + </div> 493 + 494 + <div class="feature"> 495 + <h3>App passwords with guardrails</h3> 496 + <p> 497 + Create app passwords that can only do specific things: read-only 498 + for feed readers, post-only for bots. Full control over what each 499 + password can access. 500 + </p> 501 + </div> 502 + 503 + <div class="feature"> 504 + <h3>Delegate without sharing passwords</h3> 505 + <p> 506 + Let team members or tools manage your account with specific 507 + permission levels. They authenticate with their own credentials, 508 + you see everything they do in an audit log. 509 + </p> 510 + </div> 511 + 512 + <div class="feature"> 513 + <h3>Automatic backups</h3> 514 + <p> 515 + Your repository is backed up daily to object storage. Download any 516 + backup or restore with one click. You own your data, even if the 517 + worst happens. 518 + </p> 519 + </div> 520 + </div> 521 + 522 + <h2>Everything in one place</h2> 523 + 524 + <p> 525 + Manage your profile, security settings, connected apps, and more from 526 + a clean dashboard. No command line or 3rd party apps required. 527 + </p> 528 + 529 + <h2>Works with everything</h2> 530 + 531 + <p> 532 + Use any ATProto app you already like. Tranquil PDS speaks the same 533 + language as Bluesky's servers, so all your favorite clients and tools 534 + just work. 535 + </p> 536 + 537 + <h2>Ready to try it?</h2> 538 + 539 + <p> 540 + Join this server, or grab the source and run your own. Either way, you 541 + can migrate an existing account over and your followers, posts, and 542 + identity come with you. 543 + </p> 544 + 545 + <div class="actions" id="footerActions"> 546 + <a href="/app/register" class="btn primary" id="footerPrimary" 547 + >Join This Server</a> 548 + <a 549 + href="https://tangled.org/lewis.moe/bspds-sandbox" 550 + class="btn secondary" 551 + target="_blank" 552 + rel="noopener" 553 + >View Source</a> 554 + </div> 555 + </section> 556 + 557 + <footer class="site-footer"> 558 + <span>Made by people who don't take themselves too seriously</span> 559 + <span>Open Source: issues & PRs welcome</span> 560 + </footer> 561 + </div> 562 + 563 + <script> 564 + (function checkSession() { 565 + try { 566 + const stored = localStorage.getItem("tranquil_pds_session"); 567 + if (stored) { 568 + const session = JSON.parse(stored); 569 + if (session && session.handle) { 570 + const handle = "@" + session.handle; 571 + const heroPrimary = document.getElementById( 572 + "heroPrimary", 573 + ); 574 + const footerPrimary = document.getElementById( 575 + "footerPrimary", 576 + ); 577 + const heroSecondary = document.getElementById( 578 + "heroSecondary", 579 + ); 580 + if (heroPrimary) { 581 + heroPrimary.href = "/app/dashboard"; 582 + heroPrimary.textContent = handle; 583 + } 584 + if (footerPrimary) { 585 + footerPrimary.href = "/app/dashboard"; 586 + footerPrimary.textContent = handle; 587 + } 588 + if (heroSecondary) { 589 + heroSecondary.classList.add("hidden"); 590 + } 591 + } 592 + } 593 + } catch (e) {} 594 + })(); 595 + 596 + const heroWords = ["Bluesky", "Tangled", "Leaflet", "ATProto"]; 597 + const wordSpacing = { 598 + "Bluesky": "0.01em", 599 + "Tangled": "0.02em", 600 + "Leaflet": "0.05em", 601 + "ATProto": "0", 602 + }; 603 + let currentWordIndex = 0; 604 + const cyclingWord = document.getElementById("cyclingWord"); 605 + 606 + function cycleWord() { 607 + cyclingWord.classList.add("transitioning"); 608 + setTimeout(() => { 609 + currentWordIndex = (currentWordIndex + 1) % heroWords.length; 610 + const word = heroWords[currentWordIndex]; 611 + cyclingWord.textContent = word; 612 + cyclingWord.style.letterSpacing = wordSpacing[word] || "0"; 613 + cyclingWord.classList.remove("transitioning"); 614 + const duration = word === "ATProto" ? 4000 : 2000; 615 + setTimeout(cycleWord, duration); 616 + }, 100); 617 + } 618 + setTimeout(cycleWord, 2000); 619 + 620 + fetch("/xrpc/com.atproto.server.describeServer") 621 + .then((r) => r.json()) 622 + .then((info) => { 623 + if (info.availableUserDomains?.length) { 624 + document.getElementById("hostname").textContent = 625 + info.availableUserDomains[0]; 626 + document.getElementById("hostname").classList.remove( 627 + "placeholder", 628 + ); 629 + } 630 + if (info.version) { 631 + document.getElementById("version").textContent = 632 + info.version; 633 + } 634 + }) 635 + .catch(() => {}); 636 + 637 + fetch("/xrpc/com.atproto.sync.listRepos?limit=1000") 638 + .then((r) => r.json()) 639 + .then((data) => { 640 + const count = data.repos?.length || 0; 641 + const el = document.getElementById("userCount"); 642 + el.textContent = count + " " + 643 + (count === 1 ? "user" : "users"); 644 + el.classList.remove("hidden"); 645 + }) 646 + .catch(() => {}); 647 + 648 + fetch("/logo", { method: "HEAD" }) 649 + .then((r) => { 650 + if (r.ok) { 651 + document.getElementById("navLogo").classList.remove( 652 + "hidden", 653 + ); 654 + } 655 + }) 656 + .catch(() => {}); 657 + 658 + const pattern = document.getElementById("dotPattern"); 659 + const spacing = 32; 660 + const cols = Math.ceil((window.innerWidth + 600) / spacing); 661 + const rows = Math.ceil((window.innerHeight + 100) / spacing); 662 + const dots = []; 663 + 664 + for (let y = 0; y < rows; y++) { 665 + for (let x = 0; x < cols; x++) { 666 + const dot = document.createElement("div"); 667 + dot.className = "dot"; 668 + dot.style.left = (x * spacing) + "px"; 669 + dot.style.top = (y * spacing) + "px"; 670 + pattern.appendChild(dot); 671 + dots.push({ el: dot, x: x * spacing, y: y * spacing }); 672 + } 673 + } 674 + 675 + let mouseX = -1000, mouseY = -1000; 676 + document.addEventListener("mousemove", (e) => { 677 + mouseX = e.clientX; 678 + mouseY = e.clientY; 679 + }); 680 + 681 + function updateDots() { 682 + const patternRect = pattern.getBoundingClientRect(); 683 + dots.forEach((dot) => { 684 + const dotX = patternRect.left + dot.x + 5; 685 + const dotY = patternRect.top + dot.y + 5; 686 + const dist = Math.hypot(mouseX - dotX, mouseY - dotY); 687 + const maxDist = 120; 688 + const scale = Math.min(1, Math.max(0.1, dist / maxDist)); 689 + dot.el.style.transform = "scale(" + scale + ")"; 690 + }); 691 + requestAnimationFrame(updateDots); 692 + } 693 + updateDots(); 694 + </script> 695 + </body> 696 + </html>
+16 -11
frontend/src/App.svelte
··· 2 2 import { getCurrentPath, navigate } from './lib/router.svelte' 3 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 4 import { initServerConfig } from './lib/serverConfig.svelte' 5 - import { initI18n, _ } from './lib/i18n' 5 + import { initI18n } from './lib/i18n' 6 6 import { isLoading as i18nLoading } from 'svelte-i18n' 7 7 import Login from './routes/Login.svelte' 8 8 import Register from './routes/Register.svelte' ··· 34 34 import ActAs from './routes/ActAs.svelte' 35 35 import Migration from './routes/Migration.svelte' 36 36 import DidDocumentEditor from './routes/DidDocumentEditor.svelte' 37 - import Home from './routes/Home.svelte' 38 - 39 - if (window.location.pathname === '/migrate') { 40 - const newUrl = `${window.location.origin}/${window.location.search}#/migrate` 41 - window.location.replace(newUrl) 42 - } 43 - 44 37 initI18n() 45 38 46 39 const auth = getAuthState() ··· 48 41 let oauthCallbackPending = $state(hasOAuthCallback()) 49 42 50 43 function hasOAuthCallback(): boolean { 51 - if (window.location.hash === '#/migrate') { 44 + if (window.location.pathname === '/app/migrate') { 52 45 return false 53 46 } 54 47 const params = new URLSearchParams(window.location.search) ··· 59 52 initServerConfig() 60 53 initAuth().then(({ oauthLoginCompleted }) => { 61 54 if (oauthLoginCompleted) { 62 - navigate('/dashboard') 55 + navigate('/dashboard', true) 63 56 } 64 57 oauthCallbackPending = false 65 58 }) 66 59 }) 67 60 61 + $effect(() => { 62 + if (auth.loading) return 63 + const path = getCurrentPath() 64 + if (path === '/') { 65 + if (auth.session) { 66 + navigate('/dashboard', true) 67 + } else { 68 + navigate('/login', true) 69 + } 70 + } 71 + }) 72 + 68 73 function getComponent(path: string) { 69 74 switch (path) { 70 75 case '/login': ··· 128 133 case '/did-document': 129 134 return DidDocumentEditor 130 135 default: 131 - return Home 136 + return Login 132 137 } 133 138 } 134 139
+65 -65
frontend/src/lib/api.ts
··· 237 237 return data; 238 238 }, 239 239 240 - async confirmSignup( 240 + confirmSignup( 241 241 did: string, 242 242 verificationCode: string, 243 243 ): Promise<ConfirmSignupResult> { ··· 247 247 }); 248 248 }, 249 249 250 - async resendVerification(did: string): Promise<{ success: boolean }> { 250 + resendVerification(did: string): Promise<{ success: boolean }> { 251 251 return xrpc("com.atproto.server.resendVerification", { 252 252 method: "POST", 253 253 body: { did }, 254 254 }); 255 255 }, 256 256 257 - async createSession(identifier: string, password: string): Promise<Session> { 257 + createSession(identifier: string, password: string): Promise<Session> { 258 258 return xrpc("com.atproto.server.createSession", { 259 259 method: "POST", 260 260 body: { identifier, password }, 261 261 }); 262 262 }, 263 263 264 - async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 264 + checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 265 return xrpc("_checkEmailVerified", { 266 266 method: "POST", 267 267 body: { identifier }, 268 268 }); 269 269 }, 270 270 271 - async getSession(token: string): Promise<Session> { 271 + getSession(token: string): Promise<Session> { 272 272 return xrpc("com.atproto.server.getSession", { token }); 273 273 }, 274 274 275 - async refreshSession(refreshJwt: string): Promise<Session> { 275 + refreshSession(refreshJwt: string): Promise<Session> { 276 276 return xrpc("com.atproto.server.refreshSession", { 277 277 method: "POST", 278 278 token: refreshJwt, ··· 286 286 }); 287 287 }, 288 288 289 - async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 289 + listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 290 290 return xrpc("com.atproto.server.listAppPasswords", { token }); 291 291 }, 292 292 293 - async createAppPassword( 293 + createAppPassword( 294 294 token: string, 295 295 name: string, 296 296 scopes?: string, ··· 312 312 }); 313 313 }, 314 314 315 - async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 315 + getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 316 316 return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 317 317 }, 318 318 319 - async createInviteCode( 319 + createInviteCode( 320 320 token: string, 321 321 useCount: number = 1, 322 322 ): Promise<{ code: string }> { ··· 341 341 }); 342 342 }, 343 343 344 - async requestEmailUpdate( 344 + requestEmailUpdate( 345 345 token: string, 346 346 ): Promise<{ tokenRequired: boolean }> { 347 347 return xrpc("com.atproto.server.requestEmailUpdate", { ··· 388 388 }); 389 389 }, 390 390 391 - async describeServer(): Promise<{ 391 + describeServer(): Promise<{ 392 392 availableUserDomains: string[]; 393 393 inviteCodeRequired: boolean; 394 394 links?: { privacyPolicy?: string; termsOfService?: string }; ··· 399 399 return xrpc("com.atproto.server.describeServer"); 400 400 }, 401 401 402 - async listRepos(limit?: number): Promise<{ 402 + listRepos(limit?: number): Promise<{ 403 403 repos: Array<{ did: string; head: string; rev: string }>; 404 404 cursor?: string; 405 405 }> { ··· 408 408 return xrpc("com.atproto.sync.listRepos", { params }); 409 409 }, 410 410 411 - async getNotificationPrefs(token: string): Promise<{ 411 + getNotificationPrefs(token: string): Promise<{ 412 412 preferredChannel: string; 413 413 email: string; 414 414 discordId: string | null; ··· 421 421 return xrpc("_account.getNotificationPrefs", { token }); 422 422 }, 423 423 424 - async updateNotificationPrefs(token: string, prefs: { 424 + updateNotificationPrefs(token: string, prefs: { 425 425 preferredChannel?: string; 426 426 discordId?: string; 427 427 telegramUsername?: string; ··· 434 434 }); 435 435 }, 436 436 437 - async confirmChannelVerification( 437 + confirmChannelVerification( 438 438 token: string, 439 439 channel: string, 440 440 identifier: string, ··· 447 447 }); 448 448 }, 449 449 450 - async getNotificationHistory(token: string): Promise<{ 450 + getNotificationHistory(token: string): Promise<{ 451 451 notifications: Array<{ 452 452 createdAt: string; 453 453 channel: string; ··· 460 460 return xrpc("_account.getNotificationHistory", { token }); 461 461 }, 462 462 463 - async getServerStats(token: string): Promise<{ 463 + getServerStats(token: string): Promise<{ 464 464 userCount: number; 465 465 repoCount: number; 466 466 recordCount: number; ··· 469 469 return xrpc("_admin.getServerStats", { token }); 470 470 }, 471 471 472 - async getServerConfig(): Promise<{ 472 + getServerConfig(): Promise<{ 473 473 serverName: string; 474 474 primaryColor: string | null; 475 475 primaryColorDark: string | null; ··· 480 480 return xrpc("_server.getConfig"); 481 481 }, 482 482 483 - async updateServerConfig( 483 + updateServerConfig( 484 484 token: string, 485 485 config: { 486 486 serverName?: string; ··· 541 541 }); 542 542 }, 543 543 544 - async removePassword(token: string): Promise<{ success: boolean }> { 544 + removePassword(token: string): Promise<{ success: boolean }> { 545 545 return xrpc("_account.removePassword", { 546 546 method: "POST", 547 547 token, 548 548 }); 549 549 }, 550 550 551 - async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 551 + getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 552 return xrpc("_account.getPasswordStatus", { token }); 553 553 }, 554 554 555 - async getLegacyLoginPreference( 555 + getLegacyLoginPreference( 556 556 token: string, 557 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 558 return xrpc("_account.getLegacyLoginPreference", { token }); 559 559 }, 560 560 561 - async updateLegacyLoginPreference( 561 + updateLegacyLoginPreference( 562 562 token: string, 563 563 allowLegacyLogin: boolean, 564 564 ): Promise<{ allowLegacyLogin: boolean }> { ··· 569 569 }); 570 570 }, 571 571 572 - async updateLocale( 572 + updateLocale( 573 573 token: string, 574 574 preferredLocale: string, 575 575 ): Promise<{ preferredLocale: string }> { ··· 580 580 }); 581 581 }, 582 582 583 - async listSessions(token: string): Promise<{ 583 + listSessions(token: string): Promise<{ 584 584 sessions: Array<{ 585 585 id: string; 586 586 sessionType: string; ··· 601 601 }); 602 602 }, 603 603 604 - async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 604 + revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 605 return xrpc("_account.revokeAllSessions", { 606 606 method: "POST", 607 607 token, 608 608 }); 609 609 }, 610 610 611 - async searchAccounts(token: string, options?: { 611 + searchAccounts(token: string, options?: { 612 612 handle?: string; 613 613 cursor?: string; 614 614 limit?: number; ··· 630 630 return xrpc("com.atproto.admin.searchAccounts", { token, params }); 631 631 }, 632 632 633 - async getInviteCodes(token: string, options?: { 633 + getInviteCodes(token: string, options?: { 634 634 sort?: "recent" | "usage"; 635 635 cursor?: string; 636 636 limit?: number; ··· 665 665 }); 666 666 }, 667 667 668 - async getAccountInfo(token: string, did: string): Promise<{ 668 + getAccountInfo(token: string, did: string): Promise<{ 669 669 did: string; 670 670 handle: string; 671 671 email?: string; ··· 701 701 }); 702 702 }, 703 703 704 - async describeRepo(token: string, repo: string): Promise<{ 704 + describeRepo(token: string, repo: string): Promise<{ 705 705 handle: string; 706 706 did: string; 707 707 didDoc: unknown; ··· 714 714 }); 715 715 }, 716 716 717 - async listRecords(token: string, repo: string, collection: string, options?: { 717 + listRecords(token: string, repo: string, collection: string, options?: { 718 718 limit?: number; 719 719 cursor?: string; 720 720 reverse?: boolean; ··· 729 729 return xrpc("com.atproto.repo.listRecords", { token, params }); 730 730 }, 731 731 732 - async getRecord( 732 + getRecord( 733 733 token: string, 734 734 repo: string, 735 735 collection: string, ··· 745 745 }); 746 746 }, 747 747 748 - async createRecord( 748 + createRecord( 749 749 token: string, 750 750 repo: string, 751 751 collection: string, ··· 762 762 }); 763 763 }, 764 764 765 - async putRecord( 765 + putRecord( 766 766 token: string, 767 767 repo: string, 768 768 collection: string, ··· 792 792 }); 793 793 }, 794 794 795 - async getTotpStatus( 795 + getTotpStatus( 796 796 token: string, 797 797 ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 798 798 return xrpc("com.atproto.server.getTotpStatus", { token }); 799 799 }, 800 800 801 - async createTotpSecret( 801 + createTotpSecret( 802 802 token: string, 803 803 ): Promise<{ uri: string; qrBase64: string }> { 804 804 return xrpc("com.atproto.server.createTotpSecret", { ··· 807 807 }); 808 808 }, 809 809 810 - async enableTotp( 810 + enableTotp( 811 811 token: string, 812 812 code: string, 813 813 ): Promise<{ success: boolean; backupCodes: string[] }> { ··· 818 818 }); 819 819 }, 820 820 821 - async disableTotp( 821 + disableTotp( 822 822 token: string, 823 823 password: string, 824 824 code: string, ··· 830 830 }); 831 831 }, 832 832 833 - async regenerateBackupCodes( 833 + regenerateBackupCodes( 834 834 token: string, 835 835 password: string, 836 836 code: string, ··· 842 842 }); 843 843 }, 844 844 845 - async startPasskeyRegistration( 845 + startPasskeyRegistration( 846 846 token: string, 847 847 friendlyName?: string, 848 848 ): Promise<{ options: unknown }> { ··· 853 853 }); 854 854 }, 855 855 856 - async finishPasskeyRegistration( 856 + finishPasskeyRegistration( 857 857 token: string, 858 858 credential: unknown, 859 859 friendlyName?: string, ··· 865 865 }); 866 866 }, 867 867 868 - async listPasskeys(token: string): Promise<{ 868 + listPasskeys(token: string): Promise<{ 869 869 passkeys: Array<{ 870 870 id: string; 871 871 credentialId: string; ··· 897 897 }); 898 898 }, 899 899 900 - async listTrustedDevices(token: string): Promise<{ 900 + listTrustedDevices(token: string): Promise<{ 901 901 devices: Array<{ 902 902 id: string; 903 903 userAgent: string | null; ··· 910 910 return xrpc("_account.listTrustedDevices", { token }); 911 911 }, 912 912 913 - async revokeTrustedDevice( 913 + revokeTrustedDevice( 914 914 token: string, 915 915 deviceId: string, 916 916 ): Promise<{ success: boolean }> { ··· 921 921 }); 922 922 }, 923 923 924 - async updateTrustedDevice( 924 + updateTrustedDevice( 925 925 token: string, 926 926 deviceId: string, 927 927 friendlyName: string, ··· 933 933 }); 934 934 }, 935 935 936 - async getReauthStatus(token: string): Promise<{ 936 + getReauthStatus(token: string): Promise<{ 937 937 requiresReauth: boolean; 938 938 lastReauthAt: string | null; 939 939 availableMethods: string[]; ··· 941 941 return xrpc("_account.getReauthStatus", { token }); 942 942 }, 943 943 944 - async reauthPassword( 944 + reauthPassword( 945 945 token: string, 946 946 password: string, 947 947 ): Promise<{ success: boolean; reauthAt: string }> { ··· 952 952 }); 953 953 }, 954 954 955 - async reauthTotp( 955 + reauthTotp( 956 956 token: string, 957 957 code: string, 958 958 ): Promise<{ success: boolean; reauthAt: string }> { ··· 963 963 }); 964 964 }, 965 965 966 - async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 966 + reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 967 return xrpc("_account.reauthPasskeyStart", { 968 968 method: "POST", 969 969 token, 970 970 }); 971 971 }, 972 972 973 - async reauthPasskeyFinish( 973 + reauthPasskeyFinish( 974 974 token: string, 975 975 credential: unknown, 976 976 ): Promise<{ success: boolean; reauthAt: string }> { ··· 981 981 }); 982 982 }, 983 983 984 - async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 984 + reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 985 985 return xrpc("com.atproto.server.reserveSigningKey", { 986 986 method: "POST", 987 987 body: { did }, 988 988 }); 989 989 }, 990 990 991 - async getRecommendedDidCredentials(token: string): Promise<{ 991 + getRecommendedDidCredentials(token: string): Promise<{ 992 992 rotationKeys?: string[]; 993 993 alsoKnownAs?: string[]; 994 994 verificationMethods?: { atproto?: string }; ··· 1043 1043 return res.json(); 1044 1044 }, 1045 1045 1046 - async startPasskeyRegistrationForSetup( 1046 + startPasskeyRegistrationForSetup( 1047 1047 did: string, 1048 1048 setupToken: string, 1049 1049 friendlyName?: string, ··· 1054 1054 }); 1055 1055 }, 1056 1056 1057 - async completePasskeySetup( 1057 + completePasskeySetup( 1058 1058 did: string, 1059 1059 setupToken: string, 1060 1060 passkeyCredential: unknown, ··· 1071 1071 }); 1072 1072 }, 1073 1073 1074 - async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1074 + requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 1075 return xrpc("_account.requestPasskeyRecovery", { 1076 1076 method: "POST", 1077 1077 body: { email }, 1078 1078 }); 1079 1079 }, 1080 1080 1081 - async recoverPasskeyAccount( 1081 + recoverPasskeyAccount( 1082 1082 did: string, 1083 1083 recoveryToken: string, 1084 1084 newPassword: string, ··· 1089 1089 }); 1090 1090 }, 1091 1091 1092 - async verifyMigrationEmail( 1092 + verifyMigrationEmail( 1093 1093 token: string, 1094 1094 email: string, 1095 1095 ): Promise<{ success: boolean; did: string }> { ··· 1099 1099 }); 1100 1100 }, 1101 1101 1102 - async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 1102 + resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 1103 1103 return xrpc("com.atproto.server.resendMigrationVerification", { 1104 1104 method: "POST", 1105 1105 body: { email }, 1106 1106 }); 1107 1107 }, 1108 1108 1109 - async verifyToken( 1109 + verifyToken( 1110 1110 token: string, 1111 1111 identifier: string, 1112 1112 accessToken?: string, ··· 1123 1123 }); 1124 1124 }, 1125 1125 1126 - async getDidDocument(token: string): Promise<DidDocument> { 1126 + getDidDocument(token: string): Promise<DidDocument> { 1127 1127 return xrpc("_account.getDidDocument", { token }); 1128 1128 }, 1129 1129 1130 - async updateDidDocument( 1130 + updateDidDocument( 1131 1131 token: string, 1132 1132 params: { 1133 1133 verificationMethods?: VerificationMethod[]; ··· 1170 1170 return res.arrayBuffer(); 1171 1171 }, 1172 1172 1173 - async listBackups(token: string): Promise<{ 1173 + listBackups(token: string): Promise<{ 1174 1174 backups: Array<{ 1175 1175 id: string; 1176 1176 repoRev: string; ··· 1199 1199 return res.blob(); 1200 1200 }, 1201 1201 1202 - async createBackup(token: string): Promise<{ 1202 + createBackup(token: string): Promise<{ 1203 1203 id: string; 1204 1204 repoRev: string; 1205 1205 sizeBytes: number; ··· 1219 1219 }); 1220 1220 }, 1221 1221 1222 - async setBackupEnabled( 1222 + setBackupEnabled( 1223 1223 token: string, 1224 1224 enabled: boolean, 1225 1225 ): Promise<{ enabled: boolean }> {
+9 -2
frontend/src/lib/auth.svelte.ts
··· 40 40 savedAccounts: SavedAccount[]; 41 41 } 42 42 43 - let state = $state<AuthState>({ 43 + const state = $state<AuthState>({ 44 44 session: null, 45 45 loading: true, 46 46 error: null, ··· 318 318 319 319 export async function logout(): Promise<void> { 320 320 if (state.session) { 321 + const did = state.session.did; 322 + const refreshToken = state.session.refreshJwt; 321 323 try { 322 - await api.deleteSession(state.session.accessJwt); 324 + await fetch("/oauth/revoke", { 325 + method: "POST", 326 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 327 + body: new URLSearchParams({ token: refreshToken }), 328 + }); 323 329 } catch { 324 330 // Ignore errors on logout 325 331 } 332 + removeSavedAccount(did); 326 333 } 327 334 state.session = null; 328 335 saveSession(null);
+59 -4
frontend/src/lib/migration/atproto-client.ts
··· 188 188 return session; 189 189 } 190 190 191 - async describeServer(): Promise<ServerDescription> { 191 + describeServer(): Promise<ServerDescription> { 192 192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 193 193 } 194 194 195 - async getServiceAuth( 195 + getServiceAuth( 196 196 aud: string, 197 197 lxm?: string, 198 198 ): Promise<{ token: string }> { ··· 203 203 return this.xrpc("com.atproto.server.getServiceAuth", { params }); 204 204 } 205 205 206 - async getRepo(did: string): Promise<Uint8Array> { 206 + getRepo(did: string): Promise<Uint8Array> { 207 207 return this.xrpc("com.atproto.sync.getRepo", { 208 208 params: { did }, 209 209 }); ··· 662 662 return url.toString(); 663 663 } 664 664 665 + export async function initiateOAuthWithPAR( 666 + metadata: OAuthServerMetadata, 667 + params: { 668 + clientId: string; 669 + redirectUri: string; 670 + codeChallenge: string; 671 + state: string; 672 + scope?: string; 673 + dpopJkt?: string; 674 + loginHint?: string; 675 + }, 676 + ): Promise<string> { 677 + if (!metadata.pushed_authorization_request_endpoint) { 678 + return buildOAuthAuthorizationUrl(metadata, params); 679 + } 680 + 681 + const body = new URLSearchParams({ 682 + response_type: "code", 683 + client_id: params.clientId, 684 + redirect_uri: params.redirectUri, 685 + code_challenge: params.codeChallenge, 686 + code_challenge_method: "S256", 687 + state: params.state, 688 + scope: params.scope ?? "atproto", 689 + }); 690 + 691 + if (params.dpopJkt) { 692 + body.set("dpop_jkt", params.dpopJkt); 693 + } 694 + if (params.loginHint) { 695 + body.set("login_hint", params.loginHint); 696 + } 697 + 698 + const res = await fetch(metadata.pushed_authorization_request_endpoint, { 699 + method: "POST", 700 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 701 + body: body.toString(), 702 + }); 703 + 704 + if (!res.ok) { 705 + const err = await res.json().catch(() => ({ 706 + error: "par_error", 707 + error_description: res.statusText, 708 + })); 709 + throw new Error(err.error_description || err.error || "PAR request failed"); 710 + } 711 + 712 + const { request_uri } = await res.json(); 713 + 714 + const authUrl = new URL(metadata.authorization_endpoint); 715 + authUrl.searchParams.set("client_id", params.clientId); 716 + authUrl.searchParams.set("request_uri", request_uri); 717 + return authUrl.toString(); 718 + } 719 + 665 720 export async function exchangeOAuthCode( 666 721 metadata: OAuthServerMetadata, 667 722 params: { ··· 839 894 } 840 895 841 896 export function getMigrationOAuthRedirectUri(): string { 842 - return `${globalThis.location.origin}/migrate`; 897 + return `${globalThis.location.origin}/app/migrate`; 843 898 } 844 899 845 900 export interface DPoPKeyPair {
+4 -3
frontend/src/lib/migration/blob-migration.ts
··· 107 107 errorMessage, 108 108 ); 109 109 110 - const isNetworkError = 111 - errorMessage.includes("fetch") || 110 + const isNetworkError = errorMessage.includes("fetch") || 112 111 errorMessage.includes("network") || 113 112 errorMessage.includes("CORS") || 114 113 errorMessage.includes("Failed to fetch") || ··· 124 123 if (migrated > 0) { 125 124 onProgress({ 126 125 currentOperation: 127 - `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`, 126 + `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${ 127 + remaining + 1 128 + } could not be fetched - these may need to be re-uploaded.`, 128 129 }); 129 130 } else { 130 131 onProgress({
+35 -13
frontend/src/lib/migration/flow.svelte.ts
··· 8 8 } from "./types"; 9 9 import { 10 10 AtprotoClient, 11 - buildOAuthAuthorizationUrl, 12 11 clearDPoPKey, 13 12 createLocalClient, 14 13 exchangeOAuthCode, ··· 18 17 getMigrationOAuthClientId, 19 18 getMigrationOAuthRedirectUri, 20 19 getOAuthServerMetadata, 20 + initiateOAuthWithPAR, 21 21 loadDPoPKey, 22 22 resolvePdsUrl, 23 23 saveDPoPKey, ··· 84 84 let sourceClient: AtprotoClient | null = null; 85 85 let localClient: AtprotoClient | null = null; 86 86 let localServerInfo: ServerDescription | null = null; 87 - let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 - null; 89 87 90 88 function setStep(step: InboundStep) { 91 89 state.step = step; ··· 141 139 "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", 142 140 ); 143 141 } 144 - sourceOAuthMetadata = metadata; 145 142 146 143 const { codeVerifier, codeChallenge } = await generatePKCE(); 147 144 const oauthState = generateOAuthState(); ··· 155 152 localStorage.setItem("migration_source_did", state.sourceDid); 156 153 localStorage.setItem("migration_source_handle", state.sourceHandle); 157 154 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 155 + if (state.resumeToStep) { 156 + localStorage.setItem("migration_resume_to_step", state.resumeToStep); 157 + } 158 158 159 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 159 + const authUrl = await initiateOAuthWithPAR(metadata, { 160 160 clientId: getMigrationOAuthClientId(), 161 161 redirectUri: getMigrationOAuthRedirectUri(), 162 162 codeChallenge, ··· 185 185 localStorage.removeItem("migration_source_did"); 186 186 localStorage.removeItem("migration_source_handle"); 187 187 localStorage.removeItem("migration_oauth_issuer"); 188 + localStorage.removeItem("migration_resume_to_step"); 188 189 } 189 190 190 191 async function handleOAuthCallback( ··· 199 200 const sourceDid = localStorage.getItem("migration_source_did"); 200 201 const sourceHandle = localStorage.getItem("migration_source_handle"); 201 202 const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); 203 + const savedResumeToStep = localStorage.getItem("migration_resume_to_step"); 204 + 205 + if (savedResumeToStep) { 206 + state.needsReauth = true; 207 + state.resumeToStep = savedResumeToStep as InboundMigrationState["step"]; 208 + } 202 209 203 210 if (returnedState !== savedState) { 204 211 cleanupOAuthSessionData(); ··· 229 236 cleanupOAuthSessionData(); 230 237 throw new Error("Could not fetch OAuth server metadata"); 231 238 } 232 - sourceOAuthMetadata = metadata; 233 239 234 240 migrationLog("handleOAuthCallback: Exchanging code for tokens"); 235 241 ··· 269 275 ]; 270 276 271 277 if (postEmailSteps.includes(targetStep)) { 278 + localClient = createLocalClient(); 272 279 if (state.authMethod === "passkey" && state.passkeySetupToken) { 273 - localClient = createLocalClient(); 274 280 setStep("passkey-setup"); 275 281 migrationLog( 276 282 "handleOAuthCallback: Resuming passkey flow at passkey-setup", ··· 281 287 "handleOAuthCallback: Resuming at email-verify for re-auth", 282 288 ); 283 289 } 290 + } else if (targetStep === "email-verify") { 291 + localClient = createLocalClient(); 292 + setStep("email-verify"); 293 + migrationLog("handleOAuthCallback: Resuming at email-verify"); 284 294 } else { 285 295 setStep(targetStep); 286 296 } ··· 550 560 551 561 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 552 562 if (checkingEmailVerification) return false; 553 - if (!sourceClient || !localClient) return false; 554 - 555 - if (state.authMethod === "passkey") { 556 - return false; 557 - } 563 + if (!localClient) return false; 558 564 559 565 checkingEmailVerification = true; 560 566 try { 561 567 const verified = await localClient.checkEmailVerified(state.targetEmail); 562 568 if (!verified) return false; 563 569 570 + if (state.authMethod === "passkey") { 571 + migrationLog( 572 + "checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup", 573 + ); 574 + setStep("passkey-setup"); 575 + return true; 576 + } 577 + 564 578 await localClient.loginDeactivated( 565 579 state.targetEmail, 566 580 state.targetPassword, 567 581 ); 582 + 583 + if (!sourceClient) { 584 + setStep("source-handle"); 585 + setError( 586 + "Email verified! Please log in to your old account again to complete the migration.", 587 + ); 588 + return true; 589 + } 590 + 568 591 if (state.sourceDid.startsWith("did:web:")) { 569 592 const credentials = await localClient.getRecommendedDidCredentials(); 570 593 state.targetVerificationMethod = ··· 856 879 }; 857 880 sourceClient = null; 858 881 passkeySetup = null; 859 - sourceOAuthMetadata = null; 860 882 clearMigrationState(); 861 883 clearDPoPKey(); 862 884 }
+2
frontend/src/lib/migration/types.ts
··· 254 254 issuer: string; 255 255 authorization_endpoint: string; 256 256 token_endpoint: string; 257 + pushed_authorization_request_endpoint?: string; 257 258 scopes_supported?: string[]; 258 259 response_types_supported?: string[]; 259 260 grant_types_supported?: string[]; 260 261 code_challenge_methods_supported?: string[]; 261 262 dpop_signing_alg_values_supported?: string[]; 263 + require_pushed_authorization_requests?: boolean; 262 264 } 263 265 264 266 export interface OAuthTokenResponse {
+3 -3
frontend/src/lib/oauth.ts
··· 10 10 const CLIENT_ID = !(import.meta.env.DEV) 11 11 ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 12 : `http://localhost/?scope=${SCOPES}`; 13 - const REDIRECT_URI = `${globalThis.location.origin}/`; 13 + const REDIRECT_URI = `${globalThis.location.origin}/app/`; 14 14 15 15 interface OAuthState { 16 16 state: string; ··· 26 26 ); 27 27 } 28 28 29 - async function sha256(plain: string): Promise<ArrayBuffer> { 29 + function sha256(plain: string): Promise<ArrayBuffer> { 30 30 const encoder = new TextEncoder(); 31 31 const data = encoder.encode(plain); 32 32 return crypto.subtle.digest("SHA-256", data); ··· 191 191 export function checkForOAuthCallback(): 192 192 | { code: string; state: string } 193 193 | null { 194 - if (globalThis.location.hash === "#/migrate") { 194 + if (globalThis.location.pathname === "/app/migrate") { 195 195 return null; 196 196 } 197 197
+3 -3
frontend/src/lib/registration/flow.svelte.ts
··· 29 29 mode: RegistrationMode, 30 30 pdsHostname: string, 31 31 ) { 32 - let state = $state<RegistrationFlowState>({ 32 + const state = $state<RegistrationFlowState>({ 33 33 mode, 34 34 step: "info", 35 35 info: { ··· 80 80 } 81 81 } 82 82 83 - async function proceedFromInfo() { 83 + function proceedFromInfo() { 84 84 state.error = null; 85 85 if (state.info.didType === "web-external") { 86 86 state.step = "key-choice"; ··· 130 130 } 131 131 } 132 132 133 - async function confirmInitialDidDoc() { 133 + function confirmInitialDidDoc() { 134 134 state.step = "creating"; 135 135 } 136 136
+24 -11
frontend/src/lib/router.svelte.ts
··· 1 - let currentPath = $state( 2 - getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), 3 - ); 1 + const APP_BASE = "/app"; 4 2 5 - function getPathWithoutQuery(hash: string): string { 6 - const queryIndex = hash.indexOf("?"); 7 - return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 3 + function getAppPath(): string { 4 + const pathname = globalThis.location.pathname; 5 + if (pathname.startsWith(APP_BASE)) { 6 + const path = pathname.slice(APP_BASE.length) || "/"; 7 + return path.startsWith("/") ? path : "/" + path; 8 + } 9 + return "/"; 8 10 } 9 11 10 - globalThis.addEventListener("hashchange", () => { 11 - currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); 12 + let currentPath = $state(getAppPath()); 13 + 14 + globalThis.addEventListener("popstate", () => { 15 + currentPath = getAppPath(); 12 16 }); 13 17 14 - export function navigate(path: string) { 15 - currentPath = path; 16 - globalThis.location.hash = path; 18 + export function navigate(path: string, replace = false) { 19 + const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path); 20 + if (replace) { 21 + globalThis.history.replaceState(null, "", fullPath); 22 + } else { 23 + globalThis.history.pushState(null, "", fullPath); 24 + } 25 + currentPath = path.startsWith("/") ? path : "/" + path; 17 26 } 18 27 19 28 export function getCurrentPath() { 20 29 return currentPath; 21 30 } 31 + 32 + export function getFullUrl(path: string): string { 33 + return APP_BASE + (path.startsWith("/") ? path : "/" + path); 34 + }
+1 -1
frontend/src/lib/serverConfig.svelte.ts
··· 10 10 loading: boolean; 11 11 } 12 12 13 - let state = $state<ServerConfigState>({ 13 + const state = $state<ServerConfigState>({ 14 14 serverName: null, 15 15 primaryColor: null, 16 16 primaryColorDark: null,
+2 -2
frontend/src/routes/ActAs.svelte
··· 10 10 let actAsInProgress = $state(false) 11 11 12 12 function getDid(): string | null { 13 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 13 + const params = new URLSearchParams(window.location.search) 14 14 return params.get('did') 15 15 } 16 16 ··· 89 89 90 90 const parData = await parResponse.json() 91 91 if (parData.request_uri) { 92 - window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` 92 + window.location.href = `/app/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` 93 93 } else { 94 94 error = $_('actAs.invalidResponse') 95 95 loading = false
+1 -1
frontend/src/routes/Admin.svelte
··· 308 308 {#if auth.session?.isAdmin} 309 309 <div class="page"> 310 310 <header> 311 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 311 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 312 312 <h1>{$_('admin.title')}</h1> 313 313 </header> 314 314 {#if loading}
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 99 99 </script> 100 100 <div class="page"> 101 101 <header> 102 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 102 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 103 103 <h1>{$_('appPasswords.title')}</h1> 104 104 </header> 105 105 <p class="description">
+1 -1
frontend/src/routes/Comms.svelte
··· 168 168 </script> 169 169 <div class="page"> 170 170 <header> 171 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 171 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 172 172 <h1>{$_('comms.title')}</h1> 173 173 <p class="description">{$_('comms.description')}</p> 174 174 </header>
+3 -3
frontend/src/routes/Controllers.svelte
··· 232 232 233 233 <div class="page"> 234 234 <header> 235 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 235 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 236 236 <h1>{$_('delegation.title')}</h1> 237 237 </header> 238 238 ··· 358 358 </div> 359 359 </div> 360 360 <div class="item-actions"> 361 - <a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 361 + <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 362 362 {$_('delegation.actAs')} 363 363 </a> 364 364 </div> ··· 423 423 <h2>{$_('delegation.auditLog')}</h2> 424 424 <p class="section-description">{$_('delegation.auditLogDesc')}</p> 425 425 </div> 426 - <a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 426 + <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 427 427 </section> 428 428 {/if} 429 429 </div>
+16 -16
frontend/src/routes/Dashboard.svelte
··· 166 166 167 167 <nav class="nav-grid"> 168 168 {#if auth.session.status === 'migrated'} 169 - <a href="#/did-document" class="nav-card migrated-card"> 169 + <a href="/app/did-document" class="nav-card migrated-card"> 170 170 <h3>{$_('dashboard.navDidDocument')}</h3> 171 171 <p>{$_('dashboard.navDidDocumentDesc')}</p> 172 172 </a> 173 - <a href="#/sessions" class="nav-card"> 173 + <a href="/app/sessions" class="nav-card"> 174 174 <h3>{$_('dashboard.navSessions')}</h3> 175 175 <p>{$_('dashboard.navSessionsDesc')}</p> 176 176 </a> 177 - <a href="#/security" class="nav-card"> 177 + <a href="/app/security" class="nav-card"> 178 178 <h3>{$_('dashboard.navSecurity')}</h3> 179 179 <p>{$_('dashboard.navSecurityDesc')}</p> 180 180 </a> 181 - <a href="#/settings" class="nav-card"> 181 + <a href="/app/settings" class="nav-card"> 182 182 <h3>{$_('dashboard.navSettings')}</h3> 183 183 <p>{$_('dashboard.navSettingsDesc')}</p> 184 184 </a> 185 - <a href="#/migrate" class="nav-card"> 185 + <a href="/app/migrate" class="nav-card"> 186 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> 188 188 </a> 189 189 {:else} 190 - <a href="#/app-passwords" class="nav-card"> 190 + <a href="/app/app-passwords" class="nav-card"> 191 191 <h3>{$_('dashboard.navAppPasswords')}</h3> 192 192 <p>{$_('dashboard.navAppPasswordsDesc')}</p> 193 193 </a> 194 - <a href="#/sessions" class="nav-card"> 194 + <a href="/app/sessions" class="nav-card"> 195 195 <h3>{$_('dashboard.navSessions')}</h3> 196 196 <p>{$_('dashboard.navSessionsDesc')}</p> 197 197 </a> 198 198 {#if inviteCodesEnabled && auth.session.isAdmin} 199 - <a href="#/invite-codes" class="nav-card"> 199 + <a href="/app/invite-codes" class="nav-card"> 200 200 <h3>{$_('dashboard.navInviteCodes')}</h3> 201 201 <p>{$_('dashboard.navInviteCodesDesc')}</p> 202 202 </a> 203 203 {/if} 204 - <a href="#/settings" class="nav-card"> 204 + <a href="/app/settings" class="nav-card"> 205 205 <h3>{$_('dashboard.navSettings')}</h3> 206 206 <p>{$_('dashboard.navSettingsDesc')}</p> 207 207 </a> 208 - <a href="#/security" class="nav-card"> 208 + <a href="/app/security" class="nav-card"> 209 209 <h3>{$_('dashboard.navSecurity')}</h3> 210 210 <p>{$_('dashboard.navSecurityDesc')}</p> 211 211 </a> 212 - <a href="#/comms" class="nav-card"> 212 + <a href="/app/comms" class="nav-card"> 213 213 <h3>{$_('dashboard.navComms')}</h3> 214 214 <p>{$_('dashboard.navCommsDesc')}</p> 215 215 </a> 216 - <a href="#/repo" class="nav-card"> 216 + <a href="/app/repo" class="nav-card"> 217 217 <h3>{$_('dashboard.navRepo')}</h3> 218 218 <p>{$_('dashboard.navRepoDesc')}</p> 219 219 </a> 220 - <a href="#/controllers" class="nav-card"> 220 + <a href="/app/controllers" class="nav-card"> 221 221 <h3>{$_('dashboard.navDelegation')}</h3> 222 222 <p>{$_('dashboard.navDelegationDesc')}</p> 223 223 </a> 224 224 {#if isDidWeb} 225 - <a href="#/did-document" class="nav-card did-web-card"> 225 + <a href="/app/did-document" class="nav-card did-web-card"> 226 226 <h3>{$_('dashboard.navDidDocument')}</h3> 227 227 <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 228 </a> 229 229 {/if} 230 - <a href="#/migrate" class="nav-card"> 230 + <a href="/app/migrate" class="nav-card"> 231 231 <h3>{$_('migration.navTitle')}</h3> 232 232 <p>{$_('migration.navDesc')}</p> 233 233 </a> 234 234 {#if auth.session.isAdmin} 235 - <a href="#/admin" class="nav-card admin-card"> 235 + <a href="/app/admin" class="nav-card admin-card"> 236 236 <h3>{$_('dashboard.navAdmin')}</h3> 237 237 <p>{$_('dashboard.navAdminDesc')}</p> 238 238 </a>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 108 108 109 109 <div class="page"> 110 110 <header> 111 - <a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a> 111 + <a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a> 112 112 <h1>{$_('delegation.auditLogTitle')}</h1> 113 113 </header> 114 114
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 105 105 106 106 <div class="page"> 107 107 <header> 108 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 108 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 109 109 <h1>{$_('didEditor.title')}</h1> 110 110 </header> 111 111
-527
frontend/src/routes/Home.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte' 3 - import { _ } from '../lib/i18n' 4 - import { getAuthState } from '../lib/auth.svelte' 5 - import { getServerConfigState } from '../lib/serverConfig.svelte' 6 - import { api } from '../lib/api' 7 - 8 - const auth = getAuthState() 9 - const serverConfig = getServerConfigState() 10 - const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox' 11 - 12 - let pdsHostname = $state<string | null>(null) 13 - let pdsVersion = $state<string | null>(null) 14 - let userCount = $state<number | null>(null) 15 - 16 - const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto'] 17 - const wordSpacing: Record<string, string> = { 18 - 'Bluesky': '0.01em', 19 - 'Tangled': '0.02em', 20 - 'Leaflet': '0.05em', 21 - 'ATProto': '0', 22 - } 23 - let currentWordIndex = $state(0) 24 - let isTransitioning = $state(false) 25 - let currentWord = $derived(heroWords[currentWordIndex]) 26 - let currentSpacing = $derived(wordSpacing[currentWord] || '0') 27 - 28 - onMount(() => { 29 - api.describeServer().then(info => { 30 - if (info.availableUserDomains?.length) { 31 - pdsHostname = info.availableUserDomains[0] 32 - } 33 - if (info.version) { 34 - pdsVersion = info.version 35 - } 36 - }).catch(() => {}) 37 - 38 - const baseDuration = 2000 39 - let wordTimeout: ReturnType<typeof setTimeout> 40 - 41 - function cycleWord() { 42 - isTransitioning = true 43 - setTimeout(() => { 44 - currentWordIndex = (currentWordIndex + 1) % heroWords.length 45 - isTransitioning = false 46 - const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration 47 - wordTimeout = setTimeout(cycleWord, duration) 48 - }, 100) 49 - } 50 - 51 - wordTimeout = setTimeout(cycleWord, baseDuration) 52 - 53 - api.listRepos(1000).then(data => { 54 - userCount = data.repos.length 55 - }).catch(() => {}) 56 - 57 - const pattern = document.getElementById('dotPattern') 58 - if (!pattern) return 59 - 60 - const spacing = 32 61 - const cols = Math.ceil((window.innerWidth + 600) / spacing) 62 - const rows = Math.ceil((window.innerHeight + 100) / spacing) 63 - const dots: { el: HTMLElement; x: number; y: number }[] = [] 64 - 65 - for (let y = 0; y < rows; y++) { 66 - for (let x = 0; x < cols; x++) { 67 - const dot = document.createElement('div') 68 - dot.className = 'dot' 69 - dot.style.left = (x * spacing) + 'px' 70 - dot.style.top = (y * spacing) + 'px' 71 - pattern.appendChild(dot) 72 - dots.push({ el: dot, x: x * spacing, y: y * spacing }) 73 - } 74 - } 75 - 76 - let mouseX = -1000 77 - let mouseY = -1000 78 - 79 - const handleMouseMove = (e: MouseEvent) => { 80 - mouseX = e.clientX 81 - mouseY = e.clientY 82 - } 83 - 84 - document.addEventListener('mousemove', handleMouseMove) 85 - 86 - let animationId: number 87 - 88 - function updateDots() { 89 - const patternRect = pattern.getBoundingClientRect() 90 - dots.forEach(dot => { 91 - const dotX = patternRect.left + dot.x + 5 92 - const dotY = patternRect.top + dot.y + 5 93 - const dist = Math.hypot(mouseX - dotX, mouseY - dotY) 94 - const maxDist = 120 95 - const scale = Math.min(1, Math.max(0.1, dist / maxDist)) 96 - dot.el.style.transform = `scale(${scale})` 97 - }) 98 - animationId = requestAnimationFrame(updateDots) 99 - } 100 - updateDots() 101 - 102 - return () => { 103 - document.removeEventListener('mousemove', handleMouseMove) 104 - cancelAnimationFrame(animationId) 105 - clearTimeout(wordTimeout) 106 - } 107 - }) 108 - </script> 109 - 110 - <div class="pattern-container"> 111 - <div class="pattern" id="dotPattern"></div> 112 - </div> 113 - <div class="pattern-fade"></div> 114 - 115 - <nav> 116 - <div class="nav-left"> 117 - {#if serverConfig.hasLogo} 118 - <img src="/logo" alt="Logo" class="nav-logo" /> 119 - {/if} 120 - {#if pdsHostname} 121 - <span class="hostname">{pdsHostname}</span> 122 - {#if userCount !== null} 123 - <span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span> 124 - {/if} 125 - {:else} 126 - <span class="hostname placeholder">loading...</span> 127 - {/if} 128 - </div> 129 - <span class="nav-meta">{pdsVersion || ''}</span> 130 - </nav> 131 - 132 - <div class="home"> 133 - <section class="hero"> 134 - <h1>A home for your <span class="cycling-word-container"><span class="cycling-word" class:transitioning={isTransitioning} style="letter-spacing: {currentSpacing}">{currentWord}</span></span> account</h1> 135 - 136 - <p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p> 137 - 138 - <div class="actions"> 139 - {#if auth.session} 140 - <a href="#/dashboard" class="btn primary">@{auth.session.handle}</a> 141 - {:else} 142 - <a href="#/register" class="btn primary">Join This Server</a> 143 - <a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a> 144 - {/if} 145 - </div> 146 - 147 - <blockquote> 148 - <p>"Nature does not hurry, yet everything is accomplished."</p> 149 - <cite>Lao Tzu</cite> 150 - </blockquote> 151 - </section> 152 - 153 - <section class="content"> 154 - <h2>What you get</h2> 155 - 156 - <div class="features"> 157 - <div class="feature"> 158 - <h3>Real security</h3> 159 - <p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p> 160 - </div> 161 - 162 - <div class="feature"> 163 - <h3>Your own identity</h3> 164 - <p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p> 165 - </div> 166 - 167 - <div class="feature"> 168 - <h3>Stay in the loop</h3> 169 - <p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p> 170 - </div> 171 - 172 - <div class="feature"> 173 - <h3>You decide what apps can do</h3> 174 - <p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p> 175 - </div> 176 - 177 - <div class="feature"> 178 - <h3>App passwords with guardrails</h3> 179 - <p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p> 180 - </div> 181 - 182 - <div class="feature"> 183 - <h3>Delegate without sharing passwords</h3> 184 - <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 - </div> 186 - 187 - <div class="feature"> 188 - <h3>Automatic backups</h3> 189 - <p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p> 190 - </div> 191 - </div> 192 - 193 - <h2>Everything in one place</h2> 194 - 195 - <p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p> 196 - 197 - <h2>Works with everything</h2> 198 - 199 - <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p> 200 - 201 - <h2>Ready to try it?</h2> 202 - 203 - <p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p> 204 - 205 - <div class="actions"> 206 - {#if auth.session} 207 - <a href="#/dashboard" class="btn primary">@{auth.session.handle}</a> 208 - {:else} 209 - <a href="#/register" class="btn primary">Join This Server</a> 210 - <a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a> 211 - {/if} 212 - </div> 213 - </section> 214 - 215 - <footer class="site-footer"> 216 - <span>Made by people who don't take themselves too seriously</span> 217 - <span>Open Source: issues & PRs welcome</span> 218 - </footer> 219 - </div> 220 - 221 - <style> 222 - .pattern-container { 223 - position: fixed; 224 - top: -32px; 225 - left: -32px; 226 - right: -32px; 227 - bottom: -32px; 228 - pointer-events: none; 229 - z-index: 1; 230 - overflow: hidden; 231 - } 232 - 233 - .pattern { 234 - position: absolute; 235 - top: 0; 236 - left: 0; 237 - width: calc(100% + 500px); 238 - height: 100%; 239 - animation: drift 80s linear infinite; 240 - } 241 - 242 - .pattern :global(.dot) { 243 - position: absolute; 244 - width: 10px; 245 - height: 10px; 246 - background: rgba(0, 0, 0, 0.06); 247 - border-radius: 50%; 248 - transition: transform 0.04s linear; 249 - } 250 - 251 - @media (prefers-color-scheme: dark) { 252 - .pattern :global(.dot) { 253 - background: rgba(255, 255, 255, 0.1); 254 - } 255 - } 256 - 257 - .pattern-fade { 258 - position: fixed; 259 - top: 0; 260 - left: 0; 261 - right: 0; 262 - bottom: 0; 263 - background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%); 264 - pointer-events: none; 265 - z-index: 2; 266 - } 267 - 268 - @keyframes drift { 269 - 0% { transform: translateX(-500px); } 270 - 100% { transform: translateX(0); } 271 - } 272 - 273 - nav { 274 - position: fixed; 275 - top: 12px; 276 - left: 32px; 277 - right: 32px; 278 - background: var(--accent); 279 - padding: 10px 18px; 280 - z-index: 100; 281 - border-radius: var(--radius-xl); 282 - display: flex; 283 - justify-content: space-between; 284 - align-items: center; 285 - } 286 - 287 - .nav-left { 288 - display: flex; 289 - align-items: center; 290 - gap: var(--space-3); 291 - } 292 - 293 - .nav-logo { 294 - height: 28px; 295 - width: auto; 296 - object-fit: contain; 297 - border-radius: var(--radius-sm); 298 - } 299 - 300 - .hostname { 301 - font-weight: var(--font-semibold); 302 - font-size: var(--text-base); 303 - letter-spacing: 0.08em; 304 - color: var(--text-inverse); 305 - text-transform: uppercase; 306 - } 307 - 308 - .hostname.placeholder { 309 - opacity: 0.4; 310 - } 311 - 312 - .user-count { 313 - font-size: var(--text-sm); 314 - color: var(--text-inverse); 315 - opacity: 0.85; 316 - padding: 4px 10px; 317 - background: rgba(255, 255, 255, 0.15); 318 - border-radius: var(--radius-md); 319 - white-space: nowrap; 320 - } 321 - 322 - @media (prefers-color-scheme: dark) { 323 - .user-count { 324 - background: rgba(0, 0, 0, 0.15); 325 - } 326 - } 327 - 328 - .nav-meta { 329 - font-size: var(--text-sm); 330 - color: var(--text-inverse); 331 - opacity: 0.6; 332 - letter-spacing: 0.05em; 333 - } 334 - 335 - .home { 336 - position: relative; 337 - z-index: 10; 338 - max-width: var(--width-xl); 339 - margin: 0 auto; 340 - padding: 72px 32px 32px; 341 - } 342 - 343 - .hero { 344 - padding: var(--space-7) 0 var(--space-8); 345 - border-bottom: 1px solid var(--border-color); 346 - margin-bottom: var(--space-8); 347 - } 348 - 349 - h1 { 350 - font-size: var(--text-4xl); 351 - font-weight: var(--font-semibold); 352 - line-height: var(--leading-tight); 353 - margin-bottom: var(--space-6); 354 - letter-spacing: -0.02em; 355 - } 356 - 357 - .cycling-word-container { 358 - display: inline-block; 359 - width: 3.9em; 360 - text-align: left; 361 - } 362 - 363 - .cycling-word { 364 - display: inline-block; 365 - transition: opacity 0.1s ease, transform 0.1s ease; 366 - } 367 - 368 - .cycling-word.transitioning { 369 - opacity: 0; 370 - transform: scale(0.95); 371 - } 372 - 373 - .lede { 374 - font-size: var(--text-xl); 375 - font-weight: var(--font-medium); 376 - color: var(--text-primary); 377 - line-height: var(--leading-relaxed); 378 - margin-bottom: 0; 379 - } 380 - 381 - .actions { 382 - display: flex; 383 - gap: var(--space-4); 384 - margin-top: var(--space-7); 385 - } 386 - 387 - .btn { 388 - font-size: var(--text-sm); 389 - font-weight: var(--font-medium); 390 - text-transform: uppercase; 391 - letter-spacing: 0.06em; 392 - padding: var(--space-4) var(--space-6); 393 - border-radius: var(--radius-lg); 394 - text-decoration: none; 395 - transition: all var(--transition-normal); 396 - border: 1px solid transparent; 397 - } 398 - 399 - .btn.primary { 400 - background: var(--secondary); 401 - color: var(--text-inverse); 402 - border-color: var(--secondary); 403 - } 404 - 405 - .btn.primary:hover { 406 - background: var(--secondary-hover); 407 - border-color: var(--secondary-hover); 408 - } 409 - 410 - .btn.secondary { 411 - background: transparent; 412 - color: var(--text-primary); 413 - border-color: var(--border-color); 414 - } 415 - 416 - .btn.secondary:hover { 417 - background: var(--secondary-muted); 418 - border-color: var(--secondary); 419 - color: var(--secondary); 420 - } 421 - 422 - blockquote { 423 - margin: var(--space-8) 0 0 0; 424 - padding: var(--space-6); 425 - background: var(--accent-muted); 426 - border-left: 3px solid var(--accent); 427 - border-radius: 0 var(--radius-xl) var(--radius-xl) 0; 428 - } 429 - 430 - blockquote p { 431 - font-size: var(--text-lg); 432 - color: var(--text-primary); 433 - font-style: italic; 434 - margin-bottom: var(--space-3); 435 - } 436 - 437 - blockquote cite { 438 - font-size: var(--text-sm); 439 - color: var(--text-secondary); 440 - font-style: normal; 441 - text-transform: uppercase; 442 - letter-spacing: 0.05em; 443 - } 444 - 445 - .content h2 { 446 - font-size: var(--text-sm); 447 - font-weight: var(--font-bold); 448 - text-transform: uppercase; 449 - letter-spacing: 0.1em; 450 - color: var(--accent-light); 451 - margin: var(--space-8) 0 var(--space-5); 452 - } 453 - 454 - .content h2:first-child { 455 - margin-top: 0; 456 - } 457 - 458 - .content > p { 459 - font-size: var(--text-base); 460 - color: var(--text-secondary); 461 - margin-bottom: var(--space-5); 462 - line-height: var(--leading-relaxed); 463 - } 464 - 465 - .features { 466 - display: grid; 467 - grid-template-columns: repeat(2, 1fr); 468 - gap: var(--space-6); 469 - margin: var(--space-6) 0 var(--space-8); 470 - } 471 - 472 - .feature { 473 - padding: var(--space-5); 474 - background: var(--bg-secondary); 475 - border-radius: var(--radius-xl); 476 - border: 1px solid var(--border-color); 477 - } 478 - 479 - .feature h3 { 480 - font-size: var(--text-base); 481 - font-weight: var(--font-semibold); 482 - color: var(--text-primary); 483 - margin-bottom: var(--space-3); 484 - } 485 - 486 - .feature p { 487 - font-size: var(--text-sm); 488 - color: var(--text-secondary); 489 - margin: 0; 490 - line-height: var(--leading-relaxed); 491 - } 492 - 493 - @media (max-width: 700px) { 494 - .features { 495 - grid-template-columns: 1fr; 496 - } 497 - 498 - h1 { 499 - font-size: var(--text-3xl); 500 - } 501 - 502 - .actions { 503 - flex-direction: column; 504 - } 505 - 506 - .btn { 507 - text-align: center; 508 - } 509 - 510 - .user-count, 511 - .nav-meta { 512 - display: none; 513 - } 514 - } 515 - 516 - .site-footer { 517 - margin-top: var(--space-9); 518 - padding-top: var(--space-7); 519 - display: flex; 520 - justify-content: space-between; 521 - font-size: var(--text-sm); 522 - color: var(--text-muted); 523 - text-transform: uppercase; 524 - letter-spacing: 0.05em; 525 - border-top: 1px solid var(--border-color); 526 - } 527 - </style>
+1 -1
frontend/src/routes/InviteCodes.svelte
··· 87 87 </script> 88 88 <div class="page"> 89 89 <header> 90 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 90 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 91 91 <h1>{$_('inviteCodes.title')}</h1> 92 92 </header> 93 93 <p class="description">
+3 -3
frontend/src/routes/Login.svelte
··· 161 161 </button> 162 162 163 163 <p class="forgot-links"> 164 - <a href="#/reset-password">{$_('login.forgotPassword')}</a> 164 + <a href="/app/reset-password">{$_('login.forgotPassword')}</a> 165 165 <span class="separator">&middot;</span> 166 - <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 166 + <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 167 167 </p> 168 168 169 169 <p class="link-text"> 170 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 170 + {$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a> 171 171 </p> 172 172 </div> 173 173
+7 -2
frontend/src/routes/Migration.svelte
··· 39 39 if (errorParam) { 40 40 oauthCallbackProcessed = true 41 41 oauthError = errorDescription || errorParam 42 - window.history.replaceState({}, '', '/#/migrate') 42 + window.history.replaceState({}, '', '/app/migrate') 43 43 return 44 44 } 45 45 46 46 if (code && state) { 47 47 oauthCallbackProcessed = true 48 - window.history.replaceState({}, '', '/#/migrate') 48 + window.history.replaceState({}, '', '/app/migrate') 49 49 direction = 'inbound' 50 50 oauthLoading = true 51 51 inboundFlow = createInboundMigrationFlow() 52 + 53 + const stored = loadMigrationState() 54 + if (stored && stored.direction === 'inbound') { 55 + inboundFlow.resumeFromState(stored) 56 + } 52 57 53 58 inboundFlow.handleOAuthCallback(code, state) 54 59 .then(() => {
+2 -2
frontend/src/routes/OAuth2FA.svelte
··· 7 7 let error = $state<string | null>(null) 8 8 9 9 function getRequestUri(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 + const params = new URLSearchParams(window.location.search) 11 11 return params.get('request_uri') 12 12 } 13 13 14 14 function getChannel(): string { 15 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 15 + const params = new URLSearchParams(window.location.search) 16 16 return params.get('channel') || 'email' 17 17 } 18 18
+1 -1
frontend/src/routes/OAuthAccounts.svelte
··· 14 14 let accounts = $state<AccountInfo[]>([]) 15 15 16 16 function getRequestUri(): string | null { 17 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 17 + const params = new URLSearchParams(window.location.search) 18 18 return params.get('request_uri') 19 19 } 20 20
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 34 34 let rememberChoice = $state(false) 35 35 36 36 function getRequestUri(): string | null { 37 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 37 + const params = new URLSearchParams(window.location.search) 38 38 return params.get('request_uri') 39 39 } 40 40
+2 -2
frontend/src/routes/OAuthDelegation.svelte
··· 21 21 }) 22 22 23 23 function getRequestUri(): string | null { 24 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 24 + const params = new URLSearchParams(window.location.search) 25 25 return params.get('request_uri') 26 26 } 27 27 28 28 function getDelegatedDid(): string | null { 29 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 29 + const params = new URLSearchParams(window.location.search) 30 30 return params.get('delegated_did') 31 31 } 32 32
+2 -2
frontend/src/routes/OAuthError.svelte
··· 2 2 import { _ } from '../lib/i18n' 3 3 4 4 function getError(): string { 5 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 5 + const params = new URLSearchParams(window.location.search) 6 6 return params.get('error') || 'Unknown error' 7 7 } 8 8 9 9 function getErrorDescription(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 + const params = new URLSearchParams(window.location.search) 11 11 return params.get('error_description') 12 12 } 13 13
+3 -3
frontend/src/routes/OAuthLogin.svelte
··· 22 22 }) 23 23 24 24 function getRequestUri(): string | null { 25 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 25 + const params = new URLSearchParams(window.location.search) 26 26 return params.get('request_uri') 27 27 } 28 28 29 29 function getErrorFromUrl(): string | null { 30 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 30 + const params = new URLSearchParams(window.location.search) 31 31 return params.get('error') 32 32 } 33 33 ··· 456 456 </form> 457 457 458 458 <p class="help-links"> 459 - <a href="#/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 459 + <a href="/app/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 460 460 </p> 461 461 </div> 462 462
+1 -1
frontend/src/routes/OAuthPasskey.svelte
··· 7 7 let autoStarted = $state(false) 8 8 9 9 function getRequestUri(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 + const params = new URLSearchParams(window.location.search) 11 11 return params.get('request_uri') 12 12 } 13 13
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 8 8 let error = $state<string | null>(null) 9 9 10 10 function getRequestUri(): string | null { 11 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 11 + const params = new URLSearchParams(window.location.search) 12 12 return params.get('request_uri') 13 13 } 14 14
+1 -1
frontend/src/routes/RecoverPasskey.svelte
··· 10 10 let success = $state(false) 11 11 12 12 function getUrlParams(): { did: string | null; token: string | null } { 13 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 13 + const params = new URLSearchParams(window.location.search) 14 14 return { 15 15 did: params.get('did'), 16 16 token: params.get('token'),
+3 -3
frontend/src/routes/Register.svelte
··· 174 174 <div class="migrate-content"> 175 175 <strong>{$_('register.migrateTitle')}</strong> 176 176 <p>{$_('register.migrateDescription')}</p> 177 - <a href="#/migrate" class="migrate-link"> 177 + <a href="/app/migrate" class="migrate-link"> 178 178 {$_('register.migrateLink')} → 179 179 </a> 180 180 </div> ··· 381 381 382 382 <div class="form-links"> 383 383 <p class="link-text"> 384 - {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 384 + {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a> 385 385 </p> 386 386 <p class="link-text"> 387 - {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 387 + {$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a> 388 388 </p> 389 389 </div> 390 390 </div>
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 413 413 </form> 414 414 415 415 <p class="link-text"> 416 - {$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a> 416 + {$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a> 417 417 </p> 418 418 419 419 {:else if flow.state.step === 'key-choice'}
+1 -1
frontend/src/routes/RepoExplorer.svelte
··· 276 276 <div class="page"> 277 277 <header> 278 278 <div class="breadcrumb"> 279 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 279 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 280 280 {#if view !== 'collections'} 281 281 <span class="sep">/</span> 282 282 <button class="breadcrumb-link" onclick={goBack}>
+1 -1
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 71 71 {/if} 72 72 73 73 <p class="link-text"> 74 - <a href="#/login">{$_('common.backToLogin')}</a> 74 + <a href="/app/login">{$_('common.backToLogin')}</a> 75 75 </p> 76 76 </div> 77 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 141 {/if} 142 142 143 143 <p class="link-text"> 144 - <a href="#/login">{$_('common.backToLogin')}</a> 144 + <a href="/app/login">{$_('common.backToLogin')}</a> 145 145 </p> 146 146 </div> 147 147
+4 -4
frontend/src/routes/Security.svelte
··· 403 403 404 404 <div class="page"> 405 405 <header> 406 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 406 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 407 407 <h1>{$_('security.title')}</h1> 408 408 </header> 409 409 ··· 722 722 <p class="description"> 723 723 {$_('security.trustedDevicesDescription')} 724 724 </p> 725 - <a href="#/trusted-devices" class="section-link"> 725 + <a href="/app/trusted-devices" class="section-link"> 726 726 {$_('security.manageTrustedDevices')} &rarr; 727 727 </a> 728 728 </section> ··· 765 765 <strong>{$_('security.legacyLoginWarning')}</strong> 766 766 <p>{$_('security.totpPasswordWarning')}</p> 767 767 <ol> 768 - <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 769 - <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 768 + <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="/app/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 769 + <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="/app/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 770 770 </ol> 771 771 </div> 772 772 {/if}
+1 -1
frontend/src/routes/Sessions.svelte
··· 88 88 </script> 89 89 <div class="page"> 90 90 <header> 91 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 91 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 92 92 <h1>{$_('sessions.title')}</h1> 93 93 </header> 94 94 {#if loading}
+1 -1
frontend/src/routes/Settings.svelte
··· 368 368 </script> 369 369 <div class="page"> 370 370 <header> 371 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 371 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 372 372 <h1>{$_('settings.title')}</h1> 373 373 </header> 374 374 {#if message}
+1 -1
frontend/src/routes/TrustedDevices.svelte
··· 112 112 113 113 <div class="page"> 114 114 <header> 115 - <a href="#/security" class="back">{$_('trustedDevices.backToSecurity')}</a> 115 + <a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a> 116 116 <h1>{$_('trustedDevices.title')}</h1> 117 117 </header> 118 118
+11 -18
frontend/src/routes/Verify.svelte
··· 33 33 34 34 35 35 function parseQueryParams() { 36 - const hash = window.location.hash 37 - const queryIndex = hash.indexOf('?') 38 - if (queryIndex === -1) return {} 39 - 40 - const queryString = hash.slice(queryIndex + 1) 41 36 const params: Record<string, string> = {} 42 - for (const pair of queryString.split('&')) { 43 - const [key, value] = pair.split('=') 44 - if (key && value) { 45 - params[decodeURIComponent(key)] = decodeURIComponent(value) 46 - } 37 + const searchParams = new URLSearchParams(window.location.search) 38 + for (const [key, value] of searchParams.entries()) { 39 + params[key] = value 47 40 } 48 41 return params 49 42 } ··· 235 228 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 229 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 230 <div class="actions"> 238 - <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 231 + <a href="/app/settings" class="btn">{$_('common.backToSettings')}</a> 239 232 </div> 240 233 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 234 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 242 235 <p class="info-text">{$_('verify.canNowSignIn')}</p> 243 236 <div class="actions"> 244 - <a href="#/login" class="btn">{$_('verify.signIn')}</a> 237 + <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 245 238 </div> 246 239 {:else} 247 240 <p class="subtitle"> ··· 259 252 {#if !auth.session} 260 253 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 254 <div class="actions"> 262 - <a href="#/login" class="btn">{$_('verify.signIn')}</a> 255 + <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 263 256 </div> 264 257 {:else} 265 258 {#if error} ··· 301 294 </form> 302 295 303 296 <p class="link-text"> 304 - <a href="#/settings">{$_('common.backToSettings')}</a> 297 + <a href="/app/settings">{$_('common.backToSettings')}</a> 305 298 </p> 306 299 {/if} 307 300 {:else if mode === 'token'} ··· 356 349 </form> 357 350 358 351 <p class="link-text"> 359 - <a href="#/login">{$_('common.backToLogin')}</a> 352 + <a href="/app/login">{$_('common.backToLogin')}</a> 360 353 </p> 361 354 {:else if pendingVerification} 362 355 <h1>{$_('verify.title')}</h1> ··· 399 392 </form> 400 393 401 394 <p class="link-text"> 402 - <a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 395 + <a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 403 396 </p> 404 397 {:else} 405 398 <h1>{$_('verify.title')}</h1> ··· 407 400 <p class="info-text">{$_('verify.noPendingInfo')}</p> 408 401 409 402 <div class="actions"> 410 - <a href="#/register" class="btn">{$_('verify.createAccount')}</a> 411 - <a href="#/login" class="btn secondary">{$_('verify.signIn')}</a> 403 + <a href="/app/register" class="btn">{$_('verify.createAccount')}</a> 404 + <a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a> 412 405 </div> 413 406 {/if} 414 407 </div>
+10 -7
frontend/src/tests/AppPasswords.test.ts
··· 22 22 setupUnauthenticatedUser(); 23 23 render(AppPasswords); 24 24 await waitFor(() => { 25 - expect(globalThis.location.hash).toBe("#/login"); 25 + expect(globalThis.location.pathname).toBe("/app/login"); 26 26 }); 27 27 }); 28 28 }); ··· 41 41 screen.getByRole("heading", { name: /app passwords/i, level: 1 }), 42 42 ).toBeInTheDocument(); 43 43 expect(screen.getByRole("link", { name: /dashboard/i })) 44 - .toHaveAttribute("href", "#/dashboard"); 44 + .toHaveAttribute("href", "/app/dashboard"); 45 45 expect(screen.getByText(/third-party apps/i)).toBeInTheDocument(); 46 46 }); 47 47 }); ··· 50 50 beforeEach(() => { 51 51 setupAuthenticatedUser(); 52 52 }); 53 - it("shows loading text while fetching passwords", async () => { 54 - mockEndpoint("com.atproto.server.listAppPasswords", async () => { 55 - await new Promise((resolve) => setTimeout(resolve, 100)); 56 - return jsonResponse({ passwords: [] }); 57 - }); 53 + it("shows loading text while fetching passwords", () => { 54 + mockEndpoint( 55 + "com.atproto.server.listAppPasswords", 56 + () => 57 + new Promise((resolve) => 58 + setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100) 59 + ), 60 + ); 58 61 render(AppPasswords); 59 62 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 60 63 });
+13 -7
frontend/src/tests/Comms.test.ts
··· 21 21 setupUnauthenticatedUser(); 22 22 render(Comms); 23 23 await waitFor(() => { 24 - expect(globalThis.location.hash).toBe("#/login"); 24 + expect(globalThis.location.pathname).toBe("/app/login"); 25 25 }); 26 26 }); 27 27 }); ··· 51 51 }), 52 52 ).toBeInTheDocument(); 53 53 expect(screen.getByRole("link", { name: /dashboard/i })) 54 - .toHaveAttribute("href", "#/dashboard"); 54 + .toHaveAttribute("href", "/app/dashboard"); 55 55 expect(screen.getByRole("heading", { name: /preferred channel/i })) 56 56 .toBeInTheDocument(); 57 57 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 71 71 () => jsonResponse({ notifications: [] }), 72 72 ); 73 73 }); 74 - it("shows loading text while fetching preferences", async () => { 75 - mockEndpoint("_account.getNotificationPrefs", async () => { 76 - await new Promise((resolve) => setTimeout(resolve, 100)); 77 - return jsonResponse(mockData.notificationPrefs()); 78 - }); 74 + it("shows loading text while fetching preferences", () => { 75 + mockEndpoint( 76 + "_account.getNotificationPrefs", 77 + () => 78 + new Promise((resolve) => 79 + setTimeout( 80 + () => resolve(jsonResponse(mockData.notificationPrefs())), 81 + 100, 82 + ) 83 + ), 84 + ); 79 85 render(Comms); 80 86 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 81 87 });
+13 -13
frontend/src/tests/Dashboard.test.ts
··· 21 21 setupUnauthenticatedUser(); 22 22 render(Dashboard); 23 23 await waitFor(() => { 24 - expect(globalThis.location.hash).toBe("#/login"); 24 + expect(globalThis.location.pathname).toBe("/app/login"); 25 25 }); 26 26 }); 27 27 it("shows loading state while checking auth", () => { ··· 61 61 render(Dashboard); 62 62 await waitFor(() => { 63 63 const navCards = [ 64 - { name: /app passwords/i, href: "#/app-passwords" }, 65 - { name: /account settings/i, href: "#/settings" }, 66 - { name: /communication preferences/i, href: "#/comms" }, 67 - { name: /repository explorer/i, href: "#/repo" }, 64 + { name: /app passwords/i, href: "/app/app-passwords" }, 65 + { name: /account settings/i, href: "/app/settings" }, 66 + { name: /communication preferences/i, href: "/app/comms" }, 67 + { name: /repository explorer/i, href: "/app/repo" }, 68 68 ]; 69 69 for (const { name, href } of navCards) { 70 70 const card = screen.getByRole("link", { name }); ··· 84 84 await waitFor(() => { 85 85 const inviteCard = screen.getByRole("link", { name: /invite codes/i }); 86 86 expect(inviteCard).toBeInTheDocument(); 87 - expect(inviteCard).toHaveAttribute("href", "#/invite-codes"); 87 + expect(inviteCard).toHaveAttribute("href", "/app/invite-codes"); 88 88 }); 89 89 }); 90 90 }); ··· 92 92 beforeEach(() => { 93 93 setupAuthenticatedUser(); 94 94 localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())); 95 - mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 95 + mockEndpoint("/oauth/revoke", () => jsonResponse({})); 96 96 }); 97 - it("calls deleteSession and navigates to login on logout", async () => { 98 - let deleteSessionCalled = false; 99 - mockEndpoint("com.atproto.server.deleteSession", () => { 100 - deleteSessionCalled = true; 97 + it("calls oauth revoke and navigates to login on logout", async () => { 98 + let revokeCalled = false; 99 + mockEndpoint("/oauth/revoke", () => { 100 + revokeCalled = true; 101 101 return jsonResponse({}); 102 102 }); 103 103 render(Dashboard); ··· 112 112 }); 113 113 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 114 114 await waitFor(() => { 115 - expect(deleteSessionCalled).toBe(true); 116 - expect(globalThis.location.hash).toBe("#/login"); 115 + expect(revokeCalled).toBe(true); 116 + expect(globalThis.location.pathname).toBe("/app/login"); 117 117 }); 118 118 }); 119 119 it("clears session from localStorage after logout", async () => {
+6 -7
frontend/src/tests/Login.test.ts
··· 14 14 beforeEach(() => { 15 15 clearMocks(); 16 16 setupFetchMock(); 17 - globalThis.location.hash = ""; 18 17 mockEndpoint( 19 18 "/oauth/par", 20 19 () => jsonResponse({ request_uri: "urn:mock:request" }), ··· 47 46 expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 48 47 expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute( 49 48 "href", 50 - "#/register", 49 + "/app/register", 51 50 ); 52 51 }); 53 52 }); ··· 56 55 render(Login); 57 56 await waitFor(() => { 58 57 expect(screen.getByRole("link", { name: /forgot password/i })) 59 - .toHaveAttribute("href", "#/reset-password"); 58 + .toHaveAttribute("href", "/app/reset-password"); 60 59 expect(screen.getByRole("link", { name: /lost passkey/i })) 61 - .toHaveAttribute("href", "#/request-passkey-recovery"); 60 + .toHaveAttribute("href", "/app/request-passkey-recovery"); 62 61 }); 63 62 }); 64 63 }); ··· 122 121 await fireEvent.click(aliceAccount); 123 122 } 124 123 await waitFor(() => { 125 - expect(globalThis.location.hash).toBe("#/dashboard"); 124 + expect(globalThis.location.pathname).toBe("/app/dashboard"); 126 125 }); 127 126 }); 128 127 ··· 163 162 }); 164 163 }); 165 164 166 - it("shows verification form when pending verification exists", async () => { 165 + it("shows verification form when pending verification exists", () => { 167 166 render(Login); 168 167 }); 169 168 }); 170 169 171 170 describe("loading state", () => { 172 - it("shows loading state while auth is initializing", async () => { 171 + it("shows loading state while auth is initializing", () => { 173 172 _testSetState({ 174 173 session: null, 175 174 loading: true,
+3 -3
frontend/src/tests/Settings.test.ts
··· 22 22 setupUnauthenticatedUser(); 23 23 render(Settings); 24 24 await waitFor(() => { 25 - expect(globalThis.location.hash).toBe("#/login"); 25 + expect(globalThis.location.pathname).toBe("/app/login"); 26 26 }); 27 27 }); 28 28 }); ··· 37 37 screen.getByRole("heading", { name: /account settings/i, level: 1 }), 38 38 ).toBeInTheDocument(); 39 39 expect(screen.getByRole("link", { name: /dashboard/i })) 40 - .toHaveAttribute("href", "#/dashboard"); 40 + .toHaveAttribute("href", "/app/dashboard"); 41 41 expect(screen.getByRole("heading", { name: /change email/i })) 42 42 .toBeInTheDocument(); 43 43 expect(screen.getByRole("heading", { name: /change handle/i })) ··· 463 463 screen.getByRole("button", { name: /permanently delete account/i }), 464 464 ); 465 465 await waitFor(() => { 466 - expect(globalThis.location.hash).toBe("#/login"); 466 + expect(globalThis.location.pathname).toBe("/app/login"); 467 467 }); 468 468 }); 469 469 it("shows cancel button to return to request state", async () => {
+1 -1
frontend/src/tests/migration/atproto-client.test.ts
··· 263 263 describe("getMigrationOAuthRedirectUri", () => { 264 264 it("returns migrate path based on origin", () => { 265 265 const redirectUri = getMigrationOAuthRedirectUri(); 266 - expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`); 266 + expect(redirectUri).toBe(`${globalThis.location.origin}/app/migrate`); 267 267 }); 268 268 }); 269 269
+55 -14
frontend/src/tests/mocks.ts
··· 1 1 import { vi } from "vitest"; 2 2 import type { AppPassword, InviteCode, Session } from "../lib/api"; 3 3 import { _testSetState } from "../lib/auth.svelte"; 4 + 5 + const originalPushState = globalThis.history.pushState.bind(globalThis.history); 6 + const originalReplaceState = globalThis.history.replaceState.bind( 7 + globalThis.history, 8 + ); 9 + 10 + globalThis.history.pushState = ( 11 + data: unknown, 12 + unused: string, 13 + url?: string | URL | null, 14 + ) => { 15 + originalPushState(data, unused, url); 16 + if (url) { 17 + const urlStr = typeof url === "string" ? url : url.toString(); 18 + Object.defineProperty(globalThis.location, "pathname", { 19 + value: urlStr.split("?")[0], 20 + writable: true, 21 + configurable: true, 22 + }); 23 + } 24 + }; 25 + 26 + globalThis.history.replaceState = ( 27 + data: unknown, 28 + unused: string, 29 + url?: string | URL | null, 30 + ) => { 31 + originalReplaceState(data, unused, url); 32 + if (url) { 33 + const urlStr = typeof url === "string" ? url : url.toString(); 34 + Object.defineProperty(globalThis.location, "pathname", { 35 + value: urlStr.split("?")[0], 36 + writable: true, 37 + configurable: true, 38 + }); 39 + } 40 + }; 41 + 4 42 export interface MockResponse { 5 43 ok: boolean; 6 44 status: number; ··· 49 87 clone: () => ({ ...result }) as Response, 50 88 body: null, 51 89 bodyUsed: false, 52 - arrayBuffer: async () => new ArrayBuffer(0), 53 - blob: async () => new Blob(), 54 - formData: async () => new FormData(), 90 + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), 91 + blob: () => Promise.resolve(new Blob()), 92 + formData: () => Promise.resolve(new FormData()), 55 93 } as Response; 56 94 } 57 95 return { 58 96 ok: false, 59 97 status: 404, 60 - json: async () => ({ 61 - error: "NotFound", 62 - message: `No mock for ${endpoint}`, 63 - }), 64 - text: async () => 65 - JSON.stringify({ 98 + json: () => 99 + Promise.resolve({ 66 100 error: "NotFound", 67 101 message: `No mock for ${endpoint}`, 68 102 }), 103 + text: () => 104 + Promise.resolve( 105 + JSON.stringify({ 106 + error: "NotFound", 107 + message: `No mock for ${endpoint}`, 108 + }), 109 + ), 69 110 headers: new Headers(), 70 111 redirected: false, 71 112 statusText: "Not Found", ··· 76 117 }, 77 118 body: null, 78 119 bodyUsed: false, 79 - arrayBuffer: async () => new ArrayBuffer(0), 80 - blob: async () => new Blob(), 81 - formData: async () => new FormData(), 120 + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), 121 + blob: () => Promise.resolve(new Blob()), 122 + formData: () => Promise.resolve(new FormData()), 82 123 } as Response; 83 124 }, 84 125 ); ··· 87 128 return { 88 129 ok: status >= 200 && status < 300, 89 130 status, 90 - json: async () => data, 131 + json: () => Promise.resolve(data), 91 132 }; 92 133 } 93 134 export function errorResponse( ··· 98 139 return { 99 140 ok: false, 100 141 status, 101 - json: async () => ({ error, message }), 142 + json: () => Promise.resolve({ error, message }), 102 143 }; 103 144 } 104 145 export const mockData = {
+25 -2
src/api/proxy.rs
··· 130 130 Err(e) => { 131 131 warn!("Token validation failed: {:?}", e); 132 132 if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 133 - return ( 134 - StatusCode::BAD_REQUEST, 133 + let auth_header_str = headers 134 + .get("Authorization") 135 + .and_then(|h| h.to_str().ok()) 136 + .unwrap_or(""); 137 + let is_dpop = auth_header_str 138 + .trim() 139 + .get(..5) 140 + .is_some_and(|s| s.eq_ignore_ascii_case("dpop ")); 141 + let scheme = if is_dpop { "DPoP" } else { "Bearer" }; 142 + let www_auth = format!( 143 + "{} error=\"invalid_token\", error_description=\"Token has expired\"", 144 + scheme 145 + ); 146 + let mut response = ( 147 + StatusCode::UNAUTHORIZED, 135 148 Json(json!({ 136 149 "error": "ExpiredToken", 137 150 "message": "Token has expired" 138 151 })), 139 152 ) 140 153 .into_response(); 154 + response 155 + .headers_mut() 156 + .insert("WWW-Authenticate", www_auth.parse().unwrap()); 157 + if is_dpop { 158 + let nonce = crate::oauth::verify::generate_dpop_nonce(); 159 + response 160 + .headers_mut() 161 + .insert("DPoP-Nonce", nonce.parse().unwrap()); 162 + } 163 + return response; 141 164 } 142 165 } 143 166 }
+12 -5
src/api/repo/record/delete.rs
··· 42 42 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 43 43 Json(input): Json<DeleteRecordInput>, 44 44 ) -> Response { 45 - let auth = 46 - match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { 47 - Ok(res) => res, 48 - Err(err_res) => return err_res, 49 - }; 45 + let auth = match prepare_repo_write( 46 + &state, 47 + &headers, 48 + &input.repo, 49 + "POST", 50 + &crate::util::build_full_url(&uri.to_string()), 51 + ) 52 + .await 53 + { 54 + Ok(res) => res, 55 + Err(err_res) => return err_res, 56 + }; 50 57 51 58 if let Err(e) = crate::auth::scope_check::check_repo_scope( 52 59 auth.is_oauth,
+43 -12
src/api/repo/record/write.rs
··· 89 89 ) 90 90 .await 91 91 .map_err(|e| { 92 - ( 92 + tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 93 + let mut response = ( 93 94 StatusCode::UNAUTHORIZED, 94 95 Json(json!({"error": e.to_string()})), 95 96 ) 96 - .into_response() 97 + .into_response(); 98 + if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 99 + let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" }; 100 + let www_auth = format!( 101 + "{} error=\"invalid_token\", error_description=\"Token has expired\"", 102 + scheme 103 + ); 104 + response.headers_mut().insert( 105 + "WWW-Authenticate", 106 + www_auth.parse().unwrap(), 107 + ); 108 + if extracted.is_dpop { 109 + let nonce = crate::oauth::verify::generate_dpop_nonce(); 110 + response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap()); 111 + } 112 + } 113 + response 97 114 })?; 98 115 if repo_did != auth_user.did { 99 116 return Err(( ··· 219 236 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 220 237 Json(input): Json<CreateRecordInput>, 221 238 ) -> Response { 222 - let auth = 223 - match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { 224 - Ok(res) => res, 225 - Err(err_res) => return err_res, 226 - }; 239 + let auth = match prepare_repo_write( 240 + &state, 241 + &headers, 242 + &input.repo, 243 + "POST", 244 + &crate::util::build_full_url(&uri.to_string()), 245 + ) 246 + .await 247 + { 248 + Ok(res) => res, 249 + Err(err_res) => return err_res, 250 + }; 227 251 228 252 if let Err(e) = crate::auth::scope_check::check_repo_scope( 229 253 auth.is_oauth, ··· 459 483 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 460 484 Json(input): Json<PutRecordInput>, 461 485 ) -> Response { 462 - let auth = 463 - match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await { 464 - Ok(res) => res, 465 - Err(err_res) => return err_res, 466 - }; 486 + let auth = match prepare_repo_write( 487 + &state, 488 + &headers, 489 + &input.repo, 490 + "POST", 491 + &crate::util::build_full_url(&uri.to_string()), 492 + ) 493 + .await 494 + { 495 + Ok(res) => res, 496 + Err(err_res) => return err_res, 497 + }; 467 498 468 499 if let Err(e) = crate::auth::scope_check::check_repo_scope( 469 500 auth.is_oauth,
+1 -1
src/api/server/passkey_account.rs
··· 1257 1257 1258 1258 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1259 1259 let recovery_url = format!( 1260 - "https://{}/#/recover-passkey?did={}&token={}", 1260 + "https://{}/app/recover-passkey?did={}&token={}", 1261 1261 hostname, 1262 1262 urlencoding::encode(&user.did), 1263 1263 urlencoding::encode(&recovery_token)
+1
src/auth/mod.rs
··· 396 396 controller_did: None, 397 397 }) 398 398 } 399 + Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired), 399 400 Err(_) => Err(TokenValidationError::AuthenticationFailed), 400 401 } 401 402 }
+8 -8
src/comms/service.rs
··· 352 352 let strings = get_strings(&prefs.locale); 353 353 let encoded_email = urlencoding::encode(new_email); 354 354 let encoded_token = urlencoding::encode(code); 355 - let verify_page = format!("https://{}/#/verify", hostname); 355 + let verify_page = format!("https://{}/app/verify", hostname); 356 356 let verify_link = format!( 357 - "https://{}/#/verify?token={}&identifier={}", 357 + "https://{}/app/verify?token={}&identifier={}", 358 358 hostname, encoded_token, encoded_email 359 359 ); 360 360 let body = format_message( ··· 389 389 let prefs = get_user_comms_prefs(db, user_id).await?; 390 390 let strings = get_strings(&prefs.locale); 391 391 let current_email = prefs.email.clone().unwrap_or_default(); 392 - let verify_page = format!("https://{}/#/verify?type=email-update", hostname); 392 + let verify_page = format!("https://{}/app/verify?type=email-update", hostname); 393 393 let verify_link = format!( 394 - "https://{}/#/verify?type=email-update&token={}", 394 + "https://{}/app/verify?type=email-update&token={}", 395 395 hostname, 396 396 urlencoding::encode(code) 397 397 ); ··· 556 556 let encoded_email = urlencoding::encode(recipient); 557 557 let encoded_token = urlencoding::encode(code); 558 558 ( 559 - format!("https://{}/#/verify", hostname), 559 + format!("https://{}/app/verify", hostname), 560 560 format!( 561 - "https://{}/#/verify?token={}&identifier={}", 561 + "https://{}/app/verify?token={}&identifier={}", 562 562 hostname, encoded_token, encoded_email 563 563 ), 564 564 ) ··· 606 606 let strings = get_strings(&prefs.locale); 607 607 let encoded_email = urlencoding::encode(email); 608 608 let encoded_token = urlencoding::encode(token); 609 - let verify_page = format!("https://{}/#/verify", hostname); 609 + let verify_page = format!("https://{}/app/verify", hostname); 610 610 let verify_link = format!( 611 - "https://{}/#/verify?token={}&identifier={}", 611 + "https://{}/app/verify?token={}&identifier={}", 612 612 hostname, encoded_token, encoded_email 613 613 ); 614 614 let body = format_message(
+17 -2
src/lib.rs
··· 657 657 .exists() 658 658 { 659 659 let index_path = format!("{}/index.html", frontend_dir); 660 - let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(index_path)); 661 - router.fallback_service(serve_dir) 660 + let homepage_path = format!("{}/homepage.html", frontend_dir); 661 + 662 + let homepage_exists = std::path::Path::new(&homepage_path).exists(); 663 + let homepage_file = if homepage_exists { 664 + homepage_path 665 + } else { 666 + index_path.clone() 667 + }; 668 + 669 + let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); 670 + 671 + let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); 672 + 673 + router 674 + .route_service("/", ServeFile::new(&homepage_file)) 675 + .nest("/app", spa_router) 676 + .fallback_service(serve_dir) 662 677 } else { 663 678 router 664 679 }
+16 -15
src/oauth/endpoints/authorize.rs
··· 25 25 26 26 fn redirect_to_frontend_error(error: &str, description: &str) -> Response { 27 27 redirect_see_other(&format!( 28 - "/#/oauth/error?error={}&error_description={}", 28 + "/app/oauth/error?error={}&error_description={}", 29 29 url_encode(error), 30 30 url_encode(description) 31 31 )) ··· 236 236 if is_delegated && !has_password { 237 237 tracing::info!("Redirecting to delegation auth"); 238 238 return redirect_see_other(&format!( 239 - "/#/oauth/delegation?request_uri={}&delegated_did={}", 239 + "/app/oauth/delegation?request_uri={}&delegated_did={}", 240 240 url_encode(&request_uri), 241 241 url_encode(&user.did) 242 242 )); ··· 259 259 && !accounts.is_empty() 260 260 { 261 261 return redirect_see_other(&format!( 262 - "/#/oauth/accounts?request_uri={}", 262 + "/app/oauth/accounts?request_uri={}", 263 263 url_encode(&request_uri) 264 264 )); 265 265 } 266 266 redirect_see_other(&format!( 267 - "/#/oauth/login?request_uri={}", 267 + "/app/oauth/login?request_uri={}", 268 268 url_encode(&request_uri) 269 269 )) 270 270 } ··· 466 466 .into_response(); 467 467 } 468 468 redirect_see_other(&format!( 469 - "/#/oauth/login?request_uri={}&error={}", 469 + "/app/oauth/login?request_uri={}&error={}", 470 470 url_encode(&form.request_uri), 471 471 url_encode(error_msg) 472 472 )) ··· 539 539 return show_login_error("An error occurred. Please try again.", json_response); 540 540 } 541 541 let redirect_url = format!( 542 - "/#/oauth/delegation?request_uri={}&delegated_did={}", 542 + "/app/oauth/delegation?request_uri={}&delegated_did={}", 543 543 url_encode(&form.request_uri), 544 544 url_encode(&user.did) 545 545 ); ··· 565 565 return show_login_error("An error occurred. Please try again.", json_response); 566 566 } 567 567 let redirect_url = format!( 568 - "/#/oauth/passkey?request_uri={}", 568 + "/app/oauth/passkey?request_uri={}", 569 569 url_encode(&form.request_uri) 570 570 ); 571 571 if json_response { ··· 620 620 .into_response(); 621 621 } 622 622 return redirect_see_other(&format!( 623 - "/#/oauth/totp?request_uri={}", 623 + "/app/oauth/totp?request_uri={}", 624 624 url_encode(&form.request_uri) 625 625 )); 626 626 } ··· 649 649 .into_response(); 650 650 } 651 651 return redirect_see_other(&format!( 652 - "/#/oauth/2fa?request_uri={}&channel={}", 652 + "/app/oauth/2fa?request_uri={}&channel={}", 653 653 url_encode(&form.request_uri), 654 654 url_encode(channel_name) 655 655 )); ··· 713 713 .unwrap_or(true); 714 714 if needs_consent { 715 715 let consent_url = format!( 716 - "/#/oauth/consent?request_uri={}", 716 + "/app/oauth/consent?request_uri={}", 717 717 url_encode(&form.request_uri) 718 718 ); 719 719 if json_response { ··· 1103 1103 }; 1104 1104 let channel = query.channel.as_deref().unwrap_or("email"); 1105 1105 redirect_see_other(&format!( 1106 - "/#/oauth/2fa?request_uri={}&channel={}", 1106 + "/app/oauth/2fa?request_uri={}&channel={}", 1107 1107 url_encode(&query.request_uri), 1108 1108 url_encode(channel) 1109 1109 )) ··· 1464 1464 || s.starts_with("blob:") 1465 1465 || s.starts_with("rpc:") 1466 1466 || s.starts_with("account:") 1467 + || s.starts_with("identity:") 1467 1468 || s.starts_with("include:") 1468 1469 }); 1469 1470 if !has_valid_scope { ··· 1708 1709 .unwrap_or(true); 1709 1710 if needs_consent { 1710 1711 let consent_url = format!( 1711 - "/#/oauth/consent?request_uri={}", 1712 + "/app/oauth/consent?request_uri={}", 1712 1713 url_encode(&form.request_uri) 1713 1714 ); 1714 1715 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2345 2346 2346 2347 if needs_consent { 2347 2348 let consent_url = format!( 2348 - "/#/oauth/consent?request_uri={}", 2349 + "/app/oauth/consent?request_uri={}", 2349 2350 url_encode(&form.request_uri) 2350 2351 ); 2351 2352 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2729 2730 } 2730 2731 let channel_name = channel_display_name(user.preferred_comms_channel); 2731 2732 let redirect_url = format!( 2732 - "/#/oauth/2fa?request_uri={}&channel={}", 2733 + "/app/oauth/2fa?request_uri={}&channel={}", 2733 2734 url_encode(&form.request_uri), 2734 2735 url_encode(channel_name) 2735 2736 ); ··· 2754 2755 } 2755 2756 2756 2757 let redirect_url = format!( 2757 - "/#/oauth/consent?request_uri={}", 2758 + "/app/oauth/consent?request_uri={}", 2758 2759 url_encode(&form.request_uri) 2759 2760 ); 2760 2761 (
+3 -3
src/oauth/endpoints/delegation.rs
··· 206 206 success: true, 207 207 needs_totp: Some(true), 208 208 redirect_uri: Some(format!( 209 - "/#/oauth/delegation-totp?request_uri={}", 209 + "/app/oauth/delegation-totp?request_uri={}", 210 210 urlencoding::encode(&form.request_uri) 211 211 )), 212 212 error: None, ··· 239 239 success: true, 240 240 needs_totp: None, 241 241 redirect_uri: Some(format!( 242 - "/#/oauth/consent?request_uri={}", 242 + "/app/oauth/consent?request_uri={}", 243 243 urlencoding::encode(&form.request_uri) 244 244 )), 245 245 error: None, ··· 374 374 success: true, 375 375 needs_totp: None, 376 376 redirect_uri: Some(format!( 377 - "/#/oauth/consent?request_uri={}", 377 + "/app/oauth/consent?request_uri={}", 378 378 urlencoding::encode(&form.request_uri) 379 379 )), 380 380 error: None,
+2 -2
src/oauth/endpoints/metadata.rs
··· 168 168 client_name: "PDS Account Manager".to_string(), 169 169 client_uri: base_url.clone(), 170 170 redirect_uris: vec![ 171 - format!("{}/", base_url), 172 - format!("{}/migrate", base_url), 171 + format!("{}/app/", base_url), 172 + format!("{}/app/migrate", base_url), 173 173 ], 174 174 grant_types: vec![ 175 175 "authorization_code".to_string(),
+7 -4
src/oauth/endpoints/token/grants.rs
··· 94 94 )); 95 95 } 96 96 Some(result.jkt) 97 - } else if auth_request.parameters.dpop_jkt.is_some() { 98 - return Err(OAuthError::InvalidRequest( 99 - "DPoP proof required for this authorization".to_string(), 97 + } else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() { 98 + return Err(OAuthError::UseDpopNonce( 99 + crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes()) 100 + .generate_nonce(), 100 101 )); 101 102 } else { 102 103 None ··· 138 139 } else { 139 140 REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 140 141 }; 142 + let mut stored_parameters = auth_request.parameters.clone(); 143 + stored_parameters.dpop_jkt = dpop_jkt.clone(); 141 144 let token_data = TokenData { 142 145 did: did.clone(), 143 146 token_id: token_id.0.clone(), ··· 147 150 client_id: auth_request.client_id.clone(), 148 151 client_auth: stored_client_auth, 149 152 device_id: auth_request.device_id, 150 - parameters: auth_request.parameters.clone(), 153 + parameters: stored_parameters, 151 154 details: None, 152 155 code: None, 153 156 current_refresh_token: Some(refresh_token.0.clone()),
+73 -7
src/oauth/verify.rs
··· 42 42 http_uri: &str, 43 43 ) -> Result<VerifyResult, OAuthError> { 44 44 let token_info = extract_oauth_token_info(access_token)?; 45 + tracing::debug!( 46 + token_id = %token_info.token_id, 47 + has_dpop_proof = dpop_proof.is_some(), 48 + "Verifying OAuth access token" 49 + ); 45 50 let token_data = db::get_token_by_id(pool, &token_info.token_id) 46 51 .await? 47 - .ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?; 52 + .ok_or_else(|| { 53 + tracing::warn!(token_id = %token_info.token_id, "Token not found in database"); 54 + OAuthError::InvalidToken("Token not found or revoked".to_string()) 55 + })?; 48 56 let now = chrono::Utc::now(); 49 57 if token_data.expires_at < now { 50 - return Err(OAuthError::InvalidToken("Token has expired".to_string())); 58 + return Err(OAuthError::ExpiredToken( 59 + "Token session has expired".to_string(), 60 + )); 51 61 } 52 62 if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { 53 - let proof = dpop_proof 54 - .ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?; 63 + tracing::debug!(expected_jkt = %expected_jkt, "Token requires DPoP"); 64 + let proof = dpop_proof.ok_or_else(|| { 65 + tracing::warn!("DPoP proof required but not provided"); 66 + OAuthError::UseDpopNonce("DPoP proof required".to_string()) 67 + })?; 55 68 let config = AuthConfig::get(); 56 69 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 57 70 let access_token_hash = compute_ath(access_token); 58 - let result = 59 - verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; 71 + let result = verifier 72 + .verify_proof(proof, http_method, http_uri, Some(&access_token_hash)) 73 + .map_err(|e| { 74 + tracing::warn!(error = ?e, http_method = %http_method, http_uri = %http_uri, "DPoP proof verification failed"); 75 + e 76 + })?; 60 77 if !db::check_and_record_dpop_jti(pool, &result.jti).await? { 61 78 return Err(OAuthError::InvalidDpopProof( 62 79 "DPoP proof has already been used".to_string(), ··· 123 140 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 124 141 let now = chrono::Utc::now().timestamp(); 125 142 if exp < now { 126 - return Err(OAuthError::InvalidToken("Token has expired".to_string())); 143 + return Err(OAuthError::ExpiredToken("Token has expired".to_string())); 127 144 } 128 145 let token_id = payload 129 146 .get("jti") ··· 191 208 pub error: String, 192 209 pub message: String, 193 210 pub dpop_nonce: Option<String>, 211 + pub www_authenticate: Option<String>, 194 212 } 195 213 196 214 impl IntoResponse for OAuthAuthError { ··· 208 226 .headers_mut() 209 227 .insert("DPoP-Nonce", nonce.parse().unwrap()); 210 228 } 229 + if let Some(www_auth) = self.www_authenticate { 230 + response 231 + .headers_mut() 232 + .insert("WWW-Authenticate", www_auth.parse().unwrap()); 233 + } 211 234 response 212 235 } 213 236 } ··· 228 251 error: "AuthenticationRequired".to_string(), 229 252 message: "Authorization header required".to_string(), 230 253 dpop_nonce: None, 254 + www_authenticate: None, 231 255 })?; 232 256 let auth_header_trimmed = auth_header.trim(); 233 257 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 ··· 244 268 error: "InvalidRequest".to_string(), 245 269 message: "Invalid authorization scheme".to_string(), 246 270 dpop_nonce: None, 271 + www_authenticate: None, 247 272 }); 248 273 }; 249 274 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); ··· 275 300 error: "use_dpop_nonce".to_string(), 276 301 message: "DPoP nonce required".to_string(), 277 302 dpop_nonce: Some(nonce), 303 + www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()), 278 304 }), 279 305 Err(OAuthError::InvalidDpopProof(msg)) => { 280 306 let nonce = generate_dpop_nonce(); ··· 283 309 error: "invalid_dpop_proof".to_string(), 284 310 message: msg, 285 311 dpop_nonce: Some(nonce), 312 + www_authenticate: None, 313 + }) 314 + } 315 + Err(OAuthError::ExpiredToken(msg)) => { 316 + let nonce = if is_dpop_token { 317 + Some(generate_dpop_nonce()) 318 + } else { 319 + None 320 + }; 321 + let scheme = if is_dpop_token { "DPoP" } else { "Bearer" }; 322 + let www_auth = format!( 323 + "{} error=\"invalid_token\", error_description=\"{}\"", 324 + scheme, msg 325 + ); 326 + Err(OAuthAuthError { 327 + status: StatusCode::UNAUTHORIZED, 328 + error: "ExpiredToken".to_string(), 329 + message: msg, 330 + dpop_nonce: nonce, 331 + www_authenticate: Some(www_auth), 332 + }) 333 + } 334 + Err(OAuthError::InvalidToken(msg)) => { 335 + let nonce = if is_dpop_token { 336 + Some(generate_dpop_nonce()) 337 + } else { 338 + None 339 + }; 340 + let scheme = if is_dpop_token { "DPoP" } else { "Bearer" }; 341 + let www_auth = format!( 342 + "{} error=\"invalid_token\", error_description=\"{}\"", 343 + scheme, msg 344 + ); 345 + Err(OAuthAuthError { 346 + status: StatusCode::UNAUTHORIZED, 347 + error: "InvalidToken".to_string(), 348 + message: msg, 349 + dpop_nonce: nonce, 350 + www_authenticate: Some(www_auth), 286 351 }) 287 352 } 288 353 Err(e) => { ··· 296 361 error: "AuthenticationFailed".to_string(), 297 362 message: format!("{:?}", e), 298 363 dpop_nonce: nonce, 364 + www_authenticate: None, 299 365 }) 300 366 } 301 367 }