this repo has no description

Frontend on /app path for easy custom homepage

lewis 0fecd800 25af771a

+6
.env.example
··· 22 AWS_ACCESS_KEY_ID=minioadmin 23 AWS_SECRET_ACCESS_KEY=minioadmin 24 # ============================================================================= 25 # Valkey (for caching and distributed rate limiting) 26 # ============================================================================= 27 # If not set, falls back to in-memory caching (single-node only)
··· 22 AWS_ACCESS_KEY_ID=minioadmin 23 AWS_SECRET_ACCESS_KEY=minioadmin 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 + # ============================================================================= 31 # Valkey (for caching and distributed rate limiting) 32 # ============================================================================= 33 # If not set, falls back to in-memory caching (single-node only)
+25 -1
docs/install-alpine.md
··· 68 rc-update add minio 69 rc-service minio start 70 ``` 71 - Create the blob bucket (wait a few seconds for minio to start): 72 ```sh 73 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 74 chmod +x mc 75 mv mc /usr/local/bin/ 76 mc alias set local http://localhost:9000 minioadmin your-minio-password 77 mc mb local/pds-blobs 78 ``` 79 ## 5. Install valkey 80 ```sh ··· 239 ```sh 240 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 241 ```
··· 68 rc-update add minio 69 rc-service minio start 70 ``` 71 + Create the buckets (wait a few seconds for minio to start): 72 ```sh 73 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 74 chmod +x mc 75 mv mc /usr/local/bin/ 76 mc alias set local http://localhost:9000 minioadmin your-minio-password 77 mc mb local/pds-blobs 78 + mc mb local/pds-backups 79 ``` 80 ## 5. Install valkey 81 ```sh ··· 240 ```sh 241 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 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 sleep 10 83 ``` 84 85 - Create the minio bucket: 86 ```bash 87 podman run --rm --pod tranquil-pds \ 88 -e MINIO_ROOT_USER=minioadmin \ 89 -e MINIO_ROOT_PASSWORD=your-minio-password \ 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" 92 ``` 93 94 Run migrations: ··· 230 sleep 15 231 ``` 232 233 - Create the minio bucket: 234 ```sh 235 source /srv/tranquil-pds/config/tranquil-pds.env 236 podman run --rm --network tranquil-pds_default \ 237 -e MINIO_ROOT_USER="$MINIO_ROOT_USER" \ 238 -e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \ 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' 241 ``` 242 243 Run migrations: ··· 350 ```sh 351 podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql 352 ```
··· 82 sleep 10 83 ``` 84 85 + Create the minio buckets: 86 ```bash 87 podman run --rm --pod tranquil-pds \ 88 -e MINIO_ROOT_USER=minioadmin \ 89 -e MINIO_ROOT_PASSWORD=your-minio-password \ 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 && mc mb --ignore-existing local/pds-backups" 92 ``` 93 94 Run migrations: ··· 230 sleep 15 231 ``` 232 233 + Create the minio buckets: 234 ```sh 235 source /srv/tranquil-pds/config/tranquil-pds.env 236 podman run --rm --network tranquil-pds_default \ 237 -e MINIO_ROOT_USER="$MINIO_ROOT_USER" \ 238 -e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \ 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 && mc mb --ignore-existing local/pds-backups' 241 ``` 242 243 Run migrations: ··· 350 ```sh 351 podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql 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 systemctl enable minio 60 systemctl start minio 61 ``` 62 - Create the blob bucket (wait a few seconds for minio to start): 63 ```bash 64 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 65 chmod +x mc 66 mv mc /usr/local/bin/ 67 mc alias set local http://localhost:9000 minioadmin your-minio-password 68 mc mb local/pds-blobs 69 ``` 70 ## 5. Install valkey 71 ```bash ··· 212 ```bash 213 sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql 214 ```
··· 59 systemctl enable minio 60 systemctl start minio 61 ``` 62 + Create the buckets (wait a few seconds for minio to start): 63 ```bash 64 curl -O https://dl.min.io/client/mc/release/linux-amd64/mc 65 chmod +x mc 66 mv mc /usr/local/bin/ 67 mc alias set local http://localhost:9000 minioadmin your-minio-password 68 mc mb local/pds-blobs 69 + mc mb local/pds-backups 70 ``` 71 ## 5. Install valkey 72 ```bash ··· 213 ```bash 214 sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql 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 The container image expects: 13 - `DATABASE_URL` - postgres connection string 14 - `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET` 15 - `VALKEY_URL` - redis:// connection string 16 - `PDS_HOSTNAME` - your PDS hostname (without protocol) 17 - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` ··· 20 21 Health check: `GET /xrpc/_health` 22
··· 12 The container image expects: 13 - `DATABASE_URL` - postgres connection string 14 - `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET` 15 + - `BACKUP_S3_BUCKET` - bucket for repo backups (optional but recommended) 16 - `VALKEY_URL` - redis:// connection string 17 - `PDS_HOSTNAME` - your PDS hostname (without protocol) 18 - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` ··· 21 22 Health check: `GET /xrpc/_health` 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 rcctl enable minio 73 rcctl start minio 74 ``` 75 - Create the blob bucket: 76 ```sh 77 ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc 78 chmod +x /usr/local/bin/mc 79 mc alias set local http://localhost:9000 minioadmin your-minio-password 80 mc mb local/pds-blobs 81 ``` 82 ## 5. Install redis 83 OpenBSD has redis in ports (valkey not available yet): ··· 251 ```sh 252 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 253 ```
··· 72 rcctl enable minio 73 rcctl start minio 74 ``` 75 + Create the buckets: 76 ```sh 77 ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc 78 chmod +x /usr/local/bin/mc 79 mc alias set local http://localhost:9000 minioadmin your-minio-password 80 mc mb local/pds-blobs 81 + mc mb local/pds-backups 82 ``` 83 ## 5. Install redis 84 OpenBSD has redis in ports (valkey not available yet): ··· 252 ```sh 253 pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql 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 import { getCurrentPath, navigate } from './lib/router.svelte' 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 import { initServerConfig } from './lib/serverConfig.svelte' 5 - import { initI18n, _ } from './lib/i18n' 6 import { isLoading as i18nLoading } from 'svelte-i18n' 7 import Login from './routes/Login.svelte' 8 import Register from './routes/Register.svelte' ··· 34 import ActAs from './routes/ActAs.svelte' 35 import Migration from './routes/Migration.svelte' 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 initI18n() 45 46 const auth = getAuthState() ··· 48 let oauthCallbackPending = $state(hasOAuthCallback()) 49 50 function hasOAuthCallback(): boolean { 51 - if (window.location.hash === '#/migrate') { 52 return false 53 } 54 const params = new URLSearchParams(window.location.search) ··· 59 initServerConfig() 60 initAuth().then(({ oauthLoginCompleted }) => { 61 if (oauthLoginCompleted) { 62 - navigate('/dashboard') 63 } 64 oauthCallbackPending = false 65 }) 66 }) 67 68 function getComponent(path: string) { 69 switch (path) { 70 case '/login': ··· 128 case '/did-document': 129 return DidDocumentEditor 130 default: 131 - return Home 132 } 133 } 134
··· 2 import { getCurrentPath, navigate } from './lib/router.svelte' 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 import { initServerConfig } from './lib/serverConfig.svelte' 5 + import { initI18n } from './lib/i18n' 6 import { isLoading as i18nLoading } from 'svelte-i18n' 7 import Login from './routes/Login.svelte' 8 import Register from './routes/Register.svelte' ··· 34 import ActAs from './routes/ActAs.svelte' 35 import Migration from './routes/Migration.svelte' 36 import DidDocumentEditor from './routes/DidDocumentEditor.svelte' 37 initI18n() 38 39 const auth = getAuthState() ··· 41 let oauthCallbackPending = $state(hasOAuthCallback()) 42 43 function hasOAuthCallback(): boolean { 44 + if (window.location.pathname === '/app/migrate') { 45 return false 46 } 47 const params = new URLSearchParams(window.location.search) ··· 52 initServerConfig() 53 initAuth().then(({ oauthLoginCompleted }) => { 54 if (oauthLoginCompleted) { 55 + navigate('/dashboard', true) 56 } 57 oauthCallbackPending = false 58 }) 59 }) 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 + 73 function getComponent(path: string) { 74 switch (path) { 75 case '/login': ··· 133 case '/did-document': 134 return DidDocumentEditor 135 default: 136 + return Login 137 } 138 } 139
+65 -65
frontend/src/lib/api.ts
··· 237 return data; 238 }, 239 240 - async confirmSignup( 241 did: string, 242 verificationCode: string, 243 ): Promise<ConfirmSignupResult> { ··· 247 }); 248 }, 249 250 - async resendVerification(did: string): Promise<{ success: boolean }> { 251 return xrpc("com.atproto.server.resendVerification", { 252 method: "POST", 253 body: { did }, 254 }); 255 }, 256 257 - async createSession(identifier: string, password: string): Promise<Session> { 258 return xrpc("com.atproto.server.createSession", { 259 method: "POST", 260 body: { identifier, password }, 261 }); 262 }, 263 264 - async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 return xrpc("_checkEmailVerified", { 266 method: "POST", 267 body: { identifier }, 268 }); 269 }, 270 271 - async getSession(token: string): Promise<Session> { 272 return xrpc("com.atproto.server.getSession", { token }); 273 }, 274 275 - async refreshSession(refreshJwt: string): Promise<Session> { 276 return xrpc("com.atproto.server.refreshSession", { 277 method: "POST", 278 token: refreshJwt, ··· 286 }); 287 }, 288 289 - async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 290 return xrpc("com.atproto.server.listAppPasswords", { token }); 291 }, 292 293 - async createAppPassword( 294 token: string, 295 name: string, 296 scopes?: string, ··· 312 }); 313 }, 314 315 - async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 316 return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 317 }, 318 319 - async createInviteCode( 320 token: string, 321 useCount: number = 1, 322 ): Promise<{ code: string }> { ··· 341 }); 342 }, 343 344 - async requestEmailUpdate( 345 token: string, 346 ): Promise<{ tokenRequired: boolean }> { 347 return xrpc("com.atproto.server.requestEmailUpdate", { ··· 388 }); 389 }, 390 391 - async describeServer(): Promise<{ 392 availableUserDomains: string[]; 393 inviteCodeRequired: boolean; 394 links?: { privacyPolicy?: string; termsOfService?: string }; ··· 399 return xrpc("com.atproto.server.describeServer"); 400 }, 401 402 - async listRepos(limit?: number): Promise<{ 403 repos: Array<{ did: string; head: string; rev: string }>; 404 cursor?: string; 405 }> { ··· 408 return xrpc("com.atproto.sync.listRepos", { params }); 409 }, 410 411 - async getNotificationPrefs(token: string): Promise<{ 412 preferredChannel: string; 413 email: string; 414 discordId: string | null; ··· 421 return xrpc("_account.getNotificationPrefs", { token }); 422 }, 423 424 - async updateNotificationPrefs(token: string, prefs: { 425 preferredChannel?: string; 426 discordId?: string; 427 telegramUsername?: string; ··· 434 }); 435 }, 436 437 - async confirmChannelVerification( 438 token: string, 439 channel: string, 440 identifier: string, ··· 447 }); 448 }, 449 450 - async getNotificationHistory(token: string): Promise<{ 451 notifications: Array<{ 452 createdAt: string; 453 channel: string; ··· 460 return xrpc("_account.getNotificationHistory", { token }); 461 }, 462 463 - async getServerStats(token: string): Promise<{ 464 userCount: number; 465 repoCount: number; 466 recordCount: number; ··· 469 return xrpc("_admin.getServerStats", { token }); 470 }, 471 472 - async getServerConfig(): Promise<{ 473 serverName: string; 474 primaryColor: string | null; 475 primaryColorDark: string | null; ··· 480 return xrpc("_server.getConfig"); 481 }, 482 483 - async updateServerConfig( 484 token: string, 485 config: { 486 serverName?: string; ··· 541 }); 542 }, 543 544 - async removePassword(token: string): Promise<{ success: boolean }> { 545 return xrpc("_account.removePassword", { 546 method: "POST", 547 token, 548 }); 549 }, 550 551 - async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 return xrpc("_account.getPasswordStatus", { token }); 553 }, 554 555 - async getLegacyLoginPreference( 556 token: string, 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 return xrpc("_account.getLegacyLoginPreference", { token }); 559 }, 560 561 - async updateLegacyLoginPreference( 562 token: string, 563 allowLegacyLogin: boolean, 564 ): Promise<{ allowLegacyLogin: boolean }> { ··· 569 }); 570 }, 571 572 - async updateLocale( 573 token: string, 574 preferredLocale: string, 575 ): Promise<{ preferredLocale: string }> { ··· 580 }); 581 }, 582 583 - async listSessions(token: string): Promise<{ 584 sessions: Array<{ 585 id: string; 586 sessionType: string; ··· 601 }); 602 }, 603 604 - async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 return xrpc("_account.revokeAllSessions", { 606 method: "POST", 607 token, 608 }); 609 }, 610 611 - async searchAccounts(token: string, options?: { 612 handle?: string; 613 cursor?: string; 614 limit?: number; ··· 630 return xrpc("com.atproto.admin.searchAccounts", { token, params }); 631 }, 632 633 - async getInviteCodes(token: string, options?: { 634 sort?: "recent" | "usage"; 635 cursor?: string; 636 limit?: number; ··· 665 }); 666 }, 667 668 - async getAccountInfo(token: string, did: string): Promise<{ 669 did: string; 670 handle: string; 671 email?: string; ··· 701 }); 702 }, 703 704 - async describeRepo(token: string, repo: string): Promise<{ 705 handle: string; 706 did: string; 707 didDoc: unknown; ··· 714 }); 715 }, 716 717 - async listRecords(token: string, repo: string, collection: string, options?: { 718 limit?: number; 719 cursor?: string; 720 reverse?: boolean; ··· 729 return xrpc("com.atproto.repo.listRecords", { token, params }); 730 }, 731 732 - async getRecord( 733 token: string, 734 repo: string, 735 collection: string, ··· 745 }); 746 }, 747 748 - async createRecord( 749 token: string, 750 repo: string, 751 collection: string, ··· 762 }); 763 }, 764 765 - async putRecord( 766 token: string, 767 repo: string, 768 collection: string, ··· 792 }); 793 }, 794 795 - async getTotpStatus( 796 token: string, 797 ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 798 return xrpc("com.atproto.server.getTotpStatus", { token }); 799 }, 800 801 - async createTotpSecret( 802 token: string, 803 ): Promise<{ uri: string; qrBase64: string }> { 804 return xrpc("com.atproto.server.createTotpSecret", { ··· 807 }); 808 }, 809 810 - async enableTotp( 811 token: string, 812 code: string, 813 ): Promise<{ success: boolean; backupCodes: string[] }> { ··· 818 }); 819 }, 820 821 - async disableTotp( 822 token: string, 823 password: string, 824 code: string, ··· 830 }); 831 }, 832 833 - async regenerateBackupCodes( 834 token: string, 835 password: string, 836 code: string, ··· 842 }); 843 }, 844 845 - async startPasskeyRegistration( 846 token: string, 847 friendlyName?: string, 848 ): Promise<{ options: unknown }> { ··· 853 }); 854 }, 855 856 - async finishPasskeyRegistration( 857 token: string, 858 credential: unknown, 859 friendlyName?: string, ··· 865 }); 866 }, 867 868 - async listPasskeys(token: string): Promise<{ 869 passkeys: Array<{ 870 id: string; 871 credentialId: string; ··· 897 }); 898 }, 899 900 - async listTrustedDevices(token: string): Promise<{ 901 devices: Array<{ 902 id: string; 903 userAgent: string | null; ··· 910 return xrpc("_account.listTrustedDevices", { token }); 911 }, 912 913 - async revokeTrustedDevice( 914 token: string, 915 deviceId: string, 916 ): Promise<{ success: boolean }> { ··· 921 }); 922 }, 923 924 - async updateTrustedDevice( 925 token: string, 926 deviceId: string, 927 friendlyName: string, ··· 933 }); 934 }, 935 936 - async getReauthStatus(token: string): Promise<{ 937 requiresReauth: boolean; 938 lastReauthAt: string | null; 939 availableMethods: string[]; ··· 941 return xrpc("_account.getReauthStatus", { token }); 942 }, 943 944 - async reauthPassword( 945 token: string, 946 password: string, 947 ): Promise<{ success: boolean; reauthAt: string }> { ··· 952 }); 953 }, 954 955 - async reauthTotp( 956 token: string, 957 code: string, 958 ): Promise<{ success: boolean; reauthAt: string }> { ··· 963 }); 964 }, 965 966 - async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 return xrpc("_account.reauthPasskeyStart", { 968 method: "POST", 969 token, 970 }); 971 }, 972 973 - async reauthPasskeyFinish( 974 token: string, 975 credential: unknown, 976 ): Promise<{ success: boolean; reauthAt: string }> { ··· 981 }); 982 }, 983 984 - async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 985 return xrpc("com.atproto.server.reserveSigningKey", { 986 method: "POST", 987 body: { did }, 988 }); 989 }, 990 991 - async getRecommendedDidCredentials(token: string): Promise<{ 992 rotationKeys?: string[]; 993 alsoKnownAs?: string[]; 994 verificationMethods?: { atproto?: string }; ··· 1043 return res.json(); 1044 }, 1045 1046 - async startPasskeyRegistrationForSetup( 1047 did: string, 1048 setupToken: string, 1049 friendlyName?: string, ··· 1054 }); 1055 }, 1056 1057 - async completePasskeySetup( 1058 did: string, 1059 setupToken: string, 1060 passkeyCredential: unknown, ··· 1071 }); 1072 }, 1073 1074 - async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 return xrpc("_account.requestPasskeyRecovery", { 1076 method: "POST", 1077 body: { email }, 1078 }); 1079 }, 1080 1081 - async recoverPasskeyAccount( 1082 did: string, 1083 recoveryToken: string, 1084 newPassword: string, ··· 1089 }); 1090 }, 1091 1092 - async verifyMigrationEmail( 1093 token: string, 1094 email: string, 1095 ): Promise<{ success: boolean; did: string }> { ··· 1099 }); 1100 }, 1101 1102 - async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 1103 return xrpc("com.atproto.server.resendMigrationVerification", { 1104 method: "POST", 1105 body: { email }, 1106 }); 1107 }, 1108 1109 - async verifyToken( 1110 token: string, 1111 identifier: string, 1112 accessToken?: string, ··· 1123 }); 1124 }, 1125 1126 - async getDidDocument(token: string): Promise<DidDocument> { 1127 return xrpc("_account.getDidDocument", { token }); 1128 }, 1129 1130 - async updateDidDocument( 1131 token: string, 1132 params: { 1133 verificationMethods?: VerificationMethod[]; ··· 1170 return res.arrayBuffer(); 1171 }, 1172 1173 - async listBackups(token: string): Promise<{ 1174 backups: Array<{ 1175 id: string; 1176 repoRev: string; ··· 1199 return res.blob(); 1200 }, 1201 1202 - async createBackup(token: string): Promise<{ 1203 id: string; 1204 repoRev: string; 1205 sizeBytes: number; ··· 1219 }); 1220 }, 1221 1222 - async setBackupEnabled( 1223 token: string, 1224 enabled: boolean, 1225 ): Promise<{ enabled: boolean }> {
··· 237 return data; 238 }, 239 240 + confirmSignup( 241 did: string, 242 verificationCode: string, 243 ): Promise<ConfirmSignupResult> { ··· 247 }); 248 }, 249 250 + resendVerification(did: string): Promise<{ success: boolean }> { 251 return xrpc("com.atproto.server.resendVerification", { 252 method: "POST", 253 body: { did }, 254 }); 255 }, 256 257 + createSession(identifier: string, password: string): Promise<Session> { 258 return xrpc("com.atproto.server.createSession", { 259 method: "POST", 260 body: { identifier, password }, 261 }); 262 }, 263 264 + checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 return xrpc("_checkEmailVerified", { 266 method: "POST", 267 body: { identifier }, 268 }); 269 }, 270 271 + getSession(token: string): Promise<Session> { 272 return xrpc("com.atproto.server.getSession", { token }); 273 }, 274 275 + refreshSession(refreshJwt: string): Promise<Session> { 276 return xrpc("com.atproto.server.refreshSession", { 277 method: "POST", 278 token: refreshJwt, ··· 286 }); 287 }, 288 289 + listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 290 return xrpc("com.atproto.server.listAppPasswords", { token }); 291 }, 292 293 + createAppPassword( 294 token: string, 295 name: string, 296 scopes?: string, ··· 312 }); 313 }, 314 315 + getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 316 return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 317 }, 318 319 + createInviteCode( 320 token: string, 321 useCount: number = 1, 322 ): Promise<{ code: string }> { ··· 341 }); 342 }, 343 344 + requestEmailUpdate( 345 token: string, 346 ): Promise<{ tokenRequired: boolean }> { 347 return xrpc("com.atproto.server.requestEmailUpdate", { ··· 388 }); 389 }, 390 391 + describeServer(): Promise<{ 392 availableUserDomains: string[]; 393 inviteCodeRequired: boolean; 394 links?: { privacyPolicy?: string; termsOfService?: string }; ··· 399 return xrpc("com.atproto.server.describeServer"); 400 }, 401 402 + listRepos(limit?: number): Promise<{ 403 repos: Array<{ did: string; head: string; rev: string }>; 404 cursor?: string; 405 }> { ··· 408 return xrpc("com.atproto.sync.listRepos", { params }); 409 }, 410 411 + getNotificationPrefs(token: string): Promise<{ 412 preferredChannel: string; 413 email: string; 414 discordId: string | null; ··· 421 return xrpc("_account.getNotificationPrefs", { token }); 422 }, 423 424 + updateNotificationPrefs(token: string, prefs: { 425 preferredChannel?: string; 426 discordId?: string; 427 telegramUsername?: string; ··· 434 }); 435 }, 436 437 + confirmChannelVerification( 438 token: string, 439 channel: string, 440 identifier: string, ··· 447 }); 448 }, 449 450 + getNotificationHistory(token: string): Promise<{ 451 notifications: Array<{ 452 createdAt: string; 453 channel: string; ··· 460 return xrpc("_account.getNotificationHistory", { token }); 461 }, 462 463 + getServerStats(token: string): Promise<{ 464 userCount: number; 465 repoCount: number; 466 recordCount: number; ··· 469 return xrpc("_admin.getServerStats", { token }); 470 }, 471 472 + getServerConfig(): Promise<{ 473 serverName: string; 474 primaryColor: string | null; 475 primaryColorDark: string | null; ··· 480 return xrpc("_server.getConfig"); 481 }, 482 483 + updateServerConfig( 484 token: string, 485 config: { 486 serverName?: string; ··· 541 }); 542 }, 543 544 + removePassword(token: string): Promise<{ success: boolean }> { 545 return xrpc("_account.removePassword", { 546 method: "POST", 547 token, 548 }); 549 }, 550 551 + getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 return xrpc("_account.getPasswordStatus", { token }); 553 }, 554 555 + getLegacyLoginPreference( 556 token: string, 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 return xrpc("_account.getLegacyLoginPreference", { token }); 559 }, 560 561 + updateLegacyLoginPreference( 562 token: string, 563 allowLegacyLogin: boolean, 564 ): Promise<{ allowLegacyLogin: boolean }> { ··· 569 }); 570 }, 571 572 + updateLocale( 573 token: string, 574 preferredLocale: string, 575 ): Promise<{ preferredLocale: string }> { ··· 580 }); 581 }, 582 583 + listSessions(token: string): Promise<{ 584 sessions: Array<{ 585 id: string; 586 sessionType: string; ··· 601 }); 602 }, 603 604 + revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 return xrpc("_account.revokeAllSessions", { 606 method: "POST", 607 token, 608 }); 609 }, 610 611 + searchAccounts(token: string, options?: { 612 handle?: string; 613 cursor?: string; 614 limit?: number; ··· 630 return xrpc("com.atproto.admin.searchAccounts", { token, params }); 631 }, 632 633 + getInviteCodes(token: string, options?: { 634 sort?: "recent" | "usage"; 635 cursor?: string; 636 limit?: number; ··· 665 }); 666 }, 667 668 + getAccountInfo(token: string, did: string): Promise<{ 669 did: string; 670 handle: string; 671 email?: string; ··· 701 }); 702 }, 703 704 + describeRepo(token: string, repo: string): Promise<{ 705 handle: string; 706 did: string; 707 didDoc: unknown; ··· 714 }); 715 }, 716 717 + listRecords(token: string, repo: string, collection: string, options?: { 718 limit?: number; 719 cursor?: string; 720 reverse?: boolean; ··· 729 return xrpc("com.atproto.repo.listRecords", { token, params }); 730 }, 731 732 + getRecord( 733 token: string, 734 repo: string, 735 collection: string, ··· 745 }); 746 }, 747 748 + createRecord( 749 token: string, 750 repo: string, 751 collection: string, ··· 762 }); 763 }, 764 765 + putRecord( 766 token: string, 767 repo: string, 768 collection: string, ··· 792 }); 793 }, 794 795 + getTotpStatus( 796 token: string, 797 ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 798 return xrpc("com.atproto.server.getTotpStatus", { token }); 799 }, 800 801 + createTotpSecret( 802 token: string, 803 ): Promise<{ uri: string; qrBase64: string }> { 804 return xrpc("com.atproto.server.createTotpSecret", { ··· 807 }); 808 }, 809 810 + enableTotp( 811 token: string, 812 code: string, 813 ): Promise<{ success: boolean; backupCodes: string[] }> { ··· 818 }); 819 }, 820 821 + disableTotp( 822 token: string, 823 password: string, 824 code: string, ··· 830 }); 831 }, 832 833 + regenerateBackupCodes( 834 token: string, 835 password: string, 836 code: string, ··· 842 }); 843 }, 844 845 + startPasskeyRegistration( 846 token: string, 847 friendlyName?: string, 848 ): Promise<{ options: unknown }> { ··· 853 }); 854 }, 855 856 + finishPasskeyRegistration( 857 token: string, 858 credential: unknown, 859 friendlyName?: string, ··· 865 }); 866 }, 867 868 + listPasskeys(token: string): Promise<{ 869 passkeys: Array<{ 870 id: string; 871 credentialId: string; ··· 897 }); 898 }, 899 900 + listTrustedDevices(token: string): Promise<{ 901 devices: Array<{ 902 id: string; 903 userAgent: string | null; ··· 910 return xrpc("_account.listTrustedDevices", { token }); 911 }, 912 913 + revokeTrustedDevice( 914 token: string, 915 deviceId: string, 916 ): Promise<{ success: boolean }> { ··· 921 }); 922 }, 923 924 + updateTrustedDevice( 925 token: string, 926 deviceId: string, 927 friendlyName: string, ··· 933 }); 934 }, 935 936 + getReauthStatus(token: string): Promise<{ 937 requiresReauth: boolean; 938 lastReauthAt: string | null; 939 availableMethods: string[]; ··· 941 return xrpc("_account.getReauthStatus", { token }); 942 }, 943 944 + reauthPassword( 945 token: string, 946 password: string, 947 ): Promise<{ success: boolean; reauthAt: string }> { ··· 952 }); 953 }, 954 955 + reauthTotp( 956 token: string, 957 code: string, 958 ): Promise<{ success: boolean; reauthAt: string }> { ··· 963 }); 964 }, 965 966 + reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 return xrpc("_account.reauthPasskeyStart", { 968 method: "POST", 969 token, 970 }); 971 }, 972 973 + reauthPasskeyFinish( 974 token: string, 975 credential: unknown, 976 ): Promise<{ success: boolean; reauthAt: string }> { ··· 981 }); 982 }, 983 984 + reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 985 return xrpc("com.atproto.server.reserveSigningKey", { 986 method: "POST", 987 body: { did }, 988 }); 989 }, 990 991 + getRecommendedDidCredentials(token: string): Promise<{ 992 rotationKeys?: string[]; 993 alsoKnownAs?: string[]; 994 verificationMethods?: { atproto?: string }; ··· 1043 return res.json(); 1044 }, 1045 1046 + startPasskeyRegistrationForSetup( 1047 did: string, 1048 setupToken: string, 1049 friendlyName?: string, ··· 1054 }); 1055 }, 1056 1057 + completePasskeySetup( 1058 did: string, 1059 setupToken: string, 1060 passkeyCredential: unknown, ··· 1071 }); 1072 }, 1073 1074 + requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 return xrpc("_account.requestPasskeyRecovery", { 1076 method: "POST", 1077 body: { email }, 1078 }); 1079 }, 1080 1081 + recoverPasskeyAccount( 1082 did: string, 1083 recoveryToken: string, 1084 newPassword: string, ··· 1089 }); 1090 }, 1091 1092 + verifyMigrationEmail( 1093 token: string, 1094 email: string, 1095 ): Promise<{ success: boolean; did: string }> { ··· 1099 }); 1100 }, 1101 1102 + resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 1103 return xrpc("com.atproto.server.resendMigrationVerification", { 1104 method: "POST", 1105 body: { email }, 1106 }); 1107 }, 1108 1109 + verifyToken( 1110 token: string, 1111 identifier: string, 1112 accessToken?: string, ··· 1123 }); 1124 }, 1125 1126 + getDidDocument(token: string): Promise<DidDocument> { 1127 return xrpc("_account.getDidDocument", { token }); 1128 }, 1129 1130 + updateDidDocument( 1131 token: string, 1132 params: { 1133 verificationMethods?: VerificationMethod[]; ··· 1170 return res.arrayBuffer(); 1171 }, 1172 1173 + listBackups(token: string): Promise<{ 1174 backups: Array<{ 1175 id: string; 1176 repoRev: string; ··· 1199 return res.blob(); 1200 }, 1201 1202 + createBackup(token: string): Promise<{ 1203 id: string; 1204 repoRev: string; 1205 sizeBytes: number; ··· 1219 }); 1220 }, 1221 1222 + setBackupEnabled( 1223 token: string, 1224 enabled: boolean, 1225 ): Promise<{ enabled: boolean }> {
+9 -2
frontend/src/lib/auth.svelte.ts
··· 40 savedAccounts: SavedAccount[]; 41 } 42 43 - let state = $state<AuthState>({ 44 session: null, 45 loading: true, 46 error: null, ··· 318 319 export async function logout(): Promise<void> { 320 if (state.session) { 321 try { 322 - await api.deleteSession(state.session.accessJwt); 323 } catch { 324 // Ignore errors on logout 325 } 326 } 327 state.session = null; 328 saveSession(null);
··· 40 savedAccounts: SavedAccount[]; 41 } 42 43 + const state = $state<AuthState>({ 44 session: null, 45 loading: true, 46 error: null, ··· 318 319 export async function logout(): Promise<void> { 320 if (state.session) { 321 + const did = state.session.did; 322 + const refreshToken = state.session.refreshJwt; 323 try { 324 + await fetch("/oauth/revoke", { 325 + method: "POST", 326 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 327 + body: new URLSearchParams({ token: refreshToken }), 328 + }); 329 } catch { 330 // Ignore errors on logout 331 } 332 + removeSavedAccount(did); 333 } 334 state.session = null; 335 saveSession(null);
+59 -4
frontend/src/lib/migration/atproto-client.ts
··· 188 return session; 189 } 190 191 - async describeServer(): Promise<ServerDescription> { 192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 193 } 194 195 - async getServiceAuth( 196 aud: string, 197 lxm?: string, 198 ): Promise<{ token: string }> { ··· 203 return this.xrpc("com.atproto.server.getServiceAuth", { params }); 204 } 205 206 - async getRepo(did: string): Promise<Uint8Array> { 207 return this.xrpc("com.atproto.sync.getRepo", { 208 params: { did }, 209 }); ··· 662 return url.toString(); 663 } 664 665 export async function exchangeOAuthCode( 666 metadata: OAuthServerMetadata, 667 params: { ··· 839 } 840 841 export function getMigrationOAuthRedirectUri(): string { 842 - return `${globalThis.location.origin}/migrate`; 843 } 844 845 export interface DPoPKeyPair {
··· 188 return session; 189 } 190 191 + describeServer(): Promise<ServerDescription> { 192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 193 } 194 195 + getServiceAuth( 196 aud: string, 197 lxm?: string, 198 ): Promise<{ token: string }> { ··· 203 return this.xrpc("com.atproto.server.getServiceAuth", { params }); 204 } 205 206 + getRepo(did: string): Promise<Uint8Array> { 207 return this.xrpc("com.atproto.sync.getRepo", { 208 params: { did }, 209 }); ··· 662 return url.toString(); 663 } 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 + 720 export async function exchangeOAuthCode( 721 metadata: OAuthServerMetadata, 722 params: { ··· 894 } 895 896 export function getMigrationOAuthRedirectUri(): string { 897 + return `${globalThis.location.origin}/app/migrate`; 898 } 899 900 export interface DPoPKeyPair {
+4 -3
frontend/src/lib/migration/blob-migration.ts
··· 107 errorMessage, 108 ); 109 110 - const isNetworkError = 111 - errorMessage.includes("fetch") || 112 errorMessage.includes("network") || 113 errorMessage.includes("CORS") || 114 errorMessage.includes("Failed to fetch") || ··· 124 if (migrated > 0) { 125 onProgress({ 126 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.`, 128 }); 129 } else { 130 onProgress({
··· 107 errorMessage, 108 ); 109 110 + const isNetworkError = errorMessage.includes("fetch") || 111 errorMessage.includes("network") || 112 errorMessage.includes("CORS") || 113 errorMessage.includes("Failed to fetch") || ··· 123 if (migrated > 0) { 124 onProgress({ 125 currentOperation: 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.`, 129 }); 130 } else { 131 onProgress({
+35 -13
frontend/src/lib/migration/flow.svelte.ts
··· 8 } from "./types"; 9 import { 10 AtprotoClient, 11 - buildOAuthAuthorizationUrl, 12 clearDPoPKey, 13 createLocalClient, 14 exchangeOAuthCode, ··· 18 getMigrationOAuthClientId, 19 getMigrationOAuthRedirectUri, 20 getOAuthServerMetadata, 21 loadDPoPKey, 22 resolvePdsUrl, 23 saveDPoPKey, ··· 84 let sourceClient: AtprotoClient | null = null; 85 let localClient: AtprotoClient | null = null; 86 let localServerInfo: ServerDescription | null = null; 87 - let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 - null; 89 90 function setStep(step: InboundStep) { 91 state.step = step; ··· 141 "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", 142 ); 143 } 144 - sourceOAuthMetadata = metadata; 145 146 const { codeVerifier, codeChallenge } = await generatePKCE(); 147 const oauthState = generateOAuthState(); ··· 155 localStorage.setItem("migration_source_did", state.sourceDid); 156 localStorage.setItem("migration_source_handle", state.sourceHandle); 157 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 158 159 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 160 clientId: getMigrationOAuthClientId(), 161 redirectUri: getMigrationOAuthRedirectUri(), 162 codeChallenge, ··· 185 localStorage.removeItem("migration_source_did"); 186 localStorage.removeItem("migration_source_handle"); 187 localStorage.removeItem("migration_oauth_issuer"); 188 } 189 190 async function handleOAuthCallback( ··· 199 const sourceDid = localStorage.getItem("migration_source_did"); 200 const sourceHandle = localStorage.getItem("migration_source_handle"); 201 const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); 202 203 if (returnedState !== savedState) { 204 cleanupOAuthSessionData(); ··· 229 cleanupOAuthSessionData(); 230 throw new Error("Could not fetch OAuth server metadata"); 231 } 232 - sourceOAuthMetadata = metadata; 233 234 migrationLog("handleOAuthCallback: Exchanging code for tokens"); 235 ··· 269 ]; 270 271 if (postEmailSteps.includes(targetStep)) { 272 if (state.authMethod === "passkey" && state.passkeySetupToken) { 273 - localClient = createLocalClient(); 274 setStep("passkey-setup"); 275 migrationLog( 276 "handleOAuthCallback: Resuming passkey flow at passkey-setup", ··· 281 "handleOAuthCallback: Resuming at email-verify for re-auth", 282 ); 283 } 284 } else { 285 setStep(targetStep); 286 } ··· 550 551 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 552 if (checkingEmailVerification) return false; 553 - if (!sourceClient || !localClient) return false; 554 - 555 - if (state.authMethod === "passkey") { 556 - return false; 557 - } 558 559 checkingEmailVerification = true; 560 try { 561 const verified = await localClient.checkEmailVerified(state.targetEmail); 562 if (!verified) return false; 563 564 await localClient.loginDeactivated( 565 state.targetEmail, 566 state.targetPassword, 567 ); 568 if (state.sourceDid.startsWith("did:web:")) { 569 const credentials = await localClient.getRecommendedDidCredentials(); 570 state.targetVerificationMethod = ··· 856 }; 857 sourceClient = null; 858 passkeySetup = null; 859 - sourceOAuthMetadata = null; 860 clearMigrationState(); 861 clearDPoPKey(); 862 }
··· 8 } from "./types"; 9 import { 10 AtprotoClient, 11 clearDPoPKey, 12 createLocalClient, 13 exchangeOAuthCode, ··· 17 getMigrationOAuthClientId, 18 getMigrationOAuthRedirectUri, 19 getOAuthServerMetadata, 20 + initiateOAuthWithPAR, 21 loadDPoPKey, 22 resolvePdsUrl, 23 saveDPoPKey, ··· 84 let sourceClient: AtprotoClient | null = null; 85 let localClient: AtprotoClient | null = null; 86 let localServerInfo: ServerDescription | null = null; 87 88 function setStep(step: InboundStep) { 89 state.step = step; ··· 139 "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", 140 ); 141 } 142 143 const { codeVerifier, codeChallenge } = await generatePKCE(); 144 const oauthState = generateOAuthState(); ··· 152 localStorage.setItem("migration_source_did", state.sourceDid); 153 localStorage.setItem("migration_source_handle", state.sourceHandle); 154 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 155 + if (state.resumeToStep) { 156 + localStorage.setItem("migration_resume_to_step", state.resumeToStep); 157 + } 158 159 + const authUrl = await initiateOAuthWithPAR(metadata, { 160 clientId: getMigrationOAuthClientId(), 161 redirectUri: getMigrationOAuthRedirectUri(), 162 codeChallenge, ··· 185 localStorage.removeItem("migration_source_did"); 186 localStorage.removeItem("migration_source_handle"); 187 localStorage.removeItem("migration_oauth_issuer"); 188 + localStorage.removeItem("migration_resume_to_step"); 189 } 190 191 async function handleOAuthCallback( ··· 200 const sourceDid = localStorage.getItem("migration_source_did"); 201 const sourceHandle = localStorage.getItem("migration_source_handle"); 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 + } 209 210 if (returnedState !== savedState) { 211 cleanupOAuthSessionData(); ··· 236 cleanupOAuthSessionData(); 237 throw new Error("Could not fetch OAuth server metadata"); 238 } 239 240 migrationLog("handleOAuthCallback: Exchanging code for tokens"); 241 ··· 275 ]; 276 277 if (postEmailSteps.includes(targetStep)) { 278 + localClient = createLocalClient(); 279 if (state.authMethod === "passkey" && state.passkeySetupToken) { 280 setStep("passkey-setup"); 281 migrationLog( 282 "handleOAuthCallback: Resuming passkey flow at passkey-setup", ··· 287 "handleOAuthCallback: Resuming at email-verify for re-auth", 288 ); 289 } 290 + } else if (targetStep === "email-verify") { 291 + localClient = createLocalClient(); 292 + setStep("email-verify"); 293 + migrationLog("handleOAuthCallback: Resuming at email-verify"); 294 } else { 295 setStep(targetStep); 296 } ··· 560 561 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 562 if (checkingEmailVerification) return false; 563 + if (!localClient) return false; 564 565 checkingEmailVerification = true; 566 try { 567 const verified = await localClient.checkEmailVerified(state.targetEmail); 568 if (!verified) return false; 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 + 578 await localClient.loginDeactivated( 579 state.targetEmail, 580 state.targetPassword, 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 + 591 if (state.sourceDid.startsWith("did:web:")) { 592 const credentials = await localClient.getRecommendedDidCredentials(); 593 state.targetVerificationMethod = ··· 879 }; 880 sourceClient = null; 881 passkeySetup = null; 882 clearMigrationState(); 883 clearDPoPKey(); 884 }
+2
frontend/src/lib/migration/types.ts
··· 254 issuer: string; 255 authorization_endpoint: string; 256 token_endpoint: string; 257 scopes_supported?: string[]; 258 response_types_supported?: string[]; 259 grant_types_supported?: string[]; 260 code_challenge_methods_supported?: string[]; 261 dpop_signing_alg_values_supported?: string[]; 262 } 263 264 export interface OAuthTokenResponse {
··· 254 issuer: string; 255 authorization_endpoint: string; 256 token_endpoint: string; 257 + pushed_authorization_request_endpoint?: string; 258 scopes_supported?: string[]; 259 response_types_supported?: string[]; 260 grant_types_supported?: string[]; 261 code_challenge_methods_supported?: string[]; 262 dpop_signing_alg_values_supported?: string[]; 263 + require_pushed_authorization_requests?: boolean; 264 } 265 266 export interface OAuthTokenResponse {
+3 -3
frontend/src/lib/oauth.ts
··· 10 const CLIENT_ID = !(import.meta.env.DEV) 11 ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 : `http://localhost/?scope=${SCOPES}`; 13 - const REDIRECT_URI = `${globalThis.location.origin}/`; 14 15 interface OAuthState { 16 state: string; ··· 26 ); 27 } 28 29 - async function sha256(plain: string): Promise<ArrayBuffer> { 30 const encoder = new TextEncoder(); 31 const data = encoder.encode(plain); 32 return crypto.subtle.digest("SHA-256", data); ··· 191 export function checkForOAuthCallback(): 192 | { code: string; state: string } 193 | null { 194 - if (globalThis.location.hash === "#/migrate") { 195 return null; 196 } 197
··· 10 const CLIENT_ID = !(import.meta.env.DEV) 11 ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 : `http://localhost/?scope=${SCOPES}`; 13 + const REDIRECT_URI = `${globalThis.location.origin}/app/`; 14 15 interface OAuthState { 16 state: string; ··· 26 ); 27 } 28 29 + function sha256(plain: string): Promise<ArrayBuffer> { 30 const encoder = new TextEncoder(); 31 const data = encoder.encode(plain); 32 return crypto.subtle.digest("SHA-256", data); ··· 191 export function checkForOAuthCallback(): 192 | { code: string; state: string } 193 | null { 194 + if (globalThis.location.pathname === "/app/migrate") { 195 return null; 196 } 197
+3 -3
frontend/src/lib/registration/flow.svelte.ts
··· 29 mode: RegistrationMode, 30 pdsHostname: string, 31 ) { 32 - let state = $state<RegistrationFlowState>({ 33 mode, 34 step: "info", 35 info: { ··· 80 } 81 } 82 83 - async function proceedFromInfo() { 84 state.error = null; 85 if (state.info.didType === "web-external") { 86 state.step = "key-choice"; ··· 130 } 131 } 132 133 - async function confirmInitialDidDoc() { 134 state.step = "creating"; 135 } 136
··· 29 mode: RegistrationMode, 30 pdsHostname: string, 31 ) { 32 + const state = $state<RegistrationFlowState>({ 33 mode, 34 step: "info", 35 info: { ··· 80 } 81 } 82 83 + function proceedFromInfo() { 84 state.error = null; 85 if (state.info.didType === "web-external") { 86 state.step = "key-choice"; ··· 130 } 131 } 132 133 + function confirmInitialDidDoc() { 134 state.step = "creating"; 135 } 136
+24 -11
frontend/src/lib/router.svelte.ts
··· 1 - let currentPath = $state( 2 - getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), 3 - ); 4 5 - function getPathWithoutQuery(hash: string): string { 6 - const queryIndex = hash.indexOf("?"); 7 - return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 8 } 9 10 - globalThis.addEventListener("hashchange", () => { 11 - currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); 12 }); 13 14 - export function navigate(path: string) { 15 - currentPath = path; 16 - globalThis.location.hash = path; 17 } 18 19 export function getCurrentPath() { 20 return currentPath; 21 }
··· 1 + const APP_BASE = "/app"; 2 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 "/"; 10 } 11 12 + let currentPath = $state(getAppPath()); 13 + 14 + globalThis.addEventListener("popstate", () => { 15 + currentPath = getAppPath(); 16 }); 17 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; 26 } 27 28 export function getCurrentPath() { 29 return currentPath; 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 loading: boolean; 11 } 12 13 - let state = $state<ServerConfigState>({ 14 serverName: null, 15 primaryColor: null, 16 primaryColorDark: null,
··· 10 loading: boolean; 11 } 12 13 + const state = $state<ServerConfigState>({ 14 serverName: null, 15 primaryColor: null, 16 primaryColorDark: null,
+2 -2
frontend/src/routes/ActAs.svelte
··· 10 let actAsInProgress = $state(false) 11 12 function getDid(): string | null { 13 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 14 return params.get('did') 15 } 16 ··· 89 90 const parData = await parResponse.json() 91 if (parData.request_uri) { 92 - window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` 93 } else { 94 error = $_('actAs.invalidResponse') 95 loading = false
··· 10 let actAsInProgress = $state(false) 11 12 function getDid(): string | null { 13 + const params = new URLSearchParams(window.location.search) 14 return params.get('did') 15 } 16 ··· 89 90 const parData = await parResponse.json() 91 if (parData.request_uri) { 92 + window.location.href = `/app/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` 93 } else { 94 error = $_('actAs.invalidResponse') 95 loading = false
+1 -1
frontend/src/routes/Admin.svelte
··· 308 {#if auth.session?.isAdmin} 309 <div class="page"> 310 <header> 311 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 312 <h1>{$_('admin.title')}</h1> 313 </header> 314 {#if loading}
··· 308 {#if auth.session?.isAdmin} 309 <div class="page"> 310 <header> 311 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 312 <h1>{$_('admin.title')}</h1> 313 </header> 314 {#if loading}
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 99 </script> 100 <div class="page"> 101 <header> 102 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 103 <h1>{$_('appPasswords.title')}</h1> 104 </header> 105 <p class="description">
··· 99 </script> 100 <div class="page"> 101 <header> 102 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 103 <h1>{$_('appPasswords.title')}</h1> 104 </header> 105 <p class="description">
+1 -1
frontend/src/routes/Comms.svelte
··· 168 </script> 169 <div class="page"> 170 <header> 171 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 172 <h1>{$_('comms.title')}</h1> 173 <p class="description">{$_('comms.description')}</p> 174 </header>
··· 168 </script> 169 <div class="page"> 170 <header> 171 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 172 <h1>{$_('comms.title')}</h1> 173 <p class="description">{$_('comms.description')}</p> 174 </header>
+3 -3
frontend/src/routes/Controllers.svelte
··· 232 233 <div class="page"> 234 <header> 235 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 236 <h1>{$_('delegation.title')}</h1> 237 </header> 238 ··· 358 </div> 359 </div> 360 <div class="item-actions"> 361 - <a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 362 {$_('delegation.actAs')} 363 </a> 364 </div> ··· 423 <h2>{$_('delegation.auditLog')}</h2> 424 <p class="section-description">{$_('delegation.auditLogDesc')}</p> 425 </div> 426 - <a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 427 </section> 428 {/if} 429 </div>
··· 232 233 <div class="page"> 234 <header> 235 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 236 <h1>{$_('delegation.title')}</h1> 237 </header> 238 ··· 358 </div> 359 </div> 360 <div class="item-actions"> 361 + <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 362 {$_('delegation.actAs')} 363 </a> 364 </div> ··· 423 <h2>{$_('delegation.auditLog')}</h2> 424 <p class="section-description">{$_('delegation.auditLogDesc')}</p> 425 </div> 426 + <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 427 </section> 428 {/if} 429 </div>
+16 -16
frontend/src/routes/Dashboard.svelte
··· 166 167 <nav class="nav-grid"> 168 {#if auth.session.status === 'migrated'} 169 - <a href="#/did-document" class="nav-card migrated-card"> 170 <h3>{$_('dashboard.navDidDocument')}</h3> 171 <p>{$_('dashboard.navDidDocumentDesc')}</p> 172 </a> 173 - <a href="#/sessions" class="nav-card"> 174 <h3>{$_('dashboard.navSessions')}</h3> 175 <p>{$_('dashboard.navSessionsDesc')}</p> 176 </a> 177 - <a href="#/security" class="nav-card"> 178 <h3>{$_('dashboard.navSecurity')}</h3> 179 <p>{$_('dashboard.navSecurityDesc')}</p> 180 </a> 181 - <a href="#/settings" class="nav-card"> 182 <h3>{$_('dashboard.navSettings')}</h3> 183 <p>{$_('dashboard.navSettingsDesc')}</p> 184 </a> 185 - <a href="#/migrate" class="nav-card"> 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> 188 </a> 189 {:else} 190 - <a href="#/app-passwords" class="nav-card"> 191 <h3>{$_('dashboard.navAppPasswords')}</h3> 192 <p>{$_('dashboard.navAppPasswordsDesc')}</p> 193 </a> 194 - <a href="#/sessions" class="nav-card"> 195 <h3>{$_('dashboard.navSessions')}</h3> 196 <p>{$_('dashboard.navSessionsDesc')}</p> 197 </a> 198 {#if inviteCodesEnabled && auth.session.isAdmin} 199 - <a href="#/invite-codes" class="nav-card"> 200 <h3>{$_('dashboard.navInviteCodes')}</h3> 201 <p>{$_('dashboard.navInviteCodesDesc')}</p> 202 </a> 203 {/if} 204 - <a href="#/settings" class="nav-card"> 205 <h3>{$_('dashboard.navSettings')}</h3> 206 <p>{$_('dashboard.navSettingsDesc')}</p> 207 </a> 208 - <a href="#/security" class="nav-card"> 209 <h3>{$_('dashboard.navSecurity')}</h3> 210 <p>{$_('dashboard.navSecurityDesc')}</p> 211 </a> 212 - <a href="#/comms" class="nav-card"> 213 <h3>{$_('dashboard.navComms')}</h3> 214 <p>{$_('dashboard.navCommsDesc')}</p> 215 </a> 216 - <a href="#/repo" class="nav-card"> 217 <h3>{$_('dashboard.navRepo')}</h3> 218 <p>{$_('dashboard.navRepoDesc')}</p> 219 </a> 220 - <a href="#/controllers" class="nav-card"> 221 <h3>{$_('dashboard.navDelegation')}</h3> 222 <p>{$_('dashboard.navDelegationDesc')}</p> 223 </a> 224 {#if isDidWeb} 225 - <a href="#/did-document" class="nav-card did-web-card"> 226 <h3>{$_('dashboard.navDidDocument')}</h3> 227 <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 </a> 229 {/if} 230 - <a href="#/migrate" class="nav-card"> 231 <h3>{$_('migration.navTitle')}</h3> 232 <p>{$_('migration.navDesc')}</p> 233 </a> 234 {#if auth.session.isAdmin} 235 - <a href="#/admin" class="nav-card admin-card"> 236 <h3>{$_('dashboard.navAdmin')}</h3> 237 <p>{$_('dashboard.navAdminDesc')}</p> 238 </a>
··· 166 167 <nav class="nav-grid"> 168 {#if auth.session.status === 'migrated'} 169 + <a href="/app/did-document" class="nav-card migrated-card"> 170 <h3>{$_('dashboard.navDidDocument')}</h3> 171 <p>{$_('dashboard.navDidDocumentDesc')}</p> 172 </a> 173 + <a href="/app/sessions" class="nav-card"> 174 <h3>{$_('dashboard.navSessions')}</h3> 175 <p>{$_('dashboard.navSessionsDesc')}</p> 176 </a> 177 + <a href="/app/security" class="nav-card"> 178 <h3>{$_('dashboard.navSecurity')}</h3> 179 <p>{$_('dashboard.navSecurityDesc')}</p> 180 </a> 181 + <a href="/app/settings" class="nav-card"> 182 <h3>{$_('dashboard.navSettings')}</h3> 183 <p>{$_('dashboard.navSettingsDesc')}</p> 184 </a> 185 + <a href="/app/migrate" class="nav-card"> 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> 188 </a> 189 {:else} 190 + <a href="/app/app-passwords" class="nav-card"> 191 <h3>{$_('dashboard.navAppPasswords')}</h3> 192 <p>{$_('dashboard.navAppPasswordsDesc')}</p> 193 </a> 194 + <a href="/app/sessions" class="nav-card"> 195 <h3>{$_('dashboard.navSessions')}</h3> 196 <p>{$_('dashboard.navSessionsDesc')}</p> 197 </a> 198 {#if inviteCodesEnabled && auth.session.isAdmin} 199 + <a href="/app/invite-codes" class="nav-card"> 200 <h3>{$_('dashboard.navInviteCodes')}</h3> 201 <p>{$_('dashboard.navInviteCodesDesc')}</p> 202 </a> 203 {/if} 204 + <a href="/app/settings" class="nav-card"> 205 <h3>{$_('dashboard.navSettings')}</h3> 206 <p>{$_('dashboard.navSettingsDesc')}</p> 207 </a> 208 + <a href="/app/security" class="nav-card"> 209 <h3>{$_('dashboard.navSecurity')}</h3> 210 <p>{$_('dashboard.navSecurityDesc')}</p> 211 </a> 212 + <a href="/app/comms" class="nav-card"> 213 <h3>{$_('dashboard.navComms')}</h3> 214 <p>{$_('dashboard.navCommsDesc')}</p> 215 </a> 216 + <a href="/app/repo" class="nav-card"> 217 <h3>{$_('dashboard.navRepo')}</h3> 218 <p>{$_('dashboard.navRepoDesc')}</p> 219 </a> 220 + <a href="/app/controllers" class="nav-card"> 221 <h3>{$_('dashboard.navDelegation')}</h3> 222 <p>{$_('dashboard.navDelegationDesc')}</p> 223 </a> 224 {#if isDidWeb} 225 + <a href="/app/did-document" class="nav-card did-web-card"> 226 <h3>{$_('dashboard.navDidDocument')}</h3> 227 <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 </a> 229 {/if} 230 + <a href="/app/migrate" class="nav-card"> 231 <h3>{$_('migration.navTitle')}</h3> 232 <p>{$_('migration.navDesc')}</p> 233 </a> 234 {#if auth.session.isAdmin} 235 + <a href="/app/admin" class="nav-card admin-card"> 236 <h3>{$_('dashboard.navAdmin')}</h3> 237 <p>{$_('dashboard.navAdminDesc')}</p> 238 </a>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 108 109 <div class="page"> 110 <header> 111 - <a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a> 112 <h1>{$_('delegation.auditLogTitle')}</h1> 113 </header> 114
··· 108 109 <div class="page"> 110 <header> 111 + <a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a> 112 <h1>{$_('delegation.auditLogTitle')}</h1> 113 </header> 114
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 105 106 <div class="page"> 107 <header> 108 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 109 <h1>{$_('didEditor.title')}</h1> 110 </header> 111
··· 105 106 <div class="page"> 107 <header> 108 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 109 <h1>{$_('didEditor.title')}</h1> 110 </header> 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 </script> 88 <div class="page"> 89 <header> 90 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 91 <h1>{$_('inviteCodes.title')}</h1> 92 </header> 93 <p class="description">
··· 87 </script> 88 <div class="page"> 89 <header> 90 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 91 <h1>{$_('inviteCodes.title')}</h1> 92 </header> 93 <p class="description">
+3 -3
frontend/src/routes/Login.svelte
··· 161 </button> 162 163 <p class="forgot-links"> 164 - <a href="#/reset-password">{$_('login.forgotPassword')}</a> 165 <span class="separator">&middot;</span> 166 - <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 167 </p> 168 169 <p class="link-text"> 170 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 171 </p> 172 </div> 173
··· 161 </button> 162 163 <p class="forgot-links"> 164 + <a href="/app/reset-password">{$_('login.forgotPassword')}</a> 165 <span class="separator">&middot;</span> 166 + <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 167 </p> 168 169 <p class="link-text"> 170 + {$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a> 171 </p> 172 </div> 173
+7 -2
frontend/src/routes/Migration.svelte
··· 39 if (errorParam) { 40 oauthCallbackProcessed = true 41 oauthError = errorDescription || errorParam 42 - window.history.replaceState({}, '', '/#/migrate') 43 return 44 } 45 46 if (code && state) { 47 oauthCallbackProcessed = true 48 - window.history.replaceState({}, '', '/#/migrate') 49 direction = 'inbound' 50 oauthLoading = true 51 inboundFlow = createInboundMigrationFlow() 52 53 inboundFlow.handleOAuthCallback(code, state) 54 .then(() => {
··· 39 if (errorParam) { 40 oauthCallbackProcessed = true 41 oauthError = errorDescription || errorParam 42 + window.history.replaceState({}, '', '/app/migrate') 43 return 44 } 45 46 if (code && state) { 47 oauthCallbackProcessed = true 48 + window.history.replaceState({}, '', '/app/migrate') 49 direction = 'inbound' 50 oauthLoading = true 51 inboundFlow = createInboundMigrationFlow() 52 + 53 + const stored = loadMigrationState() 54 + if (stored && stored.direction === 'inbound') { 55 + inboundFlow.resumeFromState(stored) 56 + } 57 58 inboundFlow.handleOAuthCallback(code, state) 59 .then(() => {
+2 -2
frontend/src/routes/OAuth2FA.svelte
··· 7 let error = $state<string | null>(null) 8 9 function getRequestUri(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 11 return params.get('request_uri') 12 } 13 14 function getChannel(): string { 15 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 16 return params.get('channel') || 'email' 17 } 18
··· 7 let error = $state<string | null>(null) 8 9 function getRequestUri(): string | null { 10 + const params = new URLSearchParams(window.location.search) 11 return params.get('request_uri') 12 } 13 14 function getChannel(): string { 15 + const params = new URLSearchParams(window.location.search) 16 return params.get('channel') || 'email' 17 } 18
+1 -1
frontend/src/routes/OAuthAccounts.svelte
··· 14 let accounts = $state<AccountInfo[]>([]) 15 16 function getRequestUri(): string | null { 17 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 18 return params.get('request_uri') 19 } 20
··· 14 let accounts = $state<AccountInfo[]>([]) 15 16 function getRequestUri(): string | null { 17 + const params = new URLSearchParams(window.location.search) 18 return params.get('request_uri') 19 } 20
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 34 let rememberChoice = $state(false) 35 36 function getRequestUri(): string | null { 37 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 38 return params.get('request_uri') 39 } 40
··· 34 let rememberChoice = $state(false) 35 36 function getRequestUri(): string | null { 37 + const params = new URLSearchParams(window.location.search) 38 return params.get('request_uri') 39 } 40
+2 -2
frontend/src/routes/OAuthDelegation.svelte
··· 21 }) 22 23 function getRequestUri(): string | null { 24 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 25 return params.get('request_uri') 26 } 27 28 function getDelegatedDid(): string | null { 29 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 30 return params.get('delegated_did') 31 } 32
··· 21 }) 22 23 function getRequestUri(): string | null { 24 + const params = new URLSearchParams(window.location.search) 25 return params.get('request_uri') 26 } 27 28 function getDelegatedDid(): string | null { 29 + const params = new URLSearchParams(window.location.search) 30 return params.get('delegated_did') 31 } 32
+2 -2
frontend/src/routes/OAuthError.svelte
··· 2 import { _ } from '../lib/i18n' 3 4 function getError(): string { 5 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 6 return params.get('error') || 'Unknown error' 7 } 8 9 function getErrorDescription(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 11 return params.get('error_description') 12 } 13
··· 2 import { _ } from '../lib/i18n' 3 4 function getError(): string { 5 + const params = new URLSearchParams(window.location.search) 6 return params.get('error') || 'Unknown error' 7 } 8 9 function getErrorDescription(): string | null { 10 + const params = new URLSearchParams(window.location.search) 11 return params.get('error_description') 12 } 13
+3 -3
frontend/src/routes/OAuthLogin.svelte
··· 22 }) 23 24 function getRequestUri(): string | null { 25 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 26 return params.get('request_uri') 27 } 28 29 function getErrorFromUrl(): string | null { 30 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 31 return params.get('error') 32 } 33 ··· 456 </form> 457 458 <p class="help-links"> 459 - <a href="#/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 460 </p> 461 </div> 462
··· 22 }) 23 24 function getRequestUri(): string | null { 25 + const params = new URLSearchParams(window.location.search) 26 return params.get('request_uri') 27 } 28 29 function getErrorFromUrl(): string | null { 30 + const params = new URLSearchParams(window.location.search) 31 return params.get('error') 32 } 33 ··· 456 </form> 457 458 <p class="help-links"> 459 + <a href="/app/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 460 </p> 461 </div> 462
+1 -1
frontend/src/routes/OAuthPasskey.svelte
··· 7 let autoStarted = $state(false) 8 9 function getRequestUri(): string | null { 10 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 11 return params.get('request_uri') 12 } 13
··· 7 let autoStarted = $state(false) 8 9 function getRequestUri(): string | null { 10 + const params = new URLSearchParams(window.location.search) 11 return params.get('request_uri') 12 } 13
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 8 let error = $state<string | null>(null) 9 10 function getRequestUri(): string | null { 11 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 12 return params.get('request_uri') 13 } 14
··· 8 let error = $state<string | null>(null) 9 10 function getRequestUri(): string | null { 11 + const params = new URLSearchParams(window.location.search) 12 return params.get('request_uri') 13 } 14
+1 -1
frontend/src/routes/RecoverPasskey.svelte
··· 10 let success = $state(false) 11 12 function getUrlParams(): { did: string | null; token: string | null } { 13 - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 14 return { 15 did: params.get('did'), 16 token: params.get('token'),
··· 10 let success = $state(false) 11 12 function getUrlParams(): { did: string | null; token: string | null } { 13 + const params = new URLSearchParams(window.location.search) 14 return { 15 did: params.get('did'), 16 token: params.get('token'),
+3 -3
frontend/src/routes/Register.svelte
··· 174 <div class="migrate-content"> 175 <strong>{$_('register.migrateTitle')}</strong> 176 <p>{$_('register.migrateDescription')}</p> 177 - <a href="#/migrate" class="migrate-link"> 178 {$_('register.migrateLink')} → 179 </a> 180 </div> ··· 381 382 <div class="form-links"> 383 <p class="link-text"> 384 - {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 385 </p> 386 <p class="link-text"> 387 - {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 388 </p> 389 </div> 390 </div>
··· 174 <div class="migrate-content"> 175 <strong>{$_('register.migrateTitle')}</strong> 176 <p>{$_('register.migrateDescription')}</p> 177 + <a href="/app/migrate" class="migrate-link"> 178 {$_('register.migrateLink')} → 179 </a> 180 </div> ··· 381 382 <div class="form-links"> 383 <p class="link-text"> 384 + {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a> 385 </p> 386 <p class="link-text"> 387 + {$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a> 388 </p> 389 </div> 390 </div>
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 413 </form> 414 415 <p class="link-text"> 416 - {$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a> 417 </p> 418 419 {:else if flow.state.step === 'key-choice'}
··· 413 </form> 414 415 <p class="link-text"> 416 + {$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a> 417 </p> 418 419 {:else if flow.state.step === 'key-choice'}
+1 -1
frontend/src/routes/RepoExplorer.svelte
··· 276 <div class="page"> 277 <header> 278 <div class="breadcrumb"> 279 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 280 {#if view !== 'collections'} 281 <span class="sep">/</span> 282 <button class="breadcrumb-link" onclick={goBack}>
··· 276 <div class="page"> 277 <header> 278 <div class="breadcrumb"> 279 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 280 {#if view !== 'collections'} 281 <span class="sep">/</span> 282 <button class="breadcrumb-link" onclick={goBack}>
+1 -1
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 71 {/if} 72 73 <p class="link-text"> 74 - <a href="#/login">{$_('common.backToLogin')}</a> 75 </p> 76 </div> 77
··· 71 {/if} 72 73 <p class="link-text"> 74 + <a href="/app/login">{$_('common.backToLogin')}</a> 75 </p> 76 </div> 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 {/if} 142 143 <p class="link-text"> 144 - <a href="#/login">{$_('common.backToLogin')}</a> 145 </p> 146 </div> 147
··· 141 {/if} 142 143 <p class="link-text"> 144 + <a href="/app/login">{$_('common.backToLogin')}</a> 145 </p> 146 </div> 147
+4 -4
frontend/src/routes/Security.svelte
··· 403 404 <div class="page"> 405 <header> 406 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 407 <h1>{$_('security.title')}</h1> 408 </header> 409 ··· 722 <p class="description"> 723 {$_('security.trustedDevicesDescription')} 724 </p> 725 - <a href="#/trusted-devices" class="section-link"> 726 {$_('security.manageTrustedDevices')} &rarr; 727 </a> 728 </section> ··· 765 <strong>{$_('security.legacyLoginWarning')}</strong> 766 <p>{$_('security.totpPasswordWarning')}</p> 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> 770 </ol> 771 </div> 772 {/if}
··· 403 404 <div class="page"> 405 <header> 406 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 407 <h1>{$_('security.title')}</h1> 408 </header> 409 ··· 722 <p class="description"> 723 {$_('security.trustedDevicesDescription')} 724 </p> 725 + <a href="/app/trusted-devices" class="section-link"> 726 {$_('security.manageTrustedDevices')} &rarr; 727 </a> 728 </section> ··· 765 <strong>{$_('security.legacyLoginWarning')}</strong> 766 <p>{$_('security.totpPasswordWarning')}</p> 767 <ol> 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 </ol> 771 </div> 772 {/if}
+1 -1
frontend/src/routes/Sessions.svelte
··· 88 </script> 89 <div class="page"> 90 <header> 91 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 92 <h1>{$_('sessions.title')}</h1> 93 </header> 94 {#if loading}
··· 88 </script> 89 <div class="page"> 90 <header> 91 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 92 <h1>{$_('sessions.title')}</h1> 93 </header> 94 {#if loading}
+1 -1
frontend/src/routes/Settings.svelte
··· 368 </script> 369 <div class="page"> 370 <header> 371 - <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 372 <h1>{$_('settings.title')}</h1> 373 </header> 374 {#if message}
··· 368 </script> 369 <div class="page"> 370 <header> 371 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 372 <h1>{$_('settings.title')}</h1> 373 </header> 374 {#if message}
+1 -1
frontend/src/routes/TrustedDevices.svelte
··· 112 113 <div class="page"> 114 <header> 115 - <a href="#/security" class="back">{$_('trustedDevices.backToSecurity')}</a> 116 <h1>{$_('trustedDevices.title')}</h1> 117 </header> 118
··· 112 113 <div class="page"> 114 <header> 115 + <a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a> 116 <h1>{$_('trustedDevices.title')}</h1> 117 </header> 118
+11 -18
frontend/src/routes/Verify.svelte
··· 33 34 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 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 - } 47 } 48 return params 49 } ··· 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 <div class="actions"> 238 - <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 239 </div> 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 242 <p class="info-text">{$_('verify.canNowSignIn')}</p> 243 <div class="actions"> 244 - <a href="#/login" class="btn">{$_('verify.signIn')}</a> 245 </div> 246 {:else} 247 <p class="subtitle"> ··· 259 {#if !auth.session} 260 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 <div class="actions"> 262 - <a href="#/login" class="btn">{$_('verify.signIn')}</a> 263 </div> 264 {:else} 265 {#if error} ··· 301 </form> 302 303 <p class="link-text"> 304 - <a href="#/settings">{$_('common.backToSettings')}</a> 305 </p> 306 {/if} 307 {:else if mode === 'token'} ··· 356 </form> 357 358 <p class="link-text"> 359 - <a href="#/login">{$_('common.backToLogin')}</a> 360 </p> 361 {:else if pendingVerification} 362 <h1>{$_('verify.title')}</h1> ··· 399 </form> 400 401 <p class="link-text"> 402 - <a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 403 </p> 404 {:else} 405 <h1>{$_('verify.title')}</h1> ··· 407 <p class="info-text">{$_('verify.noPendingInfo')}</p> 408 409 <div class="actions"> 410 - <a href="#/register" class="btn">{$_('verify.createAccount')}</a> 411 - <a href="#/login" class="btn secondary">{$_('verify.signIn')}</a> 412 </div> 413 {/if} 414 </div>
··· 33 34 35 function parseQueryParams() { 36 const params: Record<string, string> = {} 37 + const searchParams = new URLSearchParams(window.location.search) 38 + for (const [key, value] of searchParams.entries()) { 39 + params[key] = value 40 } 41 return params 42 } ··· 228 <p class="subtitle">{$_('verify.emailUpdated')}</p> 229 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 230 <div class="actions"> 231 + <a href="/app/settings" class="btn">{$_('common.backToSettings')}</a> 232 </div> 233 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 234 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> 235 <p class="info-text">{$_('verify.canNowSignIn')}</p> 236 <div class="actions"> 237 + <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 238 </div> 239 {:else} 240 <p class="subtitle"> ··· 252 {#if !auth.session} 253 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 254 <div class="actions"> 255 + <a href="/app/login" class="btn">{$_('verify.signIn')}</a> 256 </div> 257 {:else} 258 {#if error} ··· 294 </form> 295 296 <p class="link-text"> 297 + <a href="/app/settings">{$_('common.backToSettings')}</a> 298 </p> 299 {/if} 300 {:else if mode === 'token'} ··· 349 </form> 350 351 <p class="link-text"> 352 + <a href="/app/login">{$_('common.backToLogin')}</a> 353 </p> 354 {:else if pendingVerification} 355 <h1>{$_('verify.title')}</h1> ··· 392 </form> 393 394 <p class="link-text"> 395 + <a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 396 </p> 397 {:else} 398 <h1>{$_('verify.title')}</h1> ··· 400 <p class="info-text">{$_('verify.noPendingInfo')}</p> 401 402 <div class="actions"> 403 + <a href="/app/register" class="btn">{$_('verify.createAccount')}</a> 404 + <a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a> 405 </div> 406 {/if} 407 </div>
+10 -7
frontend/src/tests/AppPasswords.test.ts
··· 22 setupUnauthenticatedUser(); 23 render(AppPasswords); 24 await waitFor(() => { 25 - expect(globalThis.location.hash).toBe("#/login"); 26 }); 27 }); 28 }); ··· 41 screen.getByRole("heading", { name: /app passwords/i, level: 1 }), 42 ).toBeInTheDocument(); 43 expect(screen.getByRole("link", { name: /dashboard/i })) 44 - .toHaveAttribute("href", "#/dashboard"); 45 expect(screen.getByText(/third-party apps/i)).toBeInTheDocument(); 46 }); 47 }); ··· 50 beforeEach(() => { 51 setupAuthenticatedUser(); 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 - }); 58 render(AppPasswords); 59 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 60 });
··· 22 setupUnauthenticatedUser(); 23 render(AppPasswords); 24 await waitFor(() => { 25 + expect(globalThis.location.pathname).toBe("/app/login"); 26 }); 27 }); 28 }); ··· 41 screen.getByRole("heading", { name: /app passwords/i, level: 1 }), 42 ).toBeInTheDocument(); 43 expect(screen.getByRole("link", { name: /dashboard/i })) 44 + .toHaveAttribute("href", "/app/dashboard"); 45 expect(screen.getByText(/third-party apps/i)).toBeInTheDocument(); 46 }); 47 }); ··· 50 beforeEach(() => { 51 setupAuthenticatedUser(); 52 }); 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 + ); 61 render(AppPasswords); 62 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 63 });
+13 -7
frontend/src/tests/Comms.test.ts
··· 21 setupUnauthenticatedUser(); 22 render(Comms); 23 await waitFor(() => { 24 - expect(globalThis.location.hash).toBe("#/login"); 25 }); 26 }); 27 }); ··· 51 }), 52 ).toBeInTheDocument(); 53 expect(screen.getByRole("link", { name: /dashboard/i })) 54 - .toHaveAttribute("href", "#/dashboard"); 55 expect(screen.getByRole("heading", { name: /preferred channel/i })) 56 .toBeInTheDocument(); 57 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 71 () => jsonResponse({ notifications: [] }), 72 ); 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 - }); 79 render(Comms); 80 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 81 });
··· 21 setupUnauthenticatedUser(); 22 render(Comms); 23 await waitFor(() => { 24 + expect(globalThis.location.pathname).toBe("/app/login"); 25 }); 26 }); 27 }); ··· 51 }), 52 ).toBeInTheDocument(); 53 expect(screen.getByRole("link", { name: /dashboard/i })) 54 + .toHaveAttribute("href", "/app/dashboard"); 55 expect(screen.getByRole("heading", { name: /preferred channel/i })) 56 .toBeInTheDocument(); 57 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 71 () => jsonResponse({ notifications: [] }), 72 ); 73 }); 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 + ); 85 render(Comms); 86 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 87 });
+13 -13
frontend/src/tests/Dashboard.test.ts
··· 21 setupUnauthenticatedUser(); 22 render(Dashboard); 23 await waitFor(() => { 24 - expect(globalThis.location.hash).toBe("#/login"); 25 }); 26 }); 27 it("shows loading state while checking auth", () => { ··· 61 render(Dashboard); 62 await waitFor(() => { 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" }, 68 ]; 69 for (const { name, href } of navCards) { 70 const card = screen.getByRole("link", { name }); ··· 84 await waitFor(() => { 85 const inviteCard = screen.getByRole("link", { name: /invite codes/i }); 86 expect(inviteCard).toBeInTheDocument(); 87 - expect(inviteCard).toHaveAttribute("href", "#/invite-codes"); 88 }); 89 }); 90 }); ··· 92 beforeEach(() => { 93 setupAuthenticatedUser(); 94 localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())); 95 - mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 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; 101 return jsonResponse({}); 102 }); 103 render(Dashboard); ··· 112 }); 113 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 114 await waitFor(() => { 115 - expect(deleteSessionCalled).toBe(true); 116 - expect(globalThis.location.hash).toBe("#/login"); 117 }); 118 }); 119 it("clears session from localStorage after logout", async () => {
··· 21 setupUnauthenticatedUser(); 22 render(Dashboard); 23 await waitFor(() => { 24 + expect(globalThis.location.pathname).toBe("/app/login"); 25 }); 26 }); 27 it("shows loading state while checking auth", () => { ··· 61 render(Dashboard); 62 await waitFor(() => { 63 const navCards = [ 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 ]; 69 for (const { name, href } of navCards) { 70 const card = screen.getByRole("link", { name }); ··· 84 await waitFor(() => { 85 const inviteCard = screen.getByRole("link", { name: /invite codes/i }); 86 expect(inviteCard).toBeInTheDocument(); 87 + expect(inviteCard).toHaveAttribute("href", "/app/invite-codes"); 88 }); 89 }); 90 }); ··· 92 beforeEach(() => { 93 setupAuthenticatedUser(); 94 localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())); 95 + mockEndpoint("/oauth/revoke", () => jsonResponse({})); 96 }); 97 + it("calls oauth revoke and navigates to login on logout", async () => { 98 + let revokeCalled = false; 99 + mockEndpoint("/oauth/revoke", () => { 100 + revokeCalled = true; 101 return jsonResponse({}); 102 }); 103 render(Dashboard); ··· 112 }); 113 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 114 await waitFor(() => { 115 + expect(revokeCalled).toBe(true); 116 + expect(globalThis.location.pathname).toBe("/app/login"); 117 }); 118 }); 119 it("clears session from localStorage after logout", async () => {
+6 -7
frontend/src/tests/Login.test.ts
··· 14 beforeEach(() => { 15 clearMocks(); 16 setupFetchMock(); 17 - globalThis.location.hash = ""; 18 mockEndpoint( 19 "/oauth/par", 20 () => jsonResponse({ request_uri: "urn:mock:request" }), ··· 47 expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 48 expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute( 49 "href", 50 - "#/register", 51 ); 52 }); 53 }); ··· 56 render(Login); 57 await waitFor(() => { 58 expect(screen.getByRole("link", { name: /forgot password/i })) 59 - .toHaveAttribute("href", "#/reset-password"); 60 expect(screen.getByRole("link", { name: /lost passkey/i })) 61 - .toHaveAttribute("href", "#/request-passkey-recovery"); 62 }); 63 }); 64 }); ··· 122 await fireEvent.click(aliceAccount); 123 } 124 await waitFor(() => { 125 - expect(globalThis.location.hash).toBe("#/dashboard"); 126 }); 127 }); 128 ··· 163 }); 164 }); 165 166 - it("shows verification form when pending verification exists", async () => { 167 render(Login); 168 }); 169 }); 170 171 describe("loading state", () => { 172 - it("shows loading state while auth is initializing", async () => { 173 _testSetState({ 174 session: null, 175 loading: true,
··· 14 beforeEach(() => { 15 clearMocks(); 16 setupFetchMock(); 17 mockEndpoint( 18 "/oauth/par", 19 () => jsonResponse({ request_uri: "urn:mock:request" }), ··· 46 expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 47 expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute( 48 "href", 49 + "/app/register", 50 ); 51 }); 52 }); ··· 55 render(Login); 56 await waitFor(() => { 57 expect(screen.getByRole("link", { name: /forgot password/i })) 58 + .toHaveAttribute("href", "/app/reset-password"); 59 expect(screen.getByRole("link", { name: /lost passkey/i })) 60 + .toHaveAttribute("href", "/app/request-passkey-recovery"); 61 }); 62 }); 63 }); ··· 121 await fireEvent.click(aliceAccount); 122 } 123 await waitFor(() => { 124 + expect(globalThis.location.pathname).toBe("/app/dashboard"); 125 }); 126 }); 127 ··· 162 }); 163 }); 164 165 + it("shows verification form when pending verification exists", () => { 166 render(Login); 167 }); 168 }); 169 170 describe("loading state", () => { 171 + it("shows loading state while auth is initializing", () => { 172 _testSetState({ 173 session: null, 174 loading: true,
+3 -3
frontend/src/tests/Settings.test.ts
··· 22 setupUnauthenticatedUser(); 23 render(Settings); 24 await waitFor(() => { 25 - expect(globalThis.location.hash).toBe("#/login"); 26 }); 27 }); 28 }); ··· 37 screen.getByRole("heading", { name: /account settings/i, level: 1 }), 38 ).toBeInTheDocument(); 39 expect(screen.getByRole("link", { name: /dashboard/i })) 40 - .toHaveAttribute("href", "#/dashboard"); 41 expect(screen.getByRole("heading", { name: /change email/i })) 42 .toBeInTheDocument(); 43 expect(screen.getByRole("heading", { name: /change handle/i })) ··· 463 screen.getByRole("button", { name: /permanently delete account/i }), 464 ); 465 await waitFor(() => { 466 - expect(globalThis.location.hash).toBe("#/login"); 467 }); 468 }); 469 it("shows cancel button to return to request state", async () => {
··· 22 setupUnauthenticatedUser(); 23 render(Settings); 24 await waitFor(() => { 25 + expect(globalThis.location.pathname).toBe("/app/login"); 26 }); 27 }); 28 }); ··· 37 screen.getByRole("heading", { name: /account settings/i, level: 1 }), 38 ).toBeInTheDocument(); 39 expect(screen.getByRole("link", { name: /dashboard/i })) 40 + .toHaveAttribute("href", "/app/dashboard"); 41 expect(screen.getByRole("heading", { name: /change email/i })) 42 .toBeInTheDocument(); 43 expect(screen.getByRole("heading", { name: /change handle/i })) ··· 463 screen.getByRole("button", { name: /permanently delete account/i }), 464 ); 465 await waitFor(() => { 466 + expect(globalThis.location.pathname).toBe("/app/login"); 467 }); 468 }); 469 it("shows cancel button to return to request state", async () => {
+1 -1
frontend/src/tests/migration/atproto-client.test.ts
··· 263 describe("getMigrationOAuthRedirectUri", () => { 264 it("returns migrate path based on origin", () => { 265 const redirectUri = getMigrationOAuthRedirectUri(); 266 - expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`); 267 }); 268 }); 269
··· 263 describe("getMigrationOAuthRedirectUri", () => { 264 it("returns migrate path based on origin", () => { 265 const redirectUri = getMigrationOAuthRedirectUri(); 266 + expect(redirectUri).toBe(`${globalThis.location.origin}/app/migrate`); 267 }); 268 }); 269
+55 -14
frontend/src/tests/mocks.ts
··· 1 import { vi } from "vitest"; 2 import type { AppPassword, InviteCode, Session } from "../lib/api"; 3 import { _testSetState } from "../lib/auth.svelte"; 4 export interface MockResponse { 5 ok: boolean; 6 status: number; ··· 49 clone: () => ({ ...result }) as Response, 50 body: null, 51 bodyUsed: false, 52 - arrayBuffer: async () => new ArrayBuffer(0), 53 - blob: async () => new Blob(), 54 - formData: async () => new FormData(), 55 } as Response; 56 } 57 return { 58 ok: false, 59 status: 404, 60 - json: async () => ({ 61 - error: "NotFound", 62 - message: `No mock for ${endpoint}`, 63 - }), 64 - text: async () => 65 - JSON.stringify({ 66 error: "NotFound", 67 message: `No mock for ${endpoint}`, 68 }), 69 headers: new Headers(), 70 redirected: false, 71 statusText: "Not Found", ··· 76 }, 77 body: null, 78 bodyUsed: false, 79 - arrayBuffer: async () => new ArrayBuffer(0), 80 - blob: async () => new Blob(), 81 - formData: async () => new FormData(), 82 } as Response; 83 }, 84 ); ··· 87 return { 88 ok: status >= 200 && status < 300, 89 status, 90 - json: async () => data, 91 }; 92 } 93 export function errorResponse( ··· 98 return { 99 ok: false, 100 status, 101 - json: async () => ({ error, message }), 102 }; 103 } 104 export const mockData = {
··· 1 import { vi } from "vitest"; 2 import type { AppPassword, InviteCode, Session } from "../lib/api"; 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 + 42 export interface MockResponse { 43 ok: boolean; 44 status: number; ··· 87 clone: () => ({ ...result }) as Response, 88 body: null, 89 bodyUsed: false, 90 + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), 91 + blob: () => Promise.resolve(new Blob()), 92 + formData: () => Promise.resolve(new FormData()), 93 } as Response; 94 } 95 return { 96 ok: false, 97 status: 404, 98 + json: () => 99 + Promise.resolve({ 100 error: "NotFound", 101 message: `No mock for ${endpoint}`, 102 }), 103 + text: () => 104 + Promise.resolve( 105 + JSON.stringify({ 106 + error: "NotFound", 107 + message: `No mock for ${endpoint}`, 108 + }), 109 + ), 110 headers: new Headers(), 111 redirected: false, 112 statusText: "Not Found", ··· 117 }, 118 body: null, 119 bodyUsed: false, 120 + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), 121 + blob: () => Promise.resolve(new Blob()), 122 + formData: () => Promise.resolve(new FormData()), 123 } as Response; 124 }, 125 ); ··· 128 return { 129 ok: status >= 200 && status < 300, 130 status, 131 + json: () => Promise.resolve(data), 132 }; 133 } 134 export function errorResponse( ··· 139 return { 140 ok: false, 141 status, 142 + json: () => Promise.resolve({ error, message }), 143 }; 144 } 145 export const mockData = {
+25 -2
src/api/proxy.rs
··· 130 Err(e) => { 131 warn!("Token validation failed: {:?}", e); 132 if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 133 - return ( 134 - StatusCode::BAD_REQUEST, 135 Json(json!({ 136 "error": "ExpiredToken", 137 "message": "Token has expired" 138 })), 139 ) 140 .into_response(); 141 } 142 } 143 }
··· 130 Err(e) => { 131 warn!("Token validation failed: {:?}", e); 132 if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 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, 148 Json(json!({ 149 "error": "ExpiredToken", 150 "message": "Token has expired" 151 })), 152 ) 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; 164 } 165 } 166 }
+12 -5
src/api/repo/record/delete.rs
··· 42 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 43 Json(input): Json<DeleteRecordInput>, 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 - }; 50 51 if let Err(e) = crate::auth::scope_check::check_repo_scope( 52 auth.is_oauth,
··· 42 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 43 Json(input): Json<DeleteRecordInput>, 44 ) -> Response { 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 + }; 57 58 if let Err(e) = crate::auth::scope_check::check_repo_scope( 59 auth.is_oauth,
+43 -12
src/api/repo/record/write.rs
··· 89 ) 90 .await 91 .map_err(|e| { 92 - ( 93 StatusCode::UNAUTHORIZED, 94 Json(json!({"error": e.to_string()})), 95 ) 96 - .into_response() 97 })?; 98 if repo_did != auth_user.did { 99 return Err(( ··· 219 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 220 Json(input): Json<CreateRecordInput>, 221 ) -> 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 - }; 227 228 if let Err(e) = crate::auth::scope_check::check_repo_scope( 229 auth.is_oauth, ··· 459 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 460 Json(input): Json<PutRecordInput>, 461 ) -> 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 - }; 467 468 if let Err(e) = crate::auth::scope_check::check_repo_scope( 469 auth.is_oauth,
··· 89 ) 90 .await 91 .map_err(|e| { 92 + tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 93 + let mut response = ( 94 StatusCode::UNAUTHORIZED, 95 Json(json!({"error": e.to_string()})), 96 ) 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 114 })?; 115 if repo_did != auth_user.did { 116 return Err(( ··· 236 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 237 Json(input): Json<CreateRecordInput>, 238 ) -> Response { 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 + }; 251 252 if let Err(e) = crate::auth::scope_check::check_repo_scope( 253 auth.is_oauth, ··· 483 axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 484 Json(input): Json<PutRecordInput>, 485 ) -> Response { 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 + }; 498 499 if let Err(e) = crate::auth::scope_check::check_repo_scope( 500 auth.is_oauth,
+1 -1
src/api/server/passkey_account.rs
··· 1257 1258 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1259 let recovery_url = format!( 1260 - "https://{}/#/recover-passkey?did={}&token={}", 1261 hostname, 1262 urlencoding::encode(&user.did), 1263 urlencoding::encode(&recovery_token)
··· 1257 1258 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1259 let recovery_url = format!( 1260 + "https://{}/app/recover-passkey?did={}&token={}", 1261 hostname, 1262 urlencoding::encode(&user.did), 1263 urlencoding::encode(&recovery_token)
+1
src/auth/mod.rs
··· 396 controller_did: None, 397 }) 398 } 399 Err(_) => Err(TokenValidationError::AuthenticationFailed), 400 } 401 }
··· 396 controller_did: None, 397 }) 398 } 399 + Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired), 400 Err(_) => Err(TokenValidationError::AuthenticationFailed), 401 } 402 }
+8 -8
src/comms/service.rs
··· 352 let strings = get_strings(&prefs.locale); 353 let encoded_email = urlencoding::encode(new_email); 354 let encoded_token = urlencoding::encode(code); 355 - let verify_page = format!("https://{}/#/verify", hostname); 356 let verify_link = format!( 357 - "https://{}/#/verify?token={}&identifier={}", 358 hostname, encoded_token, encoded_email 359 ); 360 let body = format_message( ··· 389 let prefs = get_user_comms_prefs(db, user_id).await?; 390 let strings = get_strings(&prefs.locale); 391 let current_email = prefs.email.clone().unwrap_or_default(); 392 - let verify_page = format!("https://{}/#/verify?type=email-update", hostname); 393 let verify_link = format!( 394 - "https://{}/#/verify?type=email-update&token={}", 395 hostname, 396 urlencoding::encode(code) 397 ); ··· 556 let encoded_email = urlencoding::encode(recipient); 557 let encoded_token = urlencoding::encode(code); 558 ( 559 - format!("https://{}/#/verify", hostname), 560 format!( 561 - "https://{}/#/verify?token={}&identifier={}", 562 hostname, encoded_token, encoded_email 563 ), 564 ) ··· 606 let strings = get_strings(&prefs.locale); 607 let encoded_email = urlencoding::encode(email); 608 let encoded_token = urlencoding::encode(token); 609 - let verify_page = format!("https://{}/#/verify", hostname); 610 let verify_link = format!( 611 - "https://{}/#/verify?token={}&identifier={}", 612 hostname, encoded_token, encoded_email 613 ); 614 let body = format_message(
··· 352 let strings = get_strings(&prefs.locale); 353 let encoded_email = urlencoding::encode(new_email); 354 let encoded_token = urlencoding::encode(code); 355 + let verify_page = format!("https://{}/app/verify", hostname); 356 let verify_link = format!( 357 + "https://{}/app/verify?token={}&identifier={}", 358 hostname, encoded_token, encoded_email 359 ); 360 let body = format_message( ··· 389 let prefs = get_user_comms_prefs(db, user_id).await?; 390 let strings = get_strings(&prefs.locale); 391 let current_email = prefs.email.clone().unwrap_or_default(); 392 + let verify_page = format!("https://{}/app/verify?type=email-update", hostname); 393 let verify_link = format!( 394 + "https://{}/app/verify?type=email-update&token={}", 395 hostname, 396 urlencoding::encode(code) 397 ); ··· 556 let encoded_email = urlencoding::encode(recipient); 557 let encoded_token = urlencoding::encode(code); 558 ( 559 + format!("https://{}/app/verify", hostname), 560 format!( 561 + "https://{}/app/verify?token={}&identifier={}", 562 hostname, encoded_token, encoded_email 563 ), 564 ) ··· 606 let strings = get_strings(&prefs.locale); 607 let encoded_email = urlencoding::encode(email); 608 let encoded_token = urlencoding::encode(token); 609 + let verify_page = format!("https://{}/app/verify", hostname); 610 let verify_link = format!( 611 + "https://{}/app/verify?token={}&identifier={}", 612 hostname, encoded_token, encoded_email 613 ); 614 let body = format_message(
+17 -2
src/lib.rs
··· 657 .exists() 658 { 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) 662 } else { 663 router 664 }
··· 657 .exists() 658 { 659 let index_path = format!("{}/index.html", frontend_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) 677 } else { 678 router 679 }
+16 -15
src/oauth/endpoints/authorize.rs
··· 25 26 fn redirect_to_frontend_error(error: &str, description: &str) -> Response { 27 redirect_see_other(&format!( 28 - "/#/oauth/error?error={}&error_description={}", 29 url_encode(error), 30 url_encode(description) 31 )) ··· 236 if is_delegated && !has_password { 237 tracing::info!("Redirecting to delegation auth"); 238 return redirect_see_other(&format!( 239 - "/#/oauth/delegation?request_uri={}&delegated_did={}", 240 url_encode(&request_uri), 241 url_encode(&user.did) 242 )); ··· 259 && !accounts.is_empty() 260 { 261 return redirect_see_other(&format!( 262 - "/#/oauth/accounts?request_uri={}", 263 url_encode(&request_uri) 264 )); 265 } 266 redirect_see_other(&format!( 267 - "/#/oauth/login?request_uri={}", 268 url_encode(&request_uri) 269 )) 270 } ··· 466 .into_response(); 467 } 468 redirect_see_other(&format!( 469 - "/#/oauth/login?request_uri={}&error={}", 470 url_encode(&form.request_uri), 471 url_encode(error_msg) 472 )) ··· 539 return show_login_error("An error occurred. Please try again.", json_response); 540 } 541 let redirect_url = format!( 542 - "/#/oauth/delegation?request_uri={}&delegated_did={}", 543 url_encode(&form.request_uri), 544 url_encode(&user.did) 545 ); ··· 565 return show_login_error("An error occurred. Please try again.", json_response); 566 } 567 let redirect_url = format!( 568 - "/#/oauth/passkey?request_uri={}", 569 url_encode(&form.request_uri) 570 ); 571 if json_response { ··· 620 .into_response(); 621 } 622 return redirect_see_other(&format!( 623 - "/#/oauth/totp?request_uri={}", 624 url_encode(&form.request_uri) 625 )); 626 } ··· 649 .into_response(); 650 } 651 return redirect_see_other(&format!( 652 - "/#/oauth/2fa?request_uri={}&channel={}", 653 url_encode(&form.request_uri), 654 url_encode(channel_name) 655 )); ··· 713 .unwrap_or(true); 714 if needs_consent { 715 let consent_url = format!( 716 - "/#/oauth/consent?request_uri={}", 717 url_encode(&form.request_uri) 718 ); 719 if json_response { ··· 1103 }; 1104 let channel = query.channel.as_deref().unwrap_or("email"); 1105 redirect_see_other(&format!( 1106 - "/#/oauth/2fa?request_uri={}&channel={}", 1107 url_encode(&query.request_uri), 1108 url_encode(channel) 1109 )) ··· 1464 || s.starts_with("blob:") 1465 || s.starts_with("rpc:") 1466 || s.starts_with("account:") 1467 || s.starts_with("include:") 1468 }); 1469 if !has_valid_scope { ··· 1708 .unwrap_or(true); 1709 if needs_consent { 1710 let consent_url = format!( 1711 - "/#/oauth/consent?request_uri={}", 1712 url_encode(&form.request_uri) 1713 ); 1714 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2345 2346 if needs_consent { 2347 let consent_url = format!( 2348 - "/#/oauth/consent?request_uri={}", 2349 url_encode(&form.request_uri) 2350 ); 2351 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2729 } 2730 let channel_name = channel_display_name(user.preferred_comms_channel); 2731 let redirect_url = format!( 2732 - "/#/oauth/2fa?request_uri={}&channel={}", 2733 url_encode(&form.request_uri), 2734 url_encode(channel_name) 2735 ); ··· 2754 } 2755 2756 let redirect_url = format!( 2757 - "/#/oauth/consent?request_uri={}", 2758 url_encode(&form.request_uri) 2759 ); 2760 (
··· 25 26 fn redirect_to_frontend_error(error: &str, description: &str) -> Response { 27 redirect_see_other(&format!( 28 + "/app/oauth/error?error={}&error_description={}", 29 url_encode(error), 30 url_encode(description) 31 )) ··· 236 if is_delegated && !has_password { 237 tracing::info!("Redirecting to delegation auth"); 238 return redirect_see_other(&format!( 239 + "/app/oauth/delegation?request_uri={}&delegated_did={}", 240 url_encode(&request_uri), 241 url_encode(&user.did) 242 )); ··· 259 && !accounts.is_empty() 260 { 261 return redirect_see_other(&format!( 262 + "/app/oauth/accounts?request_uri={}", 263 url_encode(&request_uri) 264 )); 265 } 266 redirect_see_other(&format!( 267 + "/app/oauth/login?request_uri={}", 268 url_encode(&request_uri) 269 )) 270 } ··· 466 .into_response(); 467 } 468 redirect_see_other(&format!( 469 + "/app/oauth/login?request_uri={}&error={}", 470 url_encode(&form.request_uri), 471 url_encode(error_msg) 472 )) ··· 539 return show_login_error("An error occurred. Please try again.", json_response); 540 } 541 let redirect_url = format!( 542 + "/app/oauth/delegation?request_uri={}&delegated_did={}", 543 url_encode(&form.request_uri), 544 url_encode(&user.did) 545 ); ··· 565 return show_login_error("An error occurred. Please try again.", json_response); 566 } 567 let redirect_url = format!( 568 + "/app/oauth/passkey?request_uri={}", 569 url_encode(&form.request_uri) 570 ); 571 if json_response { ··· 620 .into_response(); 621 } 622 return redirect_see_other(&format!( 623 + "/app/oauth/totp?request_uri={}", 624 url_encode(&form.request_uri) 625 )); 626 } ··· 649 .into_response(); 650 } 651 return redirect_see_other(&format!( 652 + "/app/oauth/2fa?request_uri={}&channel={}", 653 url_encode(&form.request_uri), 654 url_encode(channel_name) 655 )); ··· 713 .unwrap_or(true); 714 if needs_consent { 715 let consent_url = format!( 716 + "/app/oauth/consent?request_uri={}", 717 url_encode(&form.request_uri) 718 ); 719 if json_response { ··· 1103 }; 1104 let channel = query.channel.as_deref().unwrap_or("email"); 1105 redirect_see_other(&format!( 1106 + "/app/oauth/2fa?request_uri={}&channel={}", 1107 url_encode(&query.request_uri), 1108 url_encode(channel) 1109 )) ··· 1464 || s.starts_with("blob:") 1465 || s.starts_with("rpc:") 1466 || s.starts_with("account:") 1467 + || s.starts_with("identity:") 1468 || s.starts_with("include:") 1469 }); 1470 if !has_valid_scope { ··· 1709 .unwrap_or(true); 1710 if needs_consent { 1711 let consent_url = format!( 1712 + "/app/oauth/consent?request_uri={}", 1713 url_encode(&form.request_uri) 1714 ); 1715 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2346 2347 if needs_consent { 2348 let consent_url = format!( 2349 + "/app/oauth/consent?request_uri={}", 2350 url_encode(&form.request_uri) 2351 ); 2352 return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); ··· 2730 } 2731 let channel_name = channel_display_name(user.preferred_comms_channel); 2732 let redirect_url = format!( 2733 + "/app/oauth/2fa?request_uri={}&channel={}", 2734 url_encode(&form.request_uri), 2735 url_encode(channel_name) 2736 ); ··· 2755 } 2756 2757 let redirect_url = format!( 2758 + "/app/oauth/consent?request_uri={}", 2759 url_encode(&form.request_uri) 2760 ); 2761 (
+3 -3
src/oauth/endpoints/delegation.rs
··· 206 success: true, 207 needs_totp: Some(true), 208 redirect_uri: Some(format!( 209 - "/#/oauth/delegation-totp?request_uri={}", 210 urlencoding::encode(&form.request_uri) 211 )), 212 error: None, ··· 239 success: true, 240 needs_totp: None, 241 redirect_uri: Some(format!( 242 - "/#/oauth/consent?request_uri={}", 243 urlencoding::encode(&form.request_uri) 244 )), 245 error: None, ··· 374 success: true, 375 needs_totp: None, 376 redirect_uri: Some(format!( 377 - "/#/oauth/consent?request_uri={}", 378 urlencoding::encode(&form.request_uri) 379 )), 380 error: None,
··· 206 success: true, 207 needs_totp: Some(true), 208 redirect_uri: Some(format!( 209 + "/app/oauth/delegation-totp?request_uri={}", 210 urlencoding::encode(&form.request_uri) 211 )), 212 error: None, ··· 239 success: true, 240 needs_totp: None, 241 redirect_uri: Some(format!( 242 + "/app/oauth/consent?request_uri={}", 243 urlencoding::encode(&form.request_uri) 244 )), 245 error: None, ··· 374 success: true, 375 needs_totp: None, 376 redirect_uri: Some(format!( 377 + "/app/oauth/consent?request_uri={}", 378 urlencoding::encode(&form.request_uri) 379 )), 380 error: None,
+2 -2
src/oauth/endpoints/metadata.rs
··· 168 client_name: "PDS Account Manager".to_string(), 169 client_uri: base_url.clone(), 170 redirect_uris: vec![ 171 - format!("{}/", base_url), 172 - format!("{}/migrate", base_url), 173 ], 174 grant_types: vec![ 175 "authorization_code".to_string(),
··· 168 client_name: "PDS Account Manager".to_string(), 169 client_uri: base_url.clone(), 170 redirect_uris: vec![ 171 + format!("{}/app/", base_url), 172 + format!("{}/app/migrate", base_url), 173 ], 174 grant_types: vec![ 175 "authorization_code".to_string(),
+7 -4
src/oauth/endpoints/token/grants.rs
··· 94 )); 95 } 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(), 100 )); 101 } else { 102 None ··· 138 } else { 139 REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 140 }; 141 let token_data = TokenData { 142 did: did.clone(), 143 token_id: token_id.0.clone(), ··· 147 client_id: auth_request.client_id.clone(), 148 client_auth: stored_client_auth, 149 device_id: auth_request.device_id, 150 - parameters: auth_request.parameters.clone(), 151 details: None, 152 code: None, 153 current_refresh_token: Some(refresh_token.0.clone()),
··· 94 )); 95 } 96 Some(result.jkt) 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(), 101 )); 102 } else { 103 None ··· 139 } else { 140 REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 141 }; 142 + let mut stored_parameters = auth_request.parameters.clone(); 143 + stored_parameters.dpop_jkt = dpop_jkt.clone(); 144 let token_data = TokenData { 145 did: did.clone(), 146 token_id: token_id.0.clone(), ··· 150 client_id: auth_request.client_id.clone(), 151 client_auth: stored_client_auth, 152 device_id: auth_request.device_id, 153 + parameters: stored_parameters, 154 details: None, 155 code: None, 156 current_refresh_token: Some(refresh_token.0.clone()),
+73 -7
src/oauth/verify.rs
··· 42 http_uri: &str, 43 ) -> Result<VerifyResult, OAuthError> { 44 let token_info = extract_oauth_token_info(access_token)?; 45 let token_data = db::get_token_by_id(pool, &token_info.token_id) 46 .await? 47 - .ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?; 48 let now = chrono::Utc::now(); 49 if token_data.expires_at < now { 50 - return Err(OAuthError::InvalidToken("Token has expired".to_string())); 51 } 52 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()))?; 55 let config = AuthConfig::get(); 56 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 57 let access_token_hash = compute_ath(access_token); 58 - let result = 59 - verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?; 60 if !db::check_and_record_dpop_jti(pool, &result.jti).await? { 61 return Err(OAuthError::InvalidDpopProof( 62 "DPoP proof has already been used".to_string(), ··· 123 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 124 let now = chrono::Utc::now().timestamp(); 125 if exp < now { 126 - return Err(OAuthError::InvalidToken("Token has expired".to_string())); 127 } 128 let token_id = payload 129 .get("jti") ··· 191 pub error: String, 192 pub message: String, 193 pub dpop_nonce: Option<String>, 194 } 195 196 impl IntoResponse for OAuthAuthError { ··· 208 .headers_mut() 209 .insert("DPoP-Nonce", nonce.parse().unwrap()); 210 } 211 response 212 } 213 } ··· 228 error: "AuthenticationRequired".to_string(), 229 message: "Authorization header required".to_string(), 230 dpop_nonce: None, 231 })?; 232 let auth_header_trimmed = auth_header.trim(); 233 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 ··· 244 error: "InvalidRequest".to_string(), 245 message: "Invalid authorization scheme".to_string(), 246 dpop_nonce: None, 247 }); 248 }; 249 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); ··· 275 error: "use_dpop_nonce".to_string(), 276 message: "DPoP nonce required".to_string(), 277 dpop_nonce: Some(nonce), 278 }), 279 Err(OAuthError::InvalidDpopProof(msg)) => { 280 let nonce = generate_dpop_nonce(); ··· 283 error: "invalid_dpop_proof".to_string(), 284 message: msg, 285 dpop_nonce: Some(nonce), 286 }) 287 } 288 Err(e) => { ··· 296 error: "AuthenticationFailed".to_string(), 297 message: format!("{:?}", e), 298 dpop_nonce: nonce, 299 }) 300 } 301 }
··· 42 http_uri: &str, 43 ) -> Result<VerifyResult, OAuthError> { 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 + ); 50 let token_data = db::get_token_by_id(pool, &token_info.token_id) 51 .await? 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 + })?; 56 let now = chrono::Utc::now(); 57 if token_data.expires_at < now { 58 + return Err(OAuthError::ExpiredToken( 59 + "Token session has expired".to_string(), 60 + )); 61 } 62 if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { 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 + })?; 68 let config = AuthConfig::get(); 69 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 70 let access_token_hash = compute_ath(access_token); 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 + })?; 77 if !db::check_and_record_dpop_jti(pool, &result.jti).await? { 78 return Err(OAuthError::InvalidDpopProof( 79 "DPoP proof has already been used".to_string(), ··· 140 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; 141 let now = chrono::Utc::now().timestamp(); 142 if exp < now { 143 + return Err(OAuthError::ExpiredToken("Token has expired".to_string())); 144 } 145 let token_id = payload 146 .get("jti") ··· 208 pub error: String, 209 pub message: String, 210 pub dpop_nonce: Option<String>, 211 + pub www_authenticate: Option<String>, 212 } 213 214 impl IntoResponse for OAuthAuthError { ··· 226 .headers_mut() 227 .insert("DPoP-Nonce", nonce.parse().unwrap()); 228 } 229 + if let Some(www_auth) = self.www_authenticate { 230 + response 231 + .headers_mut() 232 + .insert("WWW-Authenticate", www_auth.parse().unwrap()); 233 + } 234 response 235 } 236 } ··· 251 error: "AuthenticationRequired".to_string(), 252 message: "Authorization header required".to_string(), 253 dpop_nonce: None, 254 + www_authenticate: None, 255 })?; 256 let auth_header_trimmed = auth_header.trim(); 257 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 ··· 268 error: "InvalidRequest".to_string(), 269 message: "Invalid authorization scheme".to_string(), 270 dpop_nonce: None, 271 + www_authenticate: None, 272 }); 273 }; 274 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); ··· 300 error: "use_dpop_nonce".to_string(), 301 message: "DPoP nonce required".to_string(), 302 dpop_nonce: Some(nonce), 303 + www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()), 304 }), 305 Err(OAuthError::InvalidDpopProof(msg)) => { 306 let nonce = generate_dpop_nonce(); ··· 309 error: "invalid_dpop_proof".to_string(), 310 message: msg, 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), 351 }) 352 } 353 Err(e) => { ··· 361 error: "AuthenticationFailed".to_string(), 362 message: format!("{:?}", e), 363 dpop_nonce: nonce, 364 + www_authenticate: None, 365 }) 366 } 367 }