+6
.env.example
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
-1
frontend/src/lib/serverConfig.svelte.ts
+2
-2
frontend/src/routes/ActAs.svelte
+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
+1
-1
frontend/src/routes/Admin.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/Comms.svelte
+1
-1
frontend/src/routes/Comms.svelte
+3
-3
frontend/src/routes/Controllers.svelte
+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
+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
+1
-1
frontend/src/routes/DelegationAudit.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
-527
frontend/src/routes/Home.svelte
-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
+1
-1
frontend/src/routes/InviteCodes.svelte
+3
-3
frontend/src/routes/Login.svelte
+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">·</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">·</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
+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
+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
+1
-1
frontend/src/routes/OAuthAccounts.svelte
+1
-1
frontend/src/routes/OAuthConsent.svelte
+1
-1
frontend/src/routes/OAuthConsent.svelte
+2
-2
frontend/src/routes/OAuthDelegation.svelte
+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
-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
+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> · <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> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
460
</p>
461
</div>
462
+1
-1
frontend/src/routes/OAuthPasskey.svelte
+1
-1
frontend/src/routes/OAuthPasskey.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
+1
-1
frontend/src/routes/RecoverPasskey.svelte
+1
-1
frontend/src/routes/RecoverPasskey.svelte
+3
-3
frontend/src/routes/Register.svelte
+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
+1
-1
frontend/src/routes/RegisterPasskey.svelte
+1
-1
frontend/src/routes/RepoExplorer.svelte
+1
-1
frontend/src/routes/RepoExplorer.svelte
+1
-1
frontend/src/routes/RequestPasskeyRecovery.svelte
+1
-1
frontend/src/routes/RequestPasskeyRecovery.svelte
+1
-1
frontend/src/routes/ResetPassword.svelte
+1
-1
frontend/src/routes/ResetPassword.svelte
+4
-4
frontend/src/routes/Security.svelte
+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')} →
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')} →
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
+1
-1
frontend/src/routes/Sessions.svelte
+1
-1
frontend/src/routes/Settings.svelte
+1
-1
frontend/src/routes/Settings.svelte
+1
-1
frontend/src/routes/TrustedDevices.svelte
+1
-1
frontend/src/routes/TrustedDevices.svelte
+11
-18
frontend/src/routes/Verify.svelte
+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
+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
+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
+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
+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
+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
+1
-1
frontend/src/tests/migration/atproto-client.test.ts
+55
-14
frontend/src/tests/mocks.ts
+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
+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
+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
+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
+1
-1
src/api/server/passkey_account.rs
+1
src/auth/mod.rs
+1
src/auth/mod.rs
+8
-8
src/comms/service.rs
+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
+17
-2
src/lib.rs
···
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
}
+3
-3
src/oauth/endpoints/delegation.rs
+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
+2
-2
src/oauth/endpoints/metadata.rs
+7
-4
src/oauth/endpoints/token/grants.rs
+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
+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
}