PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

Frontend on /app path for easy custom homepage

+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 }